Repository navigation
[Python] Interpreter hangs at exit after pq.read_table(python_file_object) - thread pool teardown deadlock #50482
Description
Describe the bug, including details regarding any error messages, version, and platform.
Describe the bug, including details regarding any error messages, version, and platform.
Reading a parquet file by passing a Python file object to pyarrow.parquet.read_table
makes the interpreter hang forever at shutdown on my and other similar machines:
# repro.py - hangs at exit, every run
import pyarrow.parquet as pq
with open("any.parquet", "rb") as f:
pq.read_table(f)
print("done, exiting...") # prints - then the process never exits$ python repro.py
done, exiting...
# hangs forever; SIGKILL required
Passing a path instead of a file object does not hang:
pq.read_table("any.parquet") # exits cleanly, 8/8 runsuse_threads=False also avoids the hang even with a file object (8/8 clean), as does
pa.set_cpu_count(1), so this looks like a CPU thread-pool teardown deadlock that is
only triggered when the read went through a Python file handle (GIL interaction during
pool shutdown?).
How we found it
pandas.read_parquet (which internally opens the file and passes a handle to pyarrow)
was hanging intermittently (~50% of runs when reading 3 files, rarer with 1 file).
Bisecting pandas out of the equation led to the deterministic file-object reproducer
above. So this same bug likely explains "flaky CI hang" reports from pandas users.
Details
- The script body completes and
atexithandlers run; the hang is after that,
during interpreter/C-level finalization.faulthandler(SIGABRT) produces no dump. - The file content doesn't matter - reproduces with a 5-row single-column file.
- Closing the file object (or not) before exit makes no difference.
- A hung process shows the main thread plus ~24 CPU-pool threads all in
futex_do_wait(from/proc/<pid>/task/*/wchan), and ajemalloc_bg_thd:
python(futex_do_wait) jemalloc_bg_thd(futex_do_wait) python(futex_do_wait) x24 ...
Version matrix (same reproducer, 4 runs each; 124 = hung/timeout, 0 = clean exit)
| pyarrow | result |
|---|---|
| 25.0.0 | 124 124 124 124 |
| 24.0.0 | 124 124 124 124 |
| 23.0.1 | 124 124 124 124 |
| 22.0.0 | 124 0 124 124 |
25.0.0 + use_threads=False |
0 0 0 0 |
| 25.0.0 + path instead of file object | 0 0 0 0 |
(22.0.0 being intermittent rather than deterministic suggests a timing-dependent race
that became reliably lost at some point.)
Second interpreter build
Also reproduces on the distro CPython (Python 3.14.4, GCC 15.2.0 build,
/usr/bin/python3.14, fresh venv, pyarrow 25.0.0 wheel) - intermittent there
(3/6 runs hang) instead of deterministic, path-based reads clean 6/6. So this is
not specific to one interpreter build; build/timing differences only shift how
often the race is lost.
Environment
- pyarrow 25.0.0 (manylinux wheel from PyPI, installed via
uv) - Python 3.13.13 (python-build-standalone, Clang 22.1.3) - deterministic hang
- Python 3.14.4 (distro build, GCC 15.2.0) - intermittent hang (~50%)
- Linux 7.0.0-22-generic x86_64, glibc 2.43
- 24 CPUs
- pandas 3.0.3 (only for the original discovery path; not needed for the reproducer)
- Persists after OS upgrade + reboot (kernel 7.0.0-22 → 7.0.0-27, glibc 2.43-2ubuntu2)
Component(s)
Python
Component(s)
Python
Activity
raulcd commentedon Jul 13, 2026
Thank you very much for creating the issue.
I am not sure I understand the
Version matrixsection above, can you help me understand clearly whether this:Is this only happening with a specific parquet file or is it reproducible with a minimal generated parquet file?
mikamikasuki commentedon Oct 2, 2026
I have now independently reproduced this with a generated five-row Parquet file on macOS 15 (arm64), CPython 3.14.7, and the PyArrow 25.0.0 wheel. With 24 CPU threads, the file-object case timed out in 3 of 30 fresh processes; path reads exited cleanly in 12 of 12, and file-object reads with
use_threads=Falseexited cleanly in 12 of 12.I captured native stacks from three hung processes. The main thread was waiting in Arrow's CPU
ThreadPool::Shutdown; a worker was destroying aParquetFileFragment/PyReadableFile, then blocked inPyGILState_Ensure→ CPython thread-state attachment during finalization. This matches CPython's documented behavior that non-finalization threads attempting to attach during late interpreter finalization can be blocked permanently: https://docs.python.org/3.14/c-api/interp-lifecycle.htmlThe source path leads to
OwnedRefNoGILinpython/pyarrow/src/arrow/python/common.h, which reacquires the GIL to decref a retained Python object. I have a local candidate guard for the finalization path, but it still needs a source build and review of the check-to-GIL-acquire race, so I have not opened a PR.AI assistance was used to help trace the source and draft the candidate change; I ran the reproductions and examined the captured stacks. I would like to work on this issue.
deepkilo commentedon Oct 18, 2026
Additional Linux evidence from compatibility testing of a quantitative-research runtime. This was an observed process-exit failure investigated back to native cleanup, not a finding from source review alone. Our reproducer uses generated files and a Python filesystem wrapper; it is related evidence, not a claim that every file-object case has exactly the same ownership path.
With official PyArrow 26, a malformed fragment raises the expected
ArrowInvalid, but the process can still fail during interpreter shutdown. On Linux x86_64 our reduced case aborted on CPython 3.12.15 (12/12 four-fragment runs and 12/12 eight-fragment runs), timed out on 3.13.16 (4/4), and also timed out on unmodified 26 with 3.15.0 (3/3). Upgrading Python alone did not repair it.Self-contained reduced case; run in fresh processes with a timeout, since the symptom is at exit:
Our captured native stack passes through callback-capture destruction,
MergedGenerator/SerialReadahead, the Parquet reader /ReadRangeCache, andPyBuffer::~PyBuffer, thenPyGILState_EnsureduringPy_FinalizeEx. On 3.12 the forced unwind crosses a noexcept boundary and terminates; newer Python parks the late worker while Arrow's pool shutdown joins it.This also relates to #46945: checking
Py_IsInitializedor finalization before acquiring the GIL leaves a check-to-acquire race. A local candidate uses Python 3.15's PEP 788 view/token admission API forPyBuffer,NumPyBuffer, andOwnedRefNoGILcleanup. The complete Python-native wheel was rebuilt, with the eight computational Arrow/Parquet DSOs retained byte-for-byte. Eighteen bounded subprocess checks then exited normally, including malformed-input and valid-input controls; the malformed fragment still raises rather than being suppressed.That candidate is qualified only for main-interpreter, GIL CPython 3.15, not older Python, free-threaded builds or subinterpreters. A failed admission can also mean allocation failure, so the candidate's skipped cleanup may leak a reference in that case. I am sharing the mechanism and verification boundary for discussion, not claiming a universally ready upstream fix or a newly introduced 26 regression.
Disclosure: OpenAI Codex (GPT-6 family; exact variant/effort unavailable in this session) assisted with investigation, tool-driven reproduction, candidate work and this text. The stated runs and native stacks are recorded execution evidence, not a claim that the operator manually performed or reviewed every step. No maintainer ping or competing PR is intended.
apacheGH-50482: [Python] Wait for CPU tasks before interpreter finali…
MagicLex commentedon Oct 22, 2026
The same exit hang also happens when the scan goes through a
pyarrow.fs.PyFileSysteminstead of a Python file object (macOS arm64, CPython 3.12.12).Result on pyarrow 26.0.0, fresh processes, 8 in parallel, hung = printed the count and still alive after 20 s: 15/200 hung with
PyFileSystem, 0/200 withLocalFileSystem.Main thread of a hung process (
sample):PR #54467 likely covers this path; not tested.
Workaround: scan with a native filesystem (
LocalFileSystem, orSubTreeFileSystemover it) instead of a Python-implemented one.