Skip to content

Check the free lists on the cold walks instead of faulting on a corrupted link - #23

Open
robobun wants to merge 1 commit into
oven-sh:bun-dev3-v2from
robobun:farm/65c810fa/checked-freelist-walkers
Open

Check the free lists on the cold walks instead of faulting on a corrupted link#23
robobun wants to merge 1 commit into
oven-sh:bun-dev3-v2from
robobun:farm/65c810fa/checked-freelist-walkers

Conversation

@robobun

@robobun robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Release builds fault on a corrupted free-list link. The walks that read a whole list reach it first: mi_page_purge_holes_walk (src/page.c:704, oven-sh/bun BUN-40BH, BUN-40CP), mi_page_thread_collect_to_local (:268, BUN-40SQ, BUN-3ZSP), the forced collect in mi_page_free_collect_ex (:1209, BUN-41H5). The fault address is the garbage in the link, so something writes into blocks after they are freed.
  • The sweep counts a block listed twice (a double free) twice. An OS page with that block and one live block then looks free and is discarded. A cyclic list never ends.
  • The scavenger blocks every signal. A process that faults with SIGSEGV blocked is killed without running its crash handler, so a sweep for a parked thread can die without a report (verified on Linux).

Fix

  • The three walks check each link: NULL, or the start of a formed block of this page. The sweep also records the blocks it has seen, which finds double frees and bounds the walk.
  • A bad list is reported with _mi_error_message(EFAULT, ...) and cut in front of the block that holds the bad link. The blocks cut off count as used from then on: they are never handed out, and the page stays out of the arena while one of them may be live. The thread-free collect keeps its existing recovery.
  • The scavenger leaves the thread-directed fault signals unblocked.
  • Verified: test-freelist-corruption (new, 5 cases) faults in 4 and loops in 1 without the src/ changes and passes with them, in Release and MI_DEBUG_FULL. The rest of the suite is unchanged.

Background

  • A free block stores its next link in its own first word. Debug and secure builds encode it, and mi_block_next rejects a link that leaves the page. Release builds store it plainly.
  • In release, _mi_error_message does not abort. It prints when show_errors is set and calls the mi_register_error handler, so an embedder can count or report these.
  • Per page, used + free listed + discarded == capacity. A cut moves the dropped blocks into used. The sweep knows how many blocks remain and sets used exactly; the collects add one.
Notes
  • Cost: one division per block on the three walks. The thread-free collect is the only one reached from _mi_malloc_generic, and it already chases every link it collects. _mi_page_malloc popping page->free is not touched. The sweep zeroes capacity / 8 bytes per walked page and keeps an 8 KiB array on the stack, as the report walker already does.
  • The message carries the block size, the bad value and the block that held it. Values seen in the field: 0xA0D, single bits, pointers with their low dword replaced, -1.
  • The collects add one to used because they do not know how long the dropped tail was. Such a page only looks emptier than it is; the block holding the bad link is pinned either way. The test for that path corrupts the last block of the list, so its accounting check is exact too.
  • The double-free message names the block and the block that links to it the second time. The first occurrence stays on the list, so the block is handed out once, which is the correct state.
  • The bun crash data: about 40 sibling groups under _mi_thread_idle_work, the same garbage in the allocation path (BUN-4616 pops 0xA0D in mi_page_malloc_zero), and the same shape on 1.3.x builds before the sweep existed (BUN-316K, BUN-3WB5). The writers are in bun, not in the allocator: io(windows): keep the buffer of a file read started during chunk delivery bun#39897 (a Windows file read that completes into a freed buffer) and the usockets poll double free from usockets(windows): keep a closed poll alive until the outer tick and libuv are done with it bun#39643. This change does not fix those. It only makes the walks report the damage and keep going, and fixes the two allocator-side items above (the signal mask, the double count). Whether the link checks belong in the allocator at all is for the maintainers to decide; the patch splits cleanly if only the two allocator-side items are wanted.
  • Pre-existing at 6a14aee2 in this environment, unrelated to this change: test-purge-holes fails unformed-tail in MI_DEBUG_FULL; test-heap-mt segfaults in heap-free-during-delete-overlap about one run in three under load (mi_stat_free reads page->heap->subproc of a heap being deleted, src/free.c:718, reported separately); src/prof.c needs -D_GNU_SOURCE to build with cmake here.
  • Runs: ctest in Release 20/20. In MI_DEBUG_FULL all pass except the two pre-existing failures above, the same two as without this change.

…pted link

A free block holds its next link in its own first word. When something writes
into a block after it was freed, or a stale free links a live object, the link
points anywhere. Release builds do not encode links, so the walks that read a
whole list followed it and faulted: the idle sweep (mi_page_purge_holes_walk),
the forced collect of local_free in mi_page_free_collect_ex, and
mi_page_thread_collect_to_local, which bounded the length of the thread-free
list but not where its links point. This is the crash family behind
oven-sh/bun BUN-40BH, BUN-40CP, BUN-40SQ and BUN-41H5.

The three walks now check every link: it has to be NULL or the start of a
formed block of the page. The sweep also tracks which blocks it has seen, so a
block listed twice is found instead of counted twice (two counts for one block
could make an OS page with one live block look entirely free and get
discarded) and a cyclic list ends. A corrupted list is reported through
_mi_error_message (EFAULT, as the existing thread-free message does) and cut
in front of the block holding the bad link; the blocks cut off are counted as
used from then on, so they are never handed out and the page is never returned
to the arena while one of them may still be live. The sweep knows how many
blocks remain listed and sets used exactly; the collects bump it by one. The
thread-free collect keeps its existing recovery and drops the list it took.

The scavenger thread blocked every signal, including the ones a fault on the
thread itself raises. A blocked SIGSEGV or SIGBUS is not queued: the kernel
resets it to the default action and kills the process, so a fault during a
sweep the scavenger did for a parked thread ended the process with no report
from the host's crash handler (verified on Linux). Leave the thread-directed
fault signals unblocked; the process-directed ones stay blocked as before.

test-freelist-corruption scribbles a freed block the way the crashes look (a
link 8 bytes into the block, the word 0xA0D behind it) on each of the three
lists, plus a bad head and a self-linked block, and checks the report, the cut,
the block accounting, that a second pass is quiet, that the block is not handed
out again and that the live blocks are intact. Without the src changes four of
the five tests fault and the self-link test loops.
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.

1 participant