Repository navigation
Conversation
…nd at its exit On Windows ExitProcess terminates every other thread before the exiting thread runs the FLS callbacks, the DLL detach routines and the TLS callbacks. A free in one of those can schedule a purge and wake the scavenger, which is gone by then. Its wait block is still queued in ntdll, on its stack, and WakeByAddressSingle reads it. The test puts itself between mimalloc and WaitOnAddress / WakeByAddressSingle (it patches the import address table of its executable), so it knows when the scavenger is in its wait and it counts the wakes: - free, park, stop: from an FLS callback at process exit. None may issue a wake. - gone: the same with the stack of the terminated scavenger released first, which is what Intel SDE does. The process has to survive. - live: a free and a free at thread exit wake the scavenger and are purged long before its safety timeout. A stop wakes it once and joins it.
ExitProcess terminates every other thread, and only then the exiting thread runs the FLS callbacks, the DLL detach routines and the TLS callbacks. A free in one of those can schedule a purge and wake the scavenger, and mi_process_done stops it with a wake. The scavenger is gone by then. It was in WaitOnAddress when it was terminated, and ntdll keeps the wait block of a waiter on the stack of that waiter and leaves it queued, so WakeByAddressSingle reads the stack of a thread that no longer exists. Under Intel SDE that stack is gone as well and the read is an access violation: oven-sh/bun crashed that way at exit in most runs of its baseline check. The Windows mi_scav_wake_one returns before WakeByAddressSingle when RtlDllShutdownInProgress() is true. ntdll sets that flag in LdrShutdownProcess, after the other threads are terminated and before the first FLS callback. All four wakes (a free that schedules a purge, the end of a purge pass, a park, a stop) go through this one function. The scavenger resolves the function from ntdll before its first wait. Nothing changes per malloc, free or page free, and nothing changes on other platforms.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
mi_scav_wake_one(WakeByAddressSingle,src/scavenger.c:127).ExitProcessterminates every other thread, then runs the FLS callbacks on the exiting thread. A free in one of them wakes the scavenger.Fix
mi_scav_wake_onereturns beforeWakeByAddressSinglewhenRtlDllShutdownInProgress()is true.test/test-exit-wake.con Windows x64 and arm64. Without the guard its exit cases fail in 560 of 560 runs. With the guard 2200 of 2200 pass.Background
WaitOnAddress. A free that schedules a purge, a park andmi_scavenger_stopwake it.ExitProcessfor the exiting thread. Rust runs thread-local destructors there.WaitOnAddress: a wake that finds the scavenger awake costs two more syscalls (Notes). Considered a stop by the host before exit: bun has 9 exit sites.Downsides
WaitOnAddressby name.Notes
The cases of
test-exit-wake. The test puts a counter in front ofWaitOnAddressandWakeByAddressSingle. It waits until the scavenger is in its 30 s wait, which means that nothing is scheduled and the next free, park or stop has to wake it. An exit case returns 1 frommain, and only the end of its FLS callback ends the process with 0. The callback first checks that the scavenger thread is terminated, that it was in its wait, and thatRtlDllShutdownInProgress()is true.test-exit-waketest-exit-wake-parkmi_on_thread_idle_start)test-exit-wake-stopmi_scavenger_stoptest-exit-wake-gonetest-exit-wake-liveResults. Both runs are in my fork, with the matrix cut to its Windows jobs and one more step that runs each case 20 times. Runners:
windows-2025-vs2026(10.0.26100, x64) andwindows-11-vs2026-arm64(10.0.26200). Toolchains: MSVC, clang-cl, clang, mingw-ucrt64.60a787a5), run, 7 executables × 20 runs26fc5bc4), run, 22 executables × 20 runsFAILED (a wake of the scavenger after the exit terminated it)in all 420 runs.Two facts about Windows that the test showed.
ExitProcessdoes not wait for the threads that it terminates. In a first version of the test the scavenger thread was not signaled yet when the FLS callback ran, in 26 of 28 runs. The callback now waits for it.gonerun released it withVirtualFree. This is why the stale read does not show natively.ntdll. Read in 10.0.17763.6532, whose offsets match the frames of the crash on bun's CI machine.
RtlDllShutdownInProgressreads one byte.LdrShutdownProcessstores 1 to it at+0xa7and callsRtlProcessFlsDataat+0xf3.RtlExitUserProcesscallsLdrShutdownProcessafterNtTerminateProcess(0).cmpq $0, 0x20(%r9)atntdll+0x5e895. The wake reads the first wait block in the bucket of the address. The table has 128 buckets and is in the PEB.Why the scavenger resolves the function itself. The lookup could also be in
_mi_scavenger_start. In the build I measured, that one more call made clang stop inlining_mi_scavenger_startinto_mi_scavenger_start_lazy, andmi_on_thread_idle_startand_mi_thread_init_with_heapchanged with it. Inmi_scavenger_thread_mainit changes no other function. There is no wait block of the scavenger before its first wait, so the pointer is set in time.Cost. System clang 23 for
x86_64-w64-mingw32andaarch64-w64-mingw32, C++,-O2, with bun's defines. This is not bun's clang-cl build.WakeByAddressSinglebecamemi_scav_wake_one):mi_arena_schedule_purge,_mi_arenas_try_purge,_mi_scavenger_wake,_mi_scavenger_stop,mi_scavenger_stop,mi_process_done.mi_scavenger_thread_mainhas 8 more instructions, for 2 loader lookups per scavenger start.mi_scav_wake_oneis a new function of 16 instructions.mi_scavenger_thread_main, 16 in the new function.mi_free,mi_malloc,page_retire,mi_page_free,arenas_page_freeorarenas_freein their name are identical..text+93 B,.rdata+35 B,.bss+8 B on x64, and +104, +35, +8 on arm64. The 70 undefined symbols are the same before and after, so there is no new import.test-stresshas 89,407 arena page frees and 1,371 issued wakes,test-stress-heaps120,750 and 1,314,test-heap-churn353,734 and 4.The kernel event alternative. An auto-reset event in place of the address wait needs no check at exit, because
SetEventreads no state of the waiter. It also keeps the scavenger out of the table of ntdll. It costs more while the process lives:WakeByAddressSingleenters the kernel only when a waiter is queued.SetEventalways does. A wake that finds the scavenger awake then costs one syscall on the thread that frees, and the next wait of the scavenger returns at once, so the scavenger makes one more turn of its loop.test-stress172 of 678 issued wakes found the scavenger awake, intest-purge-behind-pass4 of 9, intest-heap-churn4 of 5.Not in this change.
mi_process_donereleases it (707d90b), and a build withMI_NO_PROCESS_DETACHhas nomi_process_done.arena.c:1702),mi_bitmap_clear_once_set(bitmap.c:122), the park leave (theap.c:336) and the heap walks (arena.c:2893,arena.c:2946,theap.c:826,theap.c:865). The crash traces of bun show none of them. A check in the Windows prim layer could serve them. This change keeps the check next to its one user, and resolves it on the scavenger thread, off the start of the process.An unrelated failure to expect. In one run on
ubuntu-22.04-arm(C++),test-purge-holes-largefailed withFAILED: discard-first-classattest-purge-holes-large.c:1383. This change compiles to the same object on Linux.