Repository navigation
Conversation
… teardown Backports microsoft/mimalloc 6def7be9 as a patch on the current pin. In the direct-thread-local flavour of mi_define_thread_local (glibc, musl, Windows), _get() returned the raw variable, which _mi_thread_locals_thread_done sets to NULL, so any access to a non-main heap's theap on a thread after its teardown dereferenced NULL (mi_thread_local_get_regular, mi_thread_local_set_regular, mi_thread_locals_expand). MI_DEBUG builds hit it inside _mi_thread_done itself whenever the exiting thread still had a theap of a non-main heap that was not the cached one. _get() now returns the initial value when the variable is NULL, as the pthreads flavour already did. The patch is registered in scripts/build/workarounds.ts so that configure fails with removal instructions once MIMALLOC_COMMIT moves to a pin that contains the upstream commit.
|
Warning Review limit reached
Next review available in: 53 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
|
Status: reproduced and fixed, self-review in progress.
|
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The one-line patch is a verbatim backport of an upstream mimalloc commit and the workarounds/test plumbing follows existing patterns exactly, but since it changes the global allocator's thread-teardown behavior a maintainer sign-off would still be worthwhile.
What was reviewed:
- Patch context matches the direct-thread-local
mi_define_thread_localmacro;_peek()is left unchanged so_mi_thread_locals_thread_donestill sees NULL and won't free the static empty array;mi_slot_fast's initval is NULL so its behavior is unchanged. patches:field onDependencyand theworkarounds.tsentry match the shape used by other deps; the new import ofdeps/mimalloc.tsintoworkarounds.tsintroduces no cycle.- New test drains stdout/stderr/exited concurrently, wires the worker
errorevent to a nonzero exit, and asserts stderr/stdout/exitCode in that order per harness conventions.
Extended reasoning...
Overview
This PR backports microsoft/mimalloc commit 6def7be9 as a one-line patch (patches/mimalloc/threadlocal-get-initval-fallback.patch) so the direct-thread-local flavour of name##_get() returns initval instead of NULL after _mi_thread_locals_thread_done() has freed the per-thread slot array. It wires the patch into scripts/build/deps/mimalloc.ts via the existing patches: mechanism, adds a self-obsoleting entry in scripts/build/workarounds.ts that fails configure once MIMALLOC_COMMIT moves off the current pin, and adds a Worker regression test in test/js/node/worker_threads/worker_destruction.test.ts.
Security risks
None identified. The patch narrows a NULL dereference into a fallback to a static empty array, which is the same state a fresh thread starts in and which every caller already handles. No new attack surface, no user-controlled input, no auth/crypto/permissions.
Level of scrutiny
High. mimalloc is Bun's global malloc replacement on Linux and the JS heap allocator everywhere; every allocation and free routes through it, and the affected code runs during thread teardown on every worker/JS thread that ever used a non-main heap. Even though the diff itself is a verbatim upstream backport with a very thorough mechanism analysis and a C harness reproducing all three release-build crash paths, allocator changes are the definition of a critical code path and warrant a maintainer's explicit sign-off rather than an automated approval.
Other factors
- The build-system changes are purely additive and mirror existing patterns (
patches:used by libarchive/highway/cares/etc.; theworkarounds.tsentry mirrors the neighboring entries and importsmimalloc.source(cfg)without creating an import cycle — onlyconfigure.tsimportsworkarounds.ts). - The regression test follows harness conventions (
test.concurrent,bunEnv/bunExe, concurrent pipe drain, error event wired to reject viaprocess.exit(2), exitCode asserted last). The author is transparent that the test only fails on debug (MI_DEBUG) builds without the patch — release builds need the C harness to reproduce — which is an acknowledged limitation of the automated coverage. - No CODEOWNERS entries cover these paths and no prior human review comments exist on the PR. The bug-hunting system found nothing. Deferring solely because the change sits in the allocator, not because of any concern with the diff itself.
|
On the #37367 overlap: that PR is the real fix, and it is already described that way in the Fix section. This one is the stop-gap on the current pin. The patched line is byte-identical to the one #37367's pin carries, so there is nothing to reconcile between the two:
The reason to carry it in the meantime is that debug builds abort on the current pin today (the Worker case in the test, and the Nothing to change from the review above. |
|
Updated 12:26 PM PT - Aug 13th, 2026
❌ @robobun, your commit 1117d28 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 38199That installs a local version of the PR into your bun-38199 --bun |
|
Folded into #37367: its pin (oven-sh/mimalloc |
…TLS crash (#37367) Moves the mimalloc pin to oven-sh/mimalloc `bun-dev3-v2` @ `be7eb3ff1`, which is: - oven-sh/mimalloc#15 — the fork synced with upstream `dev3` (261 commits: the #1271 audit fixes, the init/sub-process restructure, reworked theap teardown), fork features re-applied on top. Includes upstream's `thread_locals_get` fix: on Linux/Windows a thread that had used a non-main heap (JSC's structure heap, every `bun_alloc::Arena`) could NULL-deref in `mi_free`/teardown after mimalloc's own thread-done ran. Regression test added here (`worker_destruction.test.ts`, fails 4/4 on a debug build of main). - oven-sh/mimalloc#17 — the idle sweep's state lives on the tld instead of `__thread` variables. On emulated-TLS targets (our Android build, API < 29) the first `__thread` access mallocs, so reading the sweep guard from inside a page collect recursed until the stack was gone. Fixes #38051. - A rate-limited park (`purge_holes_min_interval`) is now swept when its window ends instead of at the scavenger's next unrelated wake (up to its 30 s safety net) — the end-of-burst park is the one that used to be left waiting. - A `MI_DEBUG_FULL`-only assertion exemption for the detached meta theap (aborted `test-heap-churn`/`test-heap-mt` in the fork's debug suite after the sync). Replaces #38168 and #38199 (both were `patches/mimalloc/*.patch` stop-gaps against the old pin). --- ### Measured (macOS arm64, release builds of this same commit, old pin vs new; paired rounds; memory is `Bun.unsafe.memoryFootprint`) | | Old → new | |---|---| | Retained by size class, 1-in-64 survivors | Identical | | Bare startup footprint | 6.54 → 6.75 MB (+0.2 MB, 5/5 pairs) | | express settled footprint after 300k requests, read after a forced full GC | 43.4 → 43.2 MB avg, 5 pairs (equal — the unforced reading that looked ~7 MB lower was GC timing, not the allocator) | | express peak footprint | No consistent direction | | express throughput | 89.5k → 90.1k rps (parity) | The startup-snapshot branch was also built against the equivalent (#16) and its suite passes; that surfaced one snapshot-writer fix, landed on that branch separately. CI is the regression sweep for this change.
Problem
mi_thread_local_get_regular(vendor/mimalloc/src/threadlocal.c:186, readingtls->countthrough NULL);MI_DEBUGbuilds (bun bd) abort with:_mi_thread_locals_thread_done()(threadlocal.c:205, run from_mi_thread_done, i.e. mimalloc's pthread key destructor / FLS callback) frees the thread's slot array and stores NULL into themi_thread_localsthread local. The direct-thread-local flavour ofmi_define_thread_local(threadlocal.c:54, what our glibc, musl and Windows builds compile) returns that NULL from_get(), and the three readers (mi_thread_local_get_regular,mi_thread_local_set_regular,mi_thread_locals_expand) dereference it. The pthreads flavour used on macOS and Android (threadlocal.c:45) already maps NULL back to the initial value, so those builds are not affected._mi_thread_doneitself. It drops the array (init.c:866) before it abandons the thread's theaps (init.c:888->mi_theap_collect_ex->mi_theap_is_valid->_mi_heap_theap_peek->_mi_thread_local_get), so any thread exiting while it still has a theap of a non-main heap that is not the one in_mi_theap_cachedaborts. In Bun that is a Worker that calledBun.TOML.parse/Bun.YAML.parse(parks a per-threadArenathat outlives the thread,src/runtime/api.rs:272) and then used any otherArena(deterministic, see the test), and it is thethreadlocal.c:184abort quoted in bundler: join in-flight pool tasks before tearing the bundle down #37480's bundler error path (10/10 on an unpatched debug build here, 0/10 with this patch; that PR's own fix is about the teardown ordering and still stands).mi_free->mi_free_try_collect_mtfree.c:417/419->mi_abandoned_page_try_reclaimfree.c:355or_mi_arenas_page_try_reabandon_to_mappedarena.c:1260->_mi_page_associated_theap_peekprim-tls.h:475), or allocating from one (_mi_heap_theap_get_or_initheap.c:101). Bun's own TLS destructors only free main-heap memory today, so this is latent for us (JSC's structure heap and everybun_alloc::Arenaare non-main heaps, so every JS thread has the NULLed array at exit); the C harness below exercises it directly.Fix
patches/mimalloc/threadlocal-get-initval-fallback.patch: backport of microsoft/mimalloc@6def7be ("fix thread_locals_get"), one line: the direct-thread-local_get()returnsinitvalwhen the variable is NULL, which is what the pthreads flavour already does._peek()is unchanged, so_mi_thread_locals_thread_donestill sees the NULL and does not free the static empty array.mi_thread_locals_empty(count 0) and return NULL, which is the exact state of a thread that never used a non-main heap and which every caller already handles (_mi_page_associated_theap_peekreturns NULL, the reclaim/reabandon paths bail out,_mi_heap_theap_get_or_initcreates a theap). A write after teardown goes throughmi_thread_locals_expand, which already treats count 0 as "allocate fresh" (threadlocal.c:105), and the re-initialised thread re-registers its key so the next destructor round frees that array again.mi_slot_fasthasinitvalNULL, so its behaviour is unchanged; the pthreads branch is not touched. The fork pin mimalloc: sync the fork with upstream dev3, fix the Android emulated-TLS crash #37367 moves to (oven-sh/mimalloc 49182d59) already contains this commit, so this patch is the stop-gap until that lands and gets dropped with it.scripts/build/workarounds.tsgets an entry whoseexpectedToBeFixedtrips as soon asMIMALLOC_COMMITis anything other than the pin this patch was written against, so a bump (mimalloc: sync the fork with upstream dev3, fix the Android emulated-TLS crash #37367) fails configure with "delete the patch" instructions instead of merging cleanly and then failinggit applyon every fresh fetch (what happened in build(mimalloc): drop strnlen-oob-read.patch, already fixed at pinned commit #34335). Verified by bumping the constant locally: configure fails with that message; with the real pin it is a no-op andbuild.ninjais unchanged.test/js/node/worker_threads/worker_destruction.test.ts, "a Worker that used several allocator heaps exits cleanly": 4/4 failures on a debug build of main with the assertion above in stderr, 3/3 passes (and the whole file passes) with the patch. A release build passes it both ways, since the assertion that makes_mi_thread_doneitself read the array only exists inMI_DEBUGbuilds; the release-only paths are covered by the C harness.vendor/mimalloc/src/static.cbuilt with exactly the flagsmimalloc.tsuses for a Linux release build (-O2 -DNDEBUG -DMI_BUILD_RELEASE -DMI_MALLOC_OVERRIDE -ftls-model=initial-exec ...): threads allocate from anmi_heap_newheap and exit with a pthread key destructor that runs after mimalloc's. Unpatched: 20/20 SIGSEGV atthreadlocal.c:186for each of the three paths (free after teardown viaarena.c:1260;mallocthen free viafree.c:355;mi_heap_mallocviaheap.c:101). Patched: 0/20 for each. The same program and a two-heaps-then-exit variant built with-DMI_DEBUG=3: unpatched aborts with thetls!=NULLassertion, patched runs clean.bun bdre-fetches the dep and applies the patch (vendor/mimalloc/.refchanges,threadlocal.c:57carries the new line);heapStats-mimalloc.test.tsandworker-terminate-lifetime.test.tsstill pass on the patched build.Background
mi_heap_t(the main heap behindmalloc, or a private one frommi_heap_new/mi_heap_new_in_arena; Bun'sbun_alloc::Arenaand JSC's structure heap are private heaps) owns no pages itself. Each thread that allocates from a heap gets its ownmi_theap_tfor it, which holds that thread's pages; allocation and most frees go through the calling thread's theap for the block's heap.heap->theap;mi_thread_localsis the thread local pointing at that array,mi_thread_locals_emptyis the static zero-length array a fresh thread starts with, and_mi_thread_local_get/_setare the accessors_mi_heap_theap_peek,_mi_page_associated_theap_peekand_mi_heap_theap_get_or_inituse._mi_thread_doneruns among the first destructors when a thread exits. It frees the slot array, then abandons the pages of the thread's theaps (other threads can later pick them up) and frees the theaps. Destructors registered later (WTF's, libc++abi's, Rust's on musl) still run on the thread afterwards, and an allocation from one of them re-initialises the thread's main theap; neither of those restores the slot array, which is the window this patch handles._mi_theap_cached: a one-entry per-thread cache of the last theap returned by the heap API, consulted before the slot array. A heap being destroyed clears it, which is why the repro needs a second heap after the parked one.patches/<dep>/: files listed in a dep'spatches:aregit apply'd over the fetched archive and hashed into the source identity (scripts/build/source.ts,fetch-cli.ts), so editing or adding one re-fetches the dep; the fork itself is not writable from here, as with mimalloc: fold every thread's theap into the subproc stats aggregate #34739.scripts/build/workarounds.tsis the build system's registry of temporary fixes: configure evaluates each entry'sexpectedToBeFixedand fails with the entry's cleanup text once it returns true.C harness
Unpatched stacks (release flags):