Skip to content

mimalloc: no scavenger wake once the process exits on Windows (oven-sh/mimalloc#61) - #46016

Draft
robobun wants to merge 4 commits into
mainfrom
robobun/450ea084/no-scavenger-wake-at-exit
Draft

robobun wants to merge 4 commits into
mainfrom
robobun/450ea084/no-scavenger-wake-at-exit

Conversation

@robobun

@robobun robobun commented Oct 19, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On Windows, Bun faults inside ExitProcess when it runs under Intel SDE: panic(main thread): Segmentation fault at address 0x…. The top frames are ntdll ← mi_scav_wake_one (WakeByAddressSingle, mimalloc src/scavenger.c:127) ← mi_arenas_page_free_prim ← Rust thread-local destructors. 6 of the last 12 builds of main print it in windows x64 - verify-baseline.
  • ExitProcess terminates every other thread first. A thread-local destructor then frees a page, and mimalloc wakes its scavenger thread, which no longer exists. ntdll reads the wait block of that thread from its stack. Under SDE that stack is not mapped any more.
  • The exit code of the emulated run stays 0. scripts/verify-baseline.ts prints PASS, and each crashing run takes about 27 s longer.

Fix

  • Bump MIMALLOC_COMMIT to the fork change: the Windows mi_scav_wake_one returns before WakeByAddressSingle once RtlDllShutdownInProgress() is true.
  • This is correct because ntdll sets that flag in LdrShutdownProcess, after the other threads are terminated and before the first callback runs. All four wakes of the scavenger go through this one function, so each exit path is covered.
  • verify-baseline.ts fails a run that prints Bun's crash report, whatever its exit code is.
  • Verified: in progress. This PR is a draft until the numbers are in (see Notes).

Background

  • The scavenger is a thread of Bun's mimalloc fork that returns freed memory to the OS. On Windows it waits in WaitOnAddress.
  • ntdll keeps the wait block of a WaitOnAddress waiter on the stack of that waiter, in a process-wide table. A wake walks the table.
  • Considered mi_scavenger_stop() before ExitProcess: a join on every exit, and Bun has 9 exit sites. Considered TerminateProcess: fix(windows): use TerminateProcess to prevent NAPI module segfault on exit #27829 did that and was reverted. The guard in the fork also covers an exit() call from a native addon.

Downsides

  • Numbers follow with the verification.
Notes

State of this draft. The first commits carry a temporary measurement: the emulated test file runs 7 times in verify-baseline.ts, first with the current pin and then with the new one. The measurement is removed before this leaves draft.

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:29 PM PT - Oct 20th, 2026

✅ @robobun, your commit e896e1c9aa1ed442cc51b03f6a5500709149ccc0 passed in Build #129766! 🎉


🧪   To try this PR locally:

bunx bun-pr 46016

That installs a local version of the PR into your bun-46016 executable, so you can run:

bun-46016 --bun

@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: draft. The fix is oven-sh/mimalloc#61. This PR pins its head commit for verification and moves to the merged commit when that PR is merged.

How this was reproduced:

  • The crash shows in the CI step windows x64 - verify-baseline (Bun under Intel SDE). Build 128726 of this PR (current mimalloc pin, 7 emulated runs, with the new crash gate): 4 of 7 runs print panic(main thread): Segmentation fault with exit code 0, and the step fails with FAIL: Bun printed a crash report, exit code 0.
  • Build 129569 (new pin, same 7 runs): no run prints a crash report.
  • All decoded traces have the same frames: ntdll+0x5e895 ← mi_scav_wake_one ← mi_arenas_page_free_prim ← Rust thread-local destructors ← ExitProcess ← bun_core::Global::exit. Sampled builds of main show the same fault since 2026-07-28.
  • On native Windows (Server 2025 x64 and Windows 11 arm64) the test of the fork PR releases the stack of the terminated scavenger thread and then frees a block. Without the fix the wake ends in an access violation in 140 of 140 runs. With the fix 440 of 440 runs pass.

The PR still carries a temporary measurement (100 emulated runs and an exit code probe in the step). It is removed before the PR leaves draft.

A crash inside ExitProcess leaves the exit code of a run under Intel SDE at 0, so
runTest printed PASS for a run whose output had Bun's crash report. A run that
prints the crash banner or the bun.report trace link now counts as a failure.
… tests]

Runs the emulated test file 7 times to count the crash reports at process exit with
the current mimalloc pin. The next commits bump the pin and remove this loop.
…[skip tests]

Pins the fork commit in which the Windows mi_scav_wake_one returns before
WakeByAddressSingle once RtlDllShutdownInProgress() is true. The temporary
measurement loop in verify-baseline.ts stays for this build.
… code probe [skip tests]

On Windows only: runs the emulated test file 100 times with the new mimalloc pin,
and prints the exit code of a process that calls ExitProcess(3) from an FLS callback
inside its ExitProcess(0), natively and under Intel SDE. The step timeout is raised
for this build. All of it is removed before merge.
@robobun
robobun force-pushed the robobun/450ea084/no-scavenger-wake-at-exit branch from 3b05b4c to e896e1c Compare October 21, 2026 02:41
@robobun robobun changed the title mimalloc: no scavenger wake once the process exits on Windows mimalloc: no scavenger wake once the process exits on Windows (oven-sh/mimalloc#61) Oct 21, 2026
Jarred-Sumner added a commit that referenced this pull request Oct 21, 2026
…6270)

### What does this PR do?

Hardening follow-up to #46021. It fixes regressions from #46021 and
#44618 that were never released, includes more of the open pull requests
that fix a crash, and fixes a few crashes that had no pull request.

#### Regressions fixed (none was released)

- `spyOn(Bun, "$")`, and the same for `"sql"`, `"postgres"` and `"SQL"`,
threw "does not support accessor properties yet".
- Async `fs.read`, `fs.write` and `zlib.brotliCompress` on a view over a
`WebAssembly.Memory` could lose data or copy far more than they needed.
- `node:vm` with `vm.constants.DONT_CONTEXTIFY`: `this` in host
accessors, prototype handling, `util.types.isProxy`, `Bun.deepEquals`,
`assert.deepEqual` and `console.log` of a context.
- `zlib` `stream.flush()` threw for arguments it used to accept, and
streams had a new enumerable property.
- `Bun.Transpiler` `exports.replace` with the name `"default"`.
- `process.env.X = 3000` could store the number once the function was
JIT-compiled.
- `node:sqlite` and `bun:sqlite` could lose rows at exit.
- Dev server: stylesheets shared between pages after a build error.
- Shell `cp` of a file onto itself exited 1.
- `error.stack`: the frame of `require()` reads `at anonymous (unknown)`
again.
- `import * as process from "process"` followed by `process.on(...)`
threw at load. The EventEmitter methods that the module exports now act
on `process`.
- `delete res.statusCode; res.end()` in a `node:http` handler threw. It
sends 200, as in Node. (from #46128)
- `node:tls`, `node:https` and `node:http2`: connection teardown and
write callbacks behave as in 1.4.3 and Node again.

#### Crash fixes

Bundler, parser and CSS: #42286, #46092, #46097, #46177, #46239, #36334,
#43024, #40769.
`bun test`, `--hot` and `--watch`: #30776, #38206, #38974.
Shell: #43183, #37743, #46168, #46207, #34900.
`node:http`, `node:http2`, `Bun.serve`, streams: #43449, #45380, #44457,
#42197, #42485, #38082, #38124, #46074, #45999, #46045.
`node:vm`, native addons, workers: #46129, #46198, #45615, #33412,
#38031.
Dev server: #42790, #42850, #39488.
Other: #35991, #38030, #38586, #40526, #41830, #46029, #46078, #46114,
and the bug of #42215.

#### Behavior changes

- `crypto.randomFill(view, cb)` reports `ERR_CRYPTO_OPERATION_FAILED` if
it could not fill the view. It used to report success.
- A function registered twice on `process` runs twice, as in Node.
(#41830)
- Under `--hot`, a reload after an uncaught exception cancels the timers
that the failed run created. The new run's timers fire, and so do timers
and `fs.watchFile` watchers that earlier runs kept on `globalThis`. The
reload also resets the exit code to 0. (#38206)
- The parent of a Worker no longer gets an `'error'` event for a failed
`Bun.serve` request in that Worker. The error is printed, as on the main
thread.
- `hash.write()` after `digest()` drops the chunk, as in Node. (#35991)
- `http2` over TLS with `server.emit("connection", socket)`:
`session.socket` is a `TLSSocket` and a `'connection'` listener fires,
as in Node. (#38124)
- A `slice()` with no type of a body `Blob` has `type === ""`, as in
Node. (#46114)
- `util.types.isProxy(globalThis)` is false, as in Node.
- A write on a `node:net` or `node:tls` socket that is still pending
when the connection closes calls back with `ECANCELED`, as in Node.
- `HTMLRewriter.on()` throws for a selector that is nested too deeply.
- `Bun.Image`: `maxPixels` also bounds PNG metadata, `metadata()` of a
GIF reports the size that gets decoded, and a path with a NUL byte
throws. (#40526)
- `bun build --no-bundle` with the `base64` or `dataurl` loader prints
an error. (#36334)
- Bundled output for a default-exported function or class expression
changes to `var file_default = function name() {}`, as in esbuild.
`.name` is unchanged. (#46097)
- A Proxy in the prototype chain of a `node:vm` global is allowed. jsdom
30 with `runScripts` needs this.
- `bun:sqlite` commits a write that `iterate()` left open when the main
thread exits. An explicit `BEGIN` is still rolled back.
- When a Worker stops while a native addon's async work is still
running, the addon's finalizers run after that work is done, as in Node.
- Shell: `mv -n` and `cp -n` don't overwrite, and `cp` reads `R`, `v`
and `n` anywhere in a flag group.

#### Performance

- Ordinary `node:vm` contexts run at the same speed. With
`DONT_CONTEXTIFY`, stores to globals and reads of inherited names in a
hot loop cost 10 to 20 % more than on `main`.
- A `process.env` write costs 20 to 35 % less than after #46021.
- An async read into a `WebAssembly.Memory` view looks through the live
memories: about 1.5 µs more with one memory.

#### Not included

- Taken in part, so these should stay open for the rest: #36334, #38974,
#39488, #40526, #40769, #42485, #45615, #45999, #46029, #46092, #46114,
#46168, #46207, #46239.
- Needs a WebKit change: #34655 #39204 #40170 #40253 #41587 #41900
#42699 #42897 #42903 #43084 #43813 #44518 #44949 #45770 #45804 #45827.
- #44723 (covered by #46097), #41675, #42753, #38762, #41475, #46016.

### How did you verify your code works?

- Every item was reproduced first on release builds of `main`, of its
parent and of 1.4.3, and compared with Node v26 where Node has the API.
- Every fix has a test that fails on `main` and passes on a debug + ASAN
build of this branch, except where that isn't practical (huge inputs,
code that only runs on Windows or macOS).
- The regressions were checked on grids of calls against the build
before #46021 and against 1.4.3.
- Bun's own test suites and Node's `test-vm-*`, `test-http2-*` and
`test-zlib-*` files for the areas touched.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant