[Python][C++] SIGSEGV: NULL deref in bundled mimalloc mi_thread_init when first pyarrow import and first allocation happen on a short-lived thread
- Dominant language
- C++
- Stars
- 17.1k
- Forks
- 4.3k
- Avg merge
- 3d 13h
- Merged PRs (30d)
- 88
Description
**Components:** Python, C++
## Environment
- pyarrow 25.0.0 (PyPI wheel, bundled mimalloc), pandas + pyarrow dataset/parquet path
- Python 3.14.4, CPython, Linux x86_64 (Ubuntu-family, kernel 7.0.0), uvicorn/FastAPI service
- Not OOM (VmRSS 161 MB at crash, process peak 130 MB), not disk.
## Summary
If the **first import of pyarrow and Arrow's first allocation happen on a short-lived worker thread that subsequently exits**, a later allocation on a *new* thread crashes with SIGSEGV inside the bundled mimalloc's per-thread heap initialisation. When the import + first allocation happen on the main thread (or any thread that stays alive), the same workload is stable under heavy concurrency.
This bites any FastAPI/uvicorn-style service that lazily imports pandas/pyarrow inside a request handler: anyio's worker threads have a 10-second idle lifetime (`WorkerThread.MAX_IDLE_TIME`), so the import lands on a thread that is pruned seconds later, and the next burst of traffic on fresh threads segfaults the process.
## Crash
From a core dump (gdb, `thread apply all bt`; crashing thread was the newest of 23):
```
#5 mi_thread_init () libarrow.so.2500
#6 _mi_malloc_generic ()
#8 arrow::BaseMemoryPoolImpl::Allocate(...)
#12 arrow::AllocateEmptyBitmap(...)
#14 parquet::SerializedFile::PreBuffer(...)
#45 pyarrow._dataset.Scanner.to_table
#49 _PyEval_EvalFrameDefault
```
Disassembly at the fault: `mov (%rdx),%rax` then `=> mov 0x18(%rax),%rax` with **rax == 0** — a NULL dereference inside `mi_thread_init`.
## Isolation matrix (3 runs per configuration, deterministic)
| pyarrow import | first Arrow allocation | outcome |
|---|---|---|
| throwaway thread (exits) | same throwaway thread | **SEGV 3/3** |
| main thread | throwaway thread | survived 3/3 |
| throwaway thread | main thread | survived 3/3 |
| main thread | main thread | survived 3/3 |
The trigger is **thread identity at load/first-allocation time, not concurrency**: with the import on the main thread, 8 concurrent fresh threads performed 6,000 parquet reads with no fault.
## Reproduction shape
```python
import threading
def load_and_read():
import pyarrow.dataset as ds # first pyarrow import in the process
ds.dataset("some.parquet").to_table() # first Arrow allocation
t = threading.Thread(target=load_and_read)
t.start(); t.join() # thread exits — heap/TLS torn down
# later: allocations from NEW short-lived threads (e.g. anyio worker threads
# serving an ASGI app) crash in mi_thread_init.
```
End-to-end reproduction (our service shape): uvicorn app whose handler lazily imports pandas and reads parquet; hammer with 21 concurrent GETs per burst, 12 s idle between bursts (idle > anyio's 10 s worker lifetime so each burst gets fresh threads). Pre-workaround the process died with signal 11 on the third burst, reproducibly. Post-workaround: 10 bursts, 210 requests, all 200.
## Hypothesis
mimalloc's process-wide initialisation appears to bind state to the thread that performed it; when that thread exits, its TLS/heap teardown leaves a structure NULL that `mi_thread_init` later dereferences on a new thread. (We can share the full backtrace and the isolation script on request.)
## Workarounds (verified)
- Import pyarrow and perform one real allocation on the **main thread** before any worker-thread use (we ship this as a startup warm-up).
- `ARROW_DEFAULT_MEMORY_POOL=system` (set before import) also sidesteps it, at the cost of changing the allocator globally.
---
*Reporter context: hit in production behind FastAPI/uvicorn; root-caused from an apport core dump; happy to provide the isolation script, full `thread apply all bt`, and library build info.*
Contributor guide
Research direction
Start with the supplied isolation matrix and reproduction shape, then trace the path from pyarrow.dataset.Scanner.to_table through Arrow's memory pool to bundled mimalloc's mi_thread_init. Use the reported gdb backtrace and short-lived-thread scenarios to compare behavior before and after a fix. Done means the reproduction no longer segfaults when initialization occurs on an exiting worker thread.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, python
- Domain
- backend, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100