Skip to content

Apple FIXED_SLOT causes SIGABRT when multiple statically-linked instances coexist — no cflag can select THREAD_LOCAL #1301

Description

@shulaoda

Summary

On macOS, mimalloc v3 unconditionally selects MI_TLS_MODEL_FIXED_SLOT for the per-thread theap pointer. This makes it impossible to statically link mimalloc into multiple shared libraries loaded in the same process (e.g. Node.js napi addons) — the second library to load crashes immediately with SIGABRT.

The only alternative reachable via cflag (-DMI_HAS_TLS_SLOT=0MI_TLS_MODEL_DYNAMIC_PTHREADS) has a separate crash on process exit with long-lived background threads.

MI_TLS_MODEL_THREAD_LOCAL (the Linux/FreeBSD default, and v2's macOS non-override default) would fix both issues, but there is currently no way to select it on Apple without patching prim.h source.

We'd like to request either:

  1. Adding #ifndef guards to the MI_TLS_MODEL_* defines so downstream can override via -D, or
  2. Making THREAD_LOCAL + RECURSE_GUARD the default on Apple for non-override builds (matching v2 behavior)

Context: napi addons

napi-rs is the standard Rust framework for building Node.js native addons. Each addon compiles to a .node shared library (dlopen'd by Node.js). It is common to use mimalloc as the Rust global allocator:

#[global_allocator]
static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;

This statically links mimalloc into each .node file. When a Node.js process loads multiple such addons, multiple independent mimalloc instances coexist in the same process. MI_OVERRIDE is OFF — mimalloc is not replacing libc malloc, just serving Rust allocations within each addon.

Problem 1: FIXED_SLOT — 100% SIGABRT on second addon load

Mechanism

prim.h:373-377 unconditionally selects FIXED_SLOT on Apple:

#elif defined(__APPLE__) && MI_HAS_TLS_SLOT && !defined(__POWERPC__)
  #define MI_TLS_MODEL_FIXED_SLOT           1
  #define MI_TLS_MODEL_FIXED_SLOT_DEFAULT   108
  #define MI_TLS_MODEL_FIXED_SLOT_CACHED    109

TCB[108] is a per-thread slot, but it is shared across all loaded images. When two addons each statically link mimalloc v3:

  1. Addon A loads → mimalloc initializes → writes heap_A to TCB[108]
  2. Addon B loads → mimalloc reads TCB[108] → finds heap_A (non-NULL)
  3. mi_theap_is_initialized() (internal.h:573) checks only theap != NULL && theap->heap != NULLno ownership verification
  4. mi_thread_init() (init.c:717) sees "initialized" → early-returns → B skips its own per-thread setup
  5. B's code runs with A's heap pointer but B's own uninitialized per-image state (theap_main, heap_main, __mi_theap_main are all mi_decl_hidden) → inconsistency → SIGABRT
sequenceDiagram
  participant TCB as TCB[108] (shared)
  participant A as Addon A mimalloc
  participant B as Addon B mimalloc

  A->>TCB: write heap_A
  A-->>A: ✅ initialized

  B->>TCB: read → heap_A (non-NULL)
  Note over B: mi_theap_is_initialized(heap_A) → true<br/>(no ownership check)<br/>→ skip B's own init
  Note over B: B's per-image state is zeroed<br/>+ A's heap data<br/>→ 💥 SIGABRT
Loading

Reproduction

Two minimal napi addons, each with #[global_allocator] static ALLOC: mimalloc::MiMalloc, loaded in the same Node.js process:

$ node -e "require('./addon-a.node'); require('./addon-b.node')"
addon-a: ✅ loaded, TCB[108] = 0x103fa0400
addon-b: 💥 exit=134 (SIGABRT)

100% deterministic. Standalone reproduction: https://github.com/shulaoda/mimalloc-v3-repro

Note: v2 did not have this problem

v2's MI_TLS_SLOT (slot 89) is gated on MI_MALLOC_OVERRIDE (prim.h:350-351):

#if defined(MI_MALLOC_OVERRIDE)
#if defined(__APPLE__)
  #define MI_TLS_SLOT  89

When MI_MALLOC_OVERRIDE is not defined (standard for napi addons), v2 falls through to the #else branch (prim.h:413-420) which uses __thread _mi_heap_default — a mi_decl_hidden thread-local variable with per-image isolation. v3 made FIXED_SLOT unconditional, removing this safety.

Problem 2: DYNAMIC_PTHREADS — SIGABRT on process exit with background threads

Attempting to work around Problem 1

The only cflag that can skip the FIXED_SLOT branch is -DMI_HAS_TLS_SLOT=0, which routes Apple to MI_TLS_MODEL_DYNAMIC_PTHREADS. This fixes Problem 1 (each image gets its own pthread_key).

However, it introduces a new crash during process exit.

Mechanism

At process exit, mi_process_done() (init.c:1190) calls mi_tls_slots_done(), which deletes the pthread keys:

static void mi_tls_slots_done(void) {
  if (_mi_theap_default_key != 0) {
    pthread_key_delete(_mi_theap_default_key);
    _mi_theap_default_key = 0;                    // zeroed
  }
  // ...
}

But mi_tls_slots_init() uses mi_atomic_do_once (atomic.h:557) — a one-time flag that never resets. After mi_tls_slots_done zeros the key, any subsequent call to mi_tls_slots_init sees "already done" and skips key creation.

POSIX exit() runs cleanup callbacks on the main thread while other threads continue running. Background threads (in our case: rayon workers from the oxc compiler toolchain) are still alive. When they next allocate:

  1. _mi_theap_default()_mi_theap_default_key == 0 → returns NULL
  2. Slow path → mi_thread_init() → allocates a new theap (via mmap, OK)
  3. _mi_theap_default_set(theap) → calls mi_tls_slots_init() → do_once says "already done" → skip
  4. if (_mi_theap_default_key != 0) pthread_setspecific(...) → key IS 0 → skip — theap is not stored
  5. Returns to _mi_malloc_generic_mi_theap_default() → still NULL → returns NULL
  6. Rust's handle_alloc_error → SIGABRT
sequenceDiagram
  participant Dtor as Main thread (exit cleanup)
  participant Key as _mi_theap_default_key
  participant Flag as do_once flag (irreversible)
  participant Worker as Background thread

  Dtor->>Key: mi_tls_slots_done(): delete key, set to 0
  Note over Flag: still "done" (never resets)

  Note over Worker: still alive (exit() doesn't stop other threads)
  Worker->>Key: alloc → read key → 0 → NULL
  Worker->>Worker: slow path: allocate heap (OK)
  Worker->>Flag: mi_tls_slots_init() → do_once: "already done" → skip
  Worker->>Key: if (key != 0) setspecific... → 0 → skip
  Note over Worker: heap allocated but not stored<br/>_mi_theap_default() still NULL<br/>→ alloc returns NULL → 💥 SIGABRT
Loading

Reproduction

Single addon with 4 background threads doing a tight alloc loop:

$ node test-mode-b.mjs
Finished, allocated 4096 bytes on main thread
memory allocation of 64 bytes failed
exit=134 (SIGABRT)

Crashes on first run. In real-world usage (rolldown bundler with rayon workers from oxc), the crash rate is 5-15% per CI run on macOS because rayon workers are usually parked — they only hit the deleted key if they happen to allocate during the exit window.

What we need: THREAD_LOCAL on Apple

MI_TLS_MODEL_THREAD_LOCAL solves both problems:

FIXED_SLOT DYNAMIC_PTHREADS THREAD_LOCAL
Multi-instance isolation ❌ shared TCB slot ✅ per-image key ✅ per-image __thread variable
Process exit safety ✅ (OS-managed slot) ❌ (key deleted + do_once irreversible) ✅ (__thread has no "delete" operation)

Combined with MI_TLS_RECURSE_GUARD (which uses a plain _mi_process_is_initialized bool to prevent recursion during dyld TLV first-access), this is safe on macOS.

This is not a novel configuration — it's the default on Linux/FreeBSD/NetBSD, and it was the effective default on macOS in v2 (non-override mode).

The problem: no way to select THREAD_LOCAL on Apple via cflags

The Apple branch in prim.h:373 is an unconditional #define with no #ifndef guard:

#elif defined(__APPLE__) && MI_HAS_TLS_SLOT && !defined(__POWERPC__)
  #define MI_TLS_MODEL_FIXED_SLOT           1      // no #ifndef — cannot be overridden
  • -DMI_TLS_MODEL_THREAD_LOCAL=1 has no effect — FIXED_SLOT is defined unconditionally in the #elif body, and it appears first in the consumer cascade (prim.h:416), so it takes priority
  • -DMI_HAS_TLS_SLOT=0 skips the FIXED_SLOT branch but lands on DYNAMIC_PTHREADS → Problem 2

We currently work around this by patching prim.h at build time in our build.rs, but this is fragile and will break whenever the upstream selector block changes.

Proposed fix

Add a #ifndef-guarded flag MI_APPLE_TLS_THREAD_LOCAL and insert a single new #elif between the Windows and FIXED_SLOT branches. The original cascade is untouched — just one #elif inserted in the middle:

+// Set MI_APPLE_TLS_THREAD_LOCAL=1 to use THREAD_LOCAL + RECURSE_GUARD
+// on Apple instead of the default FIXED_SLOT. Needed when multiple
+// images in one process each statically link mimalloc (e.g. napi addons).
+#ifndef MI_APPLE_TLS_THREAD_LOCAL
+#define MI_APPLE_TLS_THREAD_LOCAL  0
+#endif
+
 #if defined(_WIN32)
   #define MI_TLS_MODEL_DYNAMIC_WIN32        1
+#elif defined(__APPLE__) && MI_APPLE_TLS_THREAD_LOCAL && !defined(__POWERPC__)
+  #define MI_TLS_MODEL_THREAD_LOCAL         1
+  #ifndef MI_TLS_RECURSE_GUARD
+  #define MI_TLS_RECURSE_GUARD              1
+  #endif
 #elif defined(__APPLE__) && MI_HAS_TLS_SLOT && !defined(__POWERPC__)
   #define MI_TLS_MODEL_FIXED_SLOT           1
   #define MI_TLS_MODEL_FIXED_SLOT_DEFAULT   108
   #define MI_TLS_MODEL_FIXED_SLOT_CACHED    109
 #elif defined(__APPLE__) || defined(__OpenBSD__) || defined(__ANDROID__)
   #define MI_TLS_MODEL_DYNAMIC_PTHREADS     1
 #else
   #define MI_TLS_MODEL_THREAD_LOCAL         1
 #endif

New #elif sits between Windows and FIXED_SLOT. #elif evaluates in order — when MI_APPLE_TLS_THREAD_LOCAL=1 the new branch matches first and FIXED_SLOT is skipped; when 0 (default) it's skipped and FIXED_SLOT matches as before. No existing line is modified.

Usage:

target_compile_definitions(mimalloc PRIVATE MI_APPLE_TLS_THREAD_LOCAL=1)

This is:

  • Zero changes to existing code — the original #if/#elif/#else/#endif block is untouched, just prefixed with one new #if + #elif
  • Follows the MI_HAS_TLS_SLOT #ifndef pattern — familiar to mimalloc contributors
  • Does not affect MI_HAS_TLS_SLOT — thread-id fast path via mi_prim_tls_slot(0) is unchanged
  • Does not route to DYNAMIC_PTHREADS — avoids the exit-time key-deletion problem (Problem 2)

Environment

  • mimalloc: v3.3.2 (30b2d9d8)
  • Platform: macOS aarch64 (Apple Silicon) — tested. x86_64 uses same slot 108 via %gs:, likely affected but not verified.
  • Rust bindings: mimalloc-safe — Rust crate wrapping mimalloc, used as #[global_allocator] in napi addons, MI_OVERRIDE=OFF
  • Addon framework: napi-rs — each addon compiles to a .node shared library loaded via dlopen
  • Downstream:
    • rolldown — Mode B reproduced in CI: action run
    • vite-plus#1581 — Mode A triggered when multiple addons coexist
    • Any project using multiple napi addons with mimalloc

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