Skip to content

purge-holes: keep sweep state off __thread statics (per-page guard, counters on the tld) - #18

Closed
dylan-conway wants to merge 2 commits into
bun-dev3-v2from
claude/no-tls-on-alloc-path
Closed

purge-holes: keep sweep state off __thread statics (per-page guard, counters on the tld)#18
dylan-conway wants to merge 2 commits into
bun-dev3-v2from
claude/no-tls-on-alloc-path

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Aug 13, 2026

Copy link
Copy Markdown
Member

The hole-sweep re-entrancy guard mi_purging_holes is read from mi_page_free_collect_ex — the allocation slow path — and it plus the three mi_holes_sweep_* values were mi_decl_thread statics. On emulated-TLS targets (Android below API 29, which is what Bun currently ships) the first access to a __thread variable calls malloc to allocate the thread's TLS block, so a fresh thread whose first slow-path allocation was also its first touch of that variable recursed malloc → mi_page_free_collect_ex → __emutls_get_address → malloc → … until it exhausted its stack. In Bun this showed up as silent SIGSEGVs on JSC's Wasm (OMG) compiler threads on Android x64; the core shows exactly that cycle. Upstream keeps compiler TLS off the allocation path on such platforms (MI_TLS_MODEL_PTHREADS on Android); this removes it here too.

  • Guard → per-page bit. The guard only has to answer "is this page's free list being rewritten by the sweep right now", and the sweeping thread holds the page exclusively while it does that (it is the owner, sweeps a parked owner's theap under MI_PARK_SWEEPING, or claimed the abandoned page). _mi_page_purge_holes sets/clears page->purging_holes around the walk and mi_page_free_collect_ex reads it directly — no thread-local, no page->theap chase (which would be unsound: abandoned pages keep a non-NULL theap). The sweep's own pre-walk collects have nothing to serve and now use _mi_page_free_collect_no_unpurge, as heap inspection already did; _mi_page_purge_holes_begin goes away.
  • Counters / holes_sweep_full → tail of mi_tld_t, as state of the tld being swept, passed explicitly (_mi_page_purge_holes(page, tld), _end(tld)); positional initializers unaffected, zero is the right initial value.

History: v1 reached the state via mi_theap_get_default(), which initialized a theap on mimalloc's own scavenger thread and tripped mi_page_is_valid_init's ownership assertion in Bun's debug build; v2 read it via page->theap->tld, which review correctly flagged as unsound for abandoned pages.

Verified: standalone clang Debug/MI_DEBUG_FULL and Release builds; ctest and mimalloc-test-purge-holes results identical to the base commit (same pre-existing failures). Bun Linux debug build (allocator asserts on) with this pin: multi-Worker allocation snippets, worker.test.ts, worker_threads.test.ts (122/122) run without allocator assertions. Android end-to-end (Wasm OMG stress tests, previously 100% segfault) is checked from the Bun PR's CI artifact.

…ad statics

`mi_purging_holes` (the sweep re-entrancy guard) is read from
`mi_page_free_collect_ex`, i.e. on the allocation slow path, and the three
`mi_holes_sweep_*` counters/flags sat next to it as `mi_decl_thread`
statics. On targets with emulated TLS (Android before API 29, which is
what Bun ships) the first access to a `__thread` variable calls `malloc`
to allocate the thread's TLS block. A fresh thread whose first slow-path
allocation was also its first touch of `mi_purging_holes` therefore went
malloc -> mi_page_free_collect_ex -> __emutls_get_address -> malloc -> ...
until it ran out of stack (seen as silent SIGSEGVs on JSC's Wasm compiler
threads).

Upstream already keeps compiler TLS off the allocation path on such
platforms (MI_TLS_MODEL_PTHREADS on Android); follow that: the four
values now live at the tail of `mi_tld_t` and are reached through
`_mi_theap_default()`, the accessor the fast path itself uses. They are
state of the thread running the sweep, so `_sweep_begin` writes the
sweeper's tld while still pacing the sequence off the tld being swept.
Reaching the state through `_mi_theap_default()` initialized a theap on
mimalloc's own scavenger thread the first time it swept a parked thread,
which then tripped `mi_page_is_valid_init`'s ownership assertion while
it validated that thread's pages. The scavenger is meant to stay
theap-less, so instead of "state of the sweeping thread" make it "state
of the tld being swept": the sweeper holds that tld exclusively (its own,
or a parked one it claimed), every sweep entry point already has it in
hand, and `_mi_page_purge_holes`/`_walk` now take it as a parameter.

The allocation-path reader `_mi_page_purge_holes_in_progress` takes the
page and consults the page owner's tld: a nested allocation on the
sweeping thread can only be served from a page under the sweep when the
sweeper is that page's owner, and abandoned pages (theap == NULL) are
not allocated from at all. No thread-local access of any kind remains.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Same fix as #17, which is now merged into bun-dev3-v2 (on top of the #15 upstream sync) and carries the emulated-TLS regression test. Closing this one in its favor; the page->theap-keyed _in_progress here is a reasonable follow-up if we want to drop the TLS read entirely.

@dylan-conway dylan-conway changed the title purge-holes: keep sweep scratch state on the tld instead of __thread statics purge-holes: keep sweep state off __thread statics (per-page guard, counters on the tld) Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants