ReviewOS

home-lang/home

node:async_hooks directory run aborts: infinite recursion in mimalloc macOS thread-local ($tlv$) theap resolution during thread teardown (not a UAF)

#79
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Summary

home test test/js/node/async_hooks aborts with SIGSEGV → abort (exit 134). Distinct from #78 (which is a BoringSSL TLS cert UAF) — this crash is inside mimalloc's thread-local heap lookup.

Repro

cd /tmp/bun-pinned
home-release-safe test test/js/node/async_hooks   # → exit 134

Faulting backtrace (macOS crash report)

libsystem_c.dylib   abort
home-release-safe   debug.handleSegfault          (SIGSEGV handler)
home-release-safe   _mi_heap_theap_peek(mi_heap_s const*)
home-release-safe   _mi_heap_theap_get_or_init(mi_heap_s const*)
home-release-safe   _mi_heap_theap_peek(mi_heap_s const*)
home-release-safe   _mi_heap_theap_get_or_init(mi_heap_s const*)
home-release-safe   _mi_heap_theap(mi_heap_s const*)
...

Analysis

  • The crash is in mimalloc's per-thread heap resolution (_mi_heap_theap*), which segfaults on a corrupted/invalid mi_heap_t* — the signature of a cross-thread free or a thread-teardown race (freeing/allocating on a thread whose mimalloc heap was already destroyed, or a stale heap pointer).
  • async_hooks exercises workers / threads / async contexts; the visible failing test is async context passes through > Bun.build plugin (the bundler running in an async/worker context).
  • Accumulation-dependent, culprit ≠ victim: no single async_hooks/*.test.* file crashes when run alone; only the full-directory run aborts.

Notes

  • Confirmed with home-release-safe (ReleaseSafe), so not a debug artifact.
  • Like #78, root-causing needs an ASan build; guard-page mimalloc won't help a cross-thread/UAF (it faults at the victim access, not the corrupting write).
10 days ago

Reclassified: this is infinite recursion in mimalloc's macOS thread-local resolution, not a UAF

Fresh backtrace on the current build (post-#78) shows the crash is a stack overflow from unbounded recursion, not heap corruption. The original "cross-thread UAF / needs ASan" framing is wrong — ASan is the wrong tool here.

The recursion (10001+ identical frames)

_mi_heap_theap_peek           (vendor/mimalloc/include/mimalloc/prim.h:529)
  -> _mi_heap_theap_get_peek  (src/heap.c:78)  -> _mi_thread_local_get / _mi_theap_*_safe
     -> mi_thread_init        (allocates)
        -> _mi_heap_theap     -> _mi_heap_theap_get_or_init -> _mi_heap_theap_peek ...  (repeat)

_mi_theap_default_safe()/_mi_theap_main_safe() (src/init.c:400/409) call mi_thread_init() when the thread's theap is uninitialized — and mi_thread_init allocates, which re-enters theap resolution while it's still uninitialized. No terminating base case → stack overflow → SIGSEGV → abort.

Why it recurses: macOS dynamic-TLS ($tlv$) allocates on access

nm zig-out/bin/home-release-safe shows:

s ___mi_theap_main$tlv$init      <- macOS dynamic thread-local ($tlv$)

mimalloc's theap pointers (__mi_theap_default/__mi_theap_main, declared mi_decl_thread = __thread) are accessed through macOS tlv_get_addr, which allocates the TLS block on first access per thread. prim.h:540 documents exactly this: "on macOS accessing the thread_local can cause allocation during thread shutdown (and reinitialize the thread)!" So: allocate → resolve theap → touch $tlv$ thread-local → that access allocates → resolve theap → … The break for this is mimalloc's MI_TLS_RECURSE_GUARD / the non-allocating os-TSD slot path (prim.h:342-410).

Scope / trigger

  • Deterministic, not corruption. Same PC repeats; MIMALLOC_GUARDED_SAMPLE_RATE is irrelevant (there is no corrupting write).
  • Accumulation + thread-teardown dependent. A minimal repro — Bun.build with an onLoad plugin run 5× inside AsyncLocalStorage.run — completes cleanly (ok=5). The crash only appears across the full directory, consistent with the macOS "allocate during thread shutdown re-initializes TLS" window being hit after enough worker-thread create/destroy churn.
  • Differential: real Bun 1.4.0 runs the same test/js/node/async_hooks directory clean (exit 0). The Zig orchestration that drives the bundler threads (threading/ThreadPool.zig run(), bundler/bundle_v2.zig, bundler_jsc/JSBundleCompletionTask.zig) is byte-for-byte identical to upstream — so the divergence is below the Zig layer: the mimalloc TLS model / recurse-guard in how Home's linked allocator resolves per-thread heaps during teardown, not a spot-fixable Zig bug.

Ensure the vendored mimalloc that Home links uses the non-allocating macOS TLS path (os-TSD slot model) or is built with MI_TLS_RECURSE_GUARD, so theap resolution during thread teardown cannot re-enter the allocator. This is a mimalloc build-configuration change (in the build/release/obj mimalloc that HOME_BUN_OBJ_ROOT provides), validated against the full async_hooks directory run — not a Zig source edit. Next diagnostic step for whoever picks this up: an instrumented build with a recursion depth guard in _mi_heap_theap_get_or_init to capture the exact teardown-time allocation site, rather than ASan.

Leaving open; retitling would help (s/SIGSEGV heap corruption/infinite recursion in macOS thread-local theap resolution/).

10 days ago

Update: ruled out two candidate fixes (pool-sharing, MI_TLS_RECURSE_GUARD)

Investigated the cheap leads before committing to a rebuild:

1. Thread-pool ownership is already correct. The JS Bun.build path (bundler/BundleThread.zig:120-126) passes the shared long-lived jsc.WorkPool.get() into BundleV2.init, not an owned per-build pool; the bundle thread is a spawn-once detached singleton. So "reuse the shared pool" is a non-fix — Home already does it. (Only the mimalloc heap is per-build: ThreadLocalArena.init() + defer heap.deinit() = mi_heap_new churn each build.)

2. MI_TLS_RECURSE_GUARD would not fix this. nm confirms Home's mimalloc uses the non-allocating fixed-slot TLS model (pthread TSD 96/97) for __mi_theap_default/__mi_theap_cached; only __mi_theap_main + the dynamic-slot backing (mi_thread_locals) are macOS $tlv$ dynamic TLS. The guard is absent, but its check is if (!_mi_process_is_initialized) return empty — and mid-run thread teardown has the process fully initialized, so the guard never fires on our path. Rebuilding mimalloc with it defined would not help.

Refined root cause: a free/alloc during a WorkPool thread's idle-exit teardown window (threading/ThreadPool.zig run() exits idle threads via wait() catch return), which touches $tlv$ dynamic TLS (__mi_theap_main / mi_thread_locals) that reallocates during shutdown → _mi_theap_*_safe → mi_thread_init re-init → recurse. mimalloc's own teardown-invalidation for exactly this (init.c thread_tld = MI_TLD_INVALID) is #if 0-disabled for macOS.

Remaining fix options: (C) instrumented build with a recursion-depth guard in _mi_heap_theap_get_or_init to capture the precise teardown allocation site, then move that free before mimalloc thread-done or off-thread; or a vendored-mimalloc source patch that sets a per-thread "done" flag so _mi_theap_default_safe returns the empty theap instead of re-running mi_thread_init during teardown. Both are heavier than a config flag. Still unresolved: why real Bun 1.4.0 is immune with the same allocator (likely it doesn't allocate in that window).

10 days ago

Revalidated the exact full-directory reproduction on current main (b4ddf3056) with a fresh ReleaseSafe build and the provisioned Node executable in PATH.

Results:

  • ReleaseSafe test/js/node/async_hooks: 111 pass, 3 copied upstream TODO, 0 fail, no SIGSEGV or abort
  • ReleaseFast same directory: 111 pass, 3 TODO, 0 fail
  • ReleaseFast single-process --rerun-each 10 accumulation stress: 1,110 pass, 30 repeated TODO, 0 fail, no allocator recursion/crash
  • all 74 AsyncLocalStorage-tracking dual-runtime cases execute against both Home and provisioned Node 24

The earlier apparent per-file failure on this host was not the historical crash: after the date rollover, PATH omitted the workspace Node toolchain, so the copied harness correctly returned nodeExe() === null and attempted to spawn "null". Restoring /Users/chris/.local/share/pantry/global/bin makes the exact suite green.

No workaround, assertion change, or allocator special-case was added. The crash is no longer reproducible after the intervening native runtime/ownership integration, so this tracker is complete. Parent status: #66.

Sign in to comment on this issue.