Skip to content

Do not dereference page->heap on the cross-thread free path while mi_heap_delete can free the heap - #24

Closed
robobun wants to merge 2 commits into
oven-sh:bun-dev3-v2from
robobun:farm/e03f2349/heap-delete-vs-free-page-heap
Closed

Do not dereference page->heap on the cross-thread free path while mi_heap_delete can free the heap#24
robobun wants to merge 2 commits into
oven-sh:bun-dev3-v2from
robobun:farm/e03f2349/heap-delete-vs-free-page-heap

Conversation

@robobun

@robobun robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • mimalloc-test-heap-mt (heap-free-during-delete-overlap) segfaults under load. Top frame: mi_stat_free, src/free.c:718, subproc->theap_meta, from mi_free_block_mt.
  • Cause: mi_stat_free reads page->heap->subproc before it owns the page. A concurrent mi_heap_delete re-points page->heap (arena.c:2774) and frees the heap struct (heap.c:225). The free list link lands on heap->subproc, its first field.
  • Same window in mi_arenas_page_free_prim (by reading): the deleter stops waiting once the page's arena_pages bit is clear (arena.c:1141), but after that mi_bitmap_clear still updates the chunkmap inside arena_pages, and line 1166 reads page->heap->subproc. The deleter frees both.

Fix

  • mi_stat_free takes the subproc from the page's arena (page->memid, constant for the life of the page) and detects a meta page with MI_THREADID_DETACHED, the only thread id theap_meta has. It no longer reads page->heap.
  • mi_arenas_page_free_prim reads the subproc first and clears the bit with the new mi_bitmap_clear_no_chunkmap, so the clear is its last access to the heap. A stale chunkmap bit is allowed by the bitmap design, and the pages bitmap is only visited, never searched.
  • Correct because the delete waits exactly while a freer owns an abandoned arena page whose bit is set. Both sites now touch the heap only inside that window. The rule is written on mi_page_heap. Stats attribution is unchanged for arena pages.
  • Verified: stress, unfixed 7 crashes in 32 runs, fixed 0 in 60. TSAN, unfixed 2 reports per run, fixed none. ctest in Debug and Release (notes).

Background

  • mi_heap_delete keeps the live blocks: it moves the pages to the main heap and frees the heap struct. The blocks are freed later with mi_free, so the two run concurrently on one heap.
  • A page is owned through a bit in page->xthread_free. A cross-thread free takes it to collect an abandoned page. The deleter spins while a freer has it (mi_heap_visit_page_at), until the freer gives it up or clears the page's arena_pages bit.
  • mi_stat_free exists only with MI_STAT > 0 (debug builds). The free_prim part is compiled in release builds.
Notes

Repro (Debug, MI_DEBUG_FULL=ON, MI_BUILD_TESTS=ON, MI_OVERRIDE=OFF): run 6 to 8 copies of taskset -c <two cpus> ./mimalloc-test-heap-mt 1000 (or 1500) at once. Unfixed: 7 crashes in 32 runs, every backtrace mi_stat_free free.c:718 <- mi_free_block_mt free.c:69 <- mi_free <- t1_freer test-heap-mt.c:67. The fault address is 0: a #GP on the 0xDF.. poison or on the free list link. Fixed: 0 crashes in 60 runs (36 with the first commit, 24 with both). A single unloaded run passes either way.

TSAN (CC=clang -DMI_DEBUG_TSAN=ON, ./mimalloc-test-heap-mt 50), unfixed. Both reports have mi_stat_free free.c:717 as the reader:

Write of size 8 ... mi_heap_delete_page arena.c:2774                                   (page->heap = heap_target)
Previous read   ... mi_page_heap internal.h:1224 <- mi_page_subproc <- mi_stat_free free.c:717
Write of size 8 ... mi_block_set_next <- mi_free_block_mt <- _mi_free_subproc_safe <- mi_heap_free heap.c:225
Previous read   ... mi_page_subproc internal.h:1231 <- mi_stat_free free.c:717

Fixed: exit 0 and no reports, 3 of 3 runs with each commit. The "Debug, TSAN" job in .github/workflows/test.yaml would show this, but that workflow triggers on dev* branches only, so it does not run for bun-dev3-v2.

How much the existing test exercises the second site: little. When the delete starts, only the pages that were full beyond page_full_retain are abandoned (one page per iteration in Debug because of the padding, none in Release). The other pages are still held by the theap and are moved without contention. #25 adds a variant where the allocating thread exits first, so every page is abandoned and claimed by the freers. The free_prim change is by reading, and by that variant under TSAN.

The OS page case in mi_stat_free: a page without an arena uses _mi_subproc(). That is the same subproc as before unless a program uses several subprocs, the page is OS allocated, and the free comes from a thread of another subproc. Stats only.

Release exposure, the question in the report that led here. mi_stat_free is compiled out with MI_STAT=0, which bun's release builds use. The free_prim part is compiled in, but it needs a concurrent mi_heap_delete, and bun never calls mi_heap_delete: src/mimalloc_sys/mimalloc.rs binds only mi_heap_destroy, and the JSC structure heap is never deleted. In release the stale subproc value was also only used for OS pages (_mi_arenas_free ignores it for arena memory). So this is not a candidate for the bun 1.4 crashes (BUN-40BH family), and bun does not need a pin bump for it: it can ride the next bump. Upstream dev3 has the same code at all three places.

Remaining mi_heap_delete vs mi_free issues, not changed here:

  1. The theaps of the heap are freed before the pages are walked (heap.c:236), and _mi_heap_detach_theaps leaves theap->heap set. A freer that used the heap before still finds that theap through its thread local (_mi_page_associated_theap_peek, from free.c:369 and arena.c:1269) and reads it while or after it is freed. A test variant (pages abandoned by an exited allocator thread, freers that allocated from the heap once) crashes in the first iteration in Debug, in mi_theap_matches_thread through prim-tls.h:422. Fixed in mi_heap_delete: detach the theaps before the pages are moved and free them after #25, stacked on this PR.
  2. _mi_heap_visit_blocks skips an OS allocated page that a concurrent freer owns instead of waiting (arena.c:2653), and an OS page still held by a theap is not visited at all. With MIMALLOC_DISALLOW_ARENA_ALLOC=1 the test trips arena.c:2800 at once, and a single threaded delete followed by frees trips page-queue.c:200. OS pages and mi_heap_delete need a design of their own.
  3. A page that a live thread holds (it allocated from the heap, or reclaimed a page on free) is moved without waiting, so that thread must not free into it during the delete. That is the existing contract, and the existing tests keep to it.

Test suite. Debug: 20 of 21 pass. test-purge-holes fails in unformed-tail (test-purge-holes.c:1643) the same way on the unfixed tree on this machine, 3 of 3 runs. Release: 20 of 20 pass. In a TSAN configuration, ninja for all targets fails to link test-theap-sentinel and mi-heapview on the unfixed tree as well, so the TSAN binaries were built per target. src/static.c compiles without warnings as C++ with clang 21 in bun's debug, release and ASAN configurations.

…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.
@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.

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