Skip to content

test-heap-aba: regression test for the theap cache ABA on a reused heap address (stacked on #25) - #26

Closed
robobun wants to merge 5 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/theap-cache-aba
Closed

test-heap-aba: regression test for the theap cache ABA on a reused heap address (stacked on #25)#26
robobun wants to merge 5 commits into
oven-sh:bun-dev3-v2from
robobun:robobun/theap-cache-aba

Conversation

@robobun

@robobun robobun commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #25 (which is stacked on #24): their four commits come first, merge them first. This PR is the last commit: a regression test.

Problem

  • _mi_heap_theap serves a heap from the thread's _mi_theap_cached entry whenever theap->heap equals the requested heap (include/mimalloc/prim-tls.h:392). Upstream 36e8fc3 ("improve concurrent heap_delete and thread termination", in the dev3 sync of Merge upstream dev3 (261 commits): #1271 audit fixes, init/subproc restructure #15) removed the theap->heap = NULL store from the heap-delete detach path (_mi_heap_detach_theaps). The old _mi_theap_free had that store with a comment that it avoids an ABA when the cache holds a heap address that a new heap reuses. The thread-exit path (_mi_tld_detach_theaps) still clears it.
  • So: thread B destroys heap H while thread A still caches its theap for H. B's next mi_heap_new reuses H's block (LIFO). A's next mi_heap_malloc(H2) hits the cache (theap->heap == H == H2) and allocates from a detached theap whose page queues point at pages that were returned to the arena. A debug build segfaults. A release build hands out blocks inside free arena slices, or inside a page that by then belongs to another heap, and corrupts whatever reused that memory.
  • mi_heap_delete: detach the theaps before the pages are moved and free them after #25 restores the store (under the tld lock, after the unlink) for a different symptom (a cross-thread free dereferencing the detached theap). Nothing in the suite pinned the cache ABA itself, so a later sync could drop the line again without a test failing.

Fix

Background

  • A mi_heap_t is shared by threads; each thread allocates from it through its own mi_theap_t. The theap for a (heap, thread) pair is found through a versioned thread-local slot (heap->theap), with _mi_theap_cached as a one-entry front cache keyed only by the theap->heap pointer. The slot is versioned, so only the cache can go stale.
  • Deleting a heap detaches its theaps from every thread's tld list and frees them, but a detached theap stays alive while a thread's cache still references it (the cache holds a refcount). The cache is refreshed only when that thread uses another heap.
  • Found while investigating a Bun production crash (Sentry BUN-4CG0: a JSC MarkedBlock whose memory reads back as zeros). Bun's arena wrapper allocates from a heap only on the thread that created or last reset it, and a Bun build instrumented to abort on the stale cache hit ran 1630 tests without firing, so Bun is not shown to reach this path. The regression is real on its own.

…oncurrent mi_heap_delete can free the heap

mi_heap_delete claims each page of the heap, re-points page->heap to the
main heap, and then frees the heap struct. A mi_free from another thread
runs concurrently with that. Two places on its path read page->heap at a
point where the deleter does not wait for them:

- mi_stat_free (MI_STAT>0) read page->heap->subproc before it owns the
  page. The heap struct can be freed between the two loads, and the free
  list link is written over heap->subproc (its first field), so the next
  load is garbage and subproc->theap_meta faults. This is the SIGSEGV in
  test-heap-mt (heap-free-during-delete-overlap) under load. The stats
  now find the subproc through the page's arena, which is constant for
  the lifetime of the page (and through the current subproc for an OS
  allocated page), and the meta theap test compares the page's thread id
  with MI_THREADID_DETACHED directly.

- mi_arenas_page_free_prim read page->heap->subproc after it cleared the
  page's bit in the heap's arena_pages. That bit is what the deleter
  waits on, so the heap can be gone by then. Read the subproc once,
  before the bit is cleared.

Document the rule on mi_page_heap and on the page->heap field.
…e is freed

mi_bitmap_clear updates the chunkmap of the bitmap after it clears the
bit. The bit is what a concurrent mi_heap_delete waits on before it frees
the heap and its arena_pages (mi_heap_visit_page_at), so that chunkmap
update could touch a freed arena_pages. Add mi_bitmap_clear_no_chunkmap,
which leaves the chunkmap bit set (allowed: it means the chunk may have
bits set, and the pages bitmap is only ever visited, never searched), and
use it in mi_arenas_page_free_prim. Narrow the comment on mi_page_heap to
what the delete actually waits for.
…em after

mi_heap_delete (and mi_heap_destroy) freed the theaps of the heap before
it walked the pages, and the detach marked a theap by clearing theap->tld.
A thread that used the heap before still reaches its theap for the heap
through the heap's thread local while it frees blocks of the heap during
the delete (_mi_page_associated_theap_peek on the reclaim and re-abandon
paths of mi_free_try_collect_mt). That theap is then detached, with a
NULL tld that the callers dereference, or already freed.

Now the detach clears theap->heap instead (as _mi_tld_detach_theaps does
on thread exit), which is what the peek compares, so a free that runs
after the detach does not get the theap anymore. The theap struct and its
tld stay valid until after the page walk, for a free that obtained the
theap just before the detach: the walk waits for such a free while it
owns the page. The statistics are still merged before the walk, so the
page statistics of the heap are unchanged. _mi_heap_theap_peek returns
NULL for a detached theap as well instead of asserting.

test-heap-mt gets a variant where the pages are abandoned (the allocating
thread exited) and every freer allocated from the heap once. It crashed
in the first iteration before (mi_theap_matches_thread through
_mi_arenas_page_try_reabandon_to_mapped).
Armed after pthread_create, the thread can have exited before the store
and then never parks, and the wait for it spins forever. Seen once as a
1500s ctest timeout under a loaded ctest -j4.
…ap address

Every thread keeps the theap of the heap it last used in `_mi_theap_cached`,
and `_mi_heap_theap` takes that cache whenever `theap->heap` equals the
requested heap. Upstream 36e8fc3 ("improve concurrent heap_delete and thread
termination") dropped the `theap->heap = NULL` store from the heap-delete
detach path, which the old `_mi_theap_free` had with a comment that it avoids
an ABA where the cache holds a heap address that a newly allocated heap reuses.
The previous commit restores that store (under the tld lock, as the thread-exit
path does). This test pins it:

  B: H = mi_heap_new()
  A: mi_heap_malloc(H)                       -> A caches its theap for H
  B: mi_heap_destroy(H); H2 = mi_heap_new()  -> H2 == H (LIFO reuse of the block)
  A: mi_heap_malloc(H2)                      -> must come from a theap of H2

Without the store a debug build segfaults in mi_heap_malloc (the cached theap is
detached, `tld == NULL`) and a release build hands out 127 of 1024 blocks that
`mi_heap_of` attributes to no heap: memory that went back to the arena. With
the store every block belongs to H2.
@robobun
robobun force-pushed the robobun/theap-cache-aba branch from 684f33b to 94540d7 Compare August 22, 2026 18:16
@robobun robobun changed the title Clear theap->heap when a heap delete detaches the theap (cache ABA on a reused heap address) test-heap-aba: regression test for the theap cache ABA on a reused heap address (stacked on #25) Aug 22, 2026
robobun added a commit to oven-sh/bun that referenced this pull request Aug 22, 2026
…en-sh/mimalloc#25, CI stand-in)

The dev3 sync (#37367) carried upstream 36e8fc33, which dropped the
`theap->heap = NULL` store from the heap-delete detach path. A thread's
one-entry theap cache is keyed only by that pointer, so a heap created at the
address of a destroyed one matched the stale entry and the thread allocated
from a detached theap whose pages had gone back to the arena.

Carry the store as a patch on the current pin so CI builds and tests it on
every platform. oven-sh/mimalloc#25 owns the change in the fork and
oven-sh/mimalloc#26 adds the regression test; this becomes a pin bump once
they are merged, and the patch is removed then.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Folded into #27, which combines #22#26 and replaces the per-reader fixes with one delete/destroy teardown protocol (pin-before-claim). The commit from this PR is carried there as-is.

Jarred-Sumner added a commit that referenced this pull request Aug 23, 2026
Heap delete/destroy: one teardown protocol (supersedes #22#26)
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