Skip to content

Segfaults from the bundled mimalloc v3 when the first thread exits and when co-loaded on macOS #151

Closed

Description

@bestend

Summary

aerospike-native 1.0.0 sets mimalloc::MiMalloc as the Rust global allocator (src/lib.rs). The locked mimalloc 0.1.52 / libmimalloc-sys 0.1.49 bundles mimalloc v3.3.2 (MI_MALLOC_VERSION 30302), which has two known bugs that kill the Python process with SIGSEGV:

  1. All platforms: if the first thread that used aerospike_native exits, the next allocation on another thread segfaults in mi_thread_init (mimalloc >= 3.3.0 causes segmentation faults when used from multiple threads microsoft/mimalloc#1287, fixed in mimalloc 3.4.1).
  2. macOS only: when another library in the same process bundles its own mimalloc v3, e.g. pyarrow 24.0.0, the two copies share a fixed TLS slot and corrupt each other's heap (Apple FIXED_SLOT causes SIGABRT when multiple statically-linked instances coexist — no cflag can select THREAD_LOCAL microsoft/mimalloc#1301, [Python] pyarrow 24.0.0 regression: co-loading a native extension bundling mimalloc v3 SIGSEGVs in bundled mimalloc at interpreter teardown (macOS arm64, py3.14); 22/23 unaffected apache/arrow#50428).

Neither needs an Aerospike server to reproduce. Building with the v2 feature of the mimalloc crate fixed both in our tests (see "Suggested fix").

Environment

  • aerospike-native 1.0.0 wheels from PyPI
  • macOS 27.0.1 arm64 (Apple M5 Pro), Python 3.11.15 and 3.14.5
  • Linux aarch64 and x86_64 in python:3.11-slim and python:3.12-slim containers

1. Segfault after the first thread exits (all platforms)

import threading

mods = {}


def load():
    import aerospike_native

    mods["an"] = aerospike_native


t = threading.Thread(target=load)
t.start()
t.join()  # the thread that first used aerospike_native has exited
an = mods["an"]


def work():
    for _ in range(300):
        [an.Key("ns", "s", f"k{j}") for j in range(50)]
        an.ClientPolicy()


threads = [threading.Thread(target=work) for _ in range(8)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print("OK")

Exit code 139 (SIGSEGV) in 3 of 3 runs on every platform above. Importing aerospike_native on the main thread first avoids it (5 of 5 runs OK on macOS and Linux x86_64). pyarrow is not involved; a script that does the same with only pyarrow 24.0.0 / 25.0.1 / 26.0.0 runs fine.

Backtrace on macOS (lldb, addresses removed):

* thread #3, stop reason = EXC_BAD_ACCESS (code=1, address=0x18)
  * frame #0: _native.cpython-311-darwin.so`mi_thread_init + 484
    frame #1: _native.cpython-311-darwin.so`_mi_malloc_generic + 760
    frame #2: _native.cpython-311-darwin.so`mi_theap_malloc_zero_aligned_at_generic + 428
    frame #3: _native.cpython-311-darwin.so`pyo3::types::boolobject::_$LT$impl$u20$pyo3..conversion..FromPyObject$u20$for$u20$bool$GT$::extract::h3b6aefcc5697dcb3 + 1292
    frame #4: _native.cpython-311-darwin.so`_$LT$_native..record..PythonValue$u20$as$u20$pyo3..conversion..FromPyObject$GT$::extract::h0e5ab896cec38955 + 120
    frame #5: _native.cpython-311-darwin.so`_native::record::Key::__pymethod___new____::h6a02330659df4f20 + 228

This matches microsoft/mimalloc#1287: mimalloc 3.3.0 to 3.3.2 treats the first thread that initializes it as the main thread, and allocations on other threads crash after that thread exits. It is fixed in mimalloc 3.4.1. pyarrow 25.0.0 shipped the same bug and fixed it in 25.0.1 by moving to mimalloc 3.4.1 (apache/arrow#50471, same mi_thread_init / _mi_malloc_generic / mi_theap_malloc_zero_aligned_at_generic frames and fault address 0x18).

In real code this happens, for example, when the first use is inside a ThreadPoolExecutor that is shut down and a later ThreadPoolExecutor uses aerospike_native again (SIGSEGV in 3 of 3 runs on macOS arm64 and Linux x86_64). Using it from the main thread after the first pool is gone did not crash in our runs; the crash needs a new non-main thread.

2. Segfault next to pyarrow 24.0.0 (macOS only)

import aerospike_native as an
import pyarrow as pa

for _ in range(20000):
    policy = an.ClientPolicy()
    policy.max_conns_per_node = 100
    keys = [an.Key("ns", "s", f"k{j}") for j in range(50)]
    pa.array(list(range(10000)))
print("OK")
pyarrow macOS arm64, Python 3.11 and 3.14 Linux
24.0.0 SIGSEGV in 5 of 5 runs. Also with the reversed import order, with pandas string arrays, and with 8 threads OK
25.0.1 OK in 5 of 5 runs
26.0.0 OK in 5 of 5 runs

The native stack shows _mi_malloc_generic recursing until the stack overflows. Changing the import order or setting ARROW_DEFAULT_MEMORY_POOL=system does not reliably avoid it.

On Apple platforms mimalloc v3 keeps its thread-local heap pointer in a fixed TLS slot that every loaded image shares, without checking which copy owns it (microsoft/mimalloc#1301, microsoft/mimalloc#1302). mimalloc v2 only used the fixed slot in MI_MALLOC_OVERRIDE builds. pyarrow 25.0.1 builds its mimalloc with thread-local variables on macOS (apache/arrow#50428, apache/arrow#50549), so this case is gone with newer pyarrow. aerospike_native still uses the fixed slot, though, so it can collide with any other extension that bundles mimalloc v3 with default settings. apache/arrow#50428 was reported with another PyO3 extension that uses mimalloc v3 as its global allocator.

Suggested fix

Use mimalloc v2 until a mimalloc crate release bundles mimalloc 3.4.1 or later. The latest release, mimalloc 0.1.52 / libmimalloc-sys 0.1.49, still bundles v3.3.2.

mimalloc = { version = "0.1", features = ["v2"] }

With this one-line change, local builds of v1.0.0 passed every reproduction above, while the same builds without the change kept crashing:

Local build of v1.0.0 Thread exit, macOS arm64 Thread exit, Linux aarch64 Next to pyarrow 24.0.0, macOS arm64
As released (mimalloc = "0.1", bundles mimalloc 3.3.2) SIGSEGV in 5 of 5 runs SIGSEGV in 5 of 5 runs (PyPI wheel) SIGSEGV in 5 of 5 runs
features = ["v2"] (bundles mimalloc 2.3.2) OK in 5 of 5 runs OK in 5 of 5 runs OK in 5 of 5 runs

The macOS builds used maturin build --release with Python 3.11. The Linux build used the ghcr.io/pyo3/maturin image and was tested in python:3.11-slim. The pyarrow column also covers the reversed import order, pandas string arrays and 8 threads.

One unrelated thing we ran into: on macOS 27 our local build with the v2 change and the default strip = "debuginfo" failed to load with mis-aligned LINKEDIT string pool from dyld. Building with CARGO_PROFILE_RELEASE_STRIP=none avoided it. The released 1.0.0 wheel and our stripped build without the change loaded fine, so this may be specific to our toolchain (rustc 1.96.0), but it may be worth checking that the macOS wheels load on recent macOS.

Other PyO3 projects worked around the same problem by switching to mimalloc v2 (see the commits linked from microsoft/mimalloc#1287). Dropping the custom global allocator is another option.

References

Activity

  1. paul-brackin commented on Oct 19, 2026

    @paul-brackin
    Collaborator

    @bestend thanks for filing this issue. I reviewed it & concur with your analysis on the mimalloc bug. We will get this addressed in the next release. The stripping issue appears to be caused by stricter alignment enforcement in macOS 27; macOS 27's dyld rejects a misaligned string pool, but only in images built with the macOS 27 SDK. The solution there is prolly to just leave the macOS images unstripped.

  2. paul-brackin commented on Oct 19, 2026

    @paul-brackin
    Collaborator

    Fixed by 152

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions