Return freed memory to the OS on a background thread instead of the JS thread - #34181
Conversation
… it parks The idle sweep punches free-block holes out of pages that are still in use, and it is almost entirely madvise: on a 100MB churn ~99% of its cost is the syscalls. It ran inline from Bun__JSC_onBeforeWait, so the JS thread paid all of it right before blocking. mimalloc can now be told a thread is about to block and is not going to touch its heaps until it wakes, which is the only condition the sweep actually needs. Hand the heaps over across the poll and the scavenger does the syscalls while we sit in the kernel. Measured in isolation the owner goes from 20.0ms to 0.005ms per park for the same work. The handoff goes after Bun__JSC_onBeforeWait, which allocates, and before dispatch, which allocates: nothing between it and the matching take-back may touch the heaps. Windows still runs the libuv loop, which has no handoff, so it keeps sweeping inline. Pins mimalloc to oven-sh/mimalloc#8.
|
Updated 7:25 PM PT - Jul 15th, 2026
@Jarred-Sumner, your commit d940463 is building: |
The handoff is a compare-and-swap, so there is nothing to gate it on: mimalloc paces the sweep itself (purge_holes_min_interval), which is where that policy belongs now that the work is not on this thread. Drops the inherited 100ms timer and the will-idle gate. mimalloc.h declares both entry points, so the local externs are gone. Bumps mimalloc for the teardown, fork and subproc fixes in oven-sh/mimalloc#8.
mi_on_thread_idle_start now reports whether it handed the heaps over instead of quietly sweeping inline when it could not. Without a scavenger the loop keeps what it did before: sweep on the JS thread, but only on a tick that really parks, and no more than every 100ms -- sweeping between ticks is the cost this is avoiding. Bumps mimalloc for that and for the reclaim and fork fixes in oven-sh/mimalloc#8.
… moves Nothing returns them under sustained load, and no arena-side knob can: the pages sit in the theap's page queues until someone collects the theap, and until then the arena scavenger cannot see them. A REPL replay grew +229MiB inside the arena VMA while madvise flowed healthily for other memory, and purge_delay 1000/100/10 all climbed in lockstep -- the memory had never reached the arena. JSC's own hook cannot stand in. `Heap::didFinishCollection` fires `scavengeThisThread` -> `mi_theap_collect`, but it runs under whichever GCConductor holds the conn, and `collectInCollectorThread` conducts async collections -- `mi_theap_get_default()` there returns the collector's near-empty theap. It is also gated on CollectionScope::Full, and sustained load runs eden for minutes. Forcing a synchronous collection every second cured the ratchet precisely because a sync collection is conducted by the mutator; that was the tell. The GC controller runs on the JS thread by construction and already knows when the heap moved, so do it here. `mi_theap_collect` walks the page queues and frees the empty pages, which schedules the arena purge and wakes the scavenger to madvise off this thread; it does not scan free lists for holes, which is the expensive part that cost vite-preview 12.9% in #34009. libpas needed no hook here at all: its scavenger polled at ~10Hz and shrank thread caches autonomously.
…the heap moves" This reverts commit c8abd3e.
|
Reverted my The load-bearing premise was false. The chain was: heapStats() cures the repl-turn ratchet → because it forces a sync full GC → which runs if (vm.heap.size() == 0) vm.heap.collectNow(...);It collects only when the heap is already empty — on a live process it never collects at all. So whatever cures the bench, it is not a forced collection, and it is not evidence for the thread-affinity or Every other clause fell independently:
And the finding that reframes everything: a plain sync churn loop ratchets 74→125MB over 60 turns with heapSize flat — and libpas ratchets identically (78→127, indistinguishable at 150 turns). That shape is JSC heapCapacity growth under async-GC lag, allocator-independent. Any repro without a libpas control on the identical script is worthless; at least one agent would have shipped a false positive without it. One real thing did reproduce in C: ~50MB stranded in un-full pages whose remaining blocks are freed cross-thread, recoverable only by Next action (from the synthesis, and it's cheap): A/B the probe on the real bench four ways — no probe / per-second heapStats / per-second eden-only |
… the heap moves" This reverts commit a908c8a.
Sampling `bun:jsc` heapStats() once a second cures the sustained-load memory ratchet on the repl replay (plateaus at 393MB against 563-584 un-instrumented). The reason is not a forced GC, which is what the earlier analysis assumed and built on: heapStats' `collectNow` is guarded by `if (vm.heap.size() == 0)` and never fires on a live process. What it actually does is call `mi_collect(false)` unconditionally (BunJSCModule.h), and `mi_collect` is exactly `mi_theap_collect(_mi_theap_default(), force)`. So the cure is a theap collect on the JS thread, obtained by accident from a statistics call. Do it deliberately. `mi_theap_collect` walks the theap's page queues and frees the empty pages, which schedules the arena purge and wakes the scavenger to madvise off this thread. It does not scan free lists for holes -- that is `purge_holes`, the expensive one that cost vite-preview 12.9% in #34009. Gated on the heap having SHRUNK, not merely moved: `process_gc_timer` is also called from `Server::on_request_complete`, and a busy server's heap is almost always moving, so `!=` fired the page walk on essentially every request -- -4.5% rps on fastify (66.1k -> 63.1k, n=6) buying nothing, since no collection had run. A shrink means a collection actually reclaimed and pages may now be empty.
…never parks `mi_theap_collect` is the only demonstrated cure for the sustained-load memory ratchet. Sampling `bun:jsc` heapStats() once a second fixes it, and the reason is not a forced GC as the earlier analysis assumed: heapStats' `collectNow` is guarded by `if (vm.heap.size() == 0)` and never fires on a live process. What it does unconditionally is `mi_collect(false)` (BunJSCModule.h), and `mi_collect` is exactly `mi_theap_collect(_mi_theap_default(), force)`. On the replay workload that call moves ~170MB. So do it deliberately instead of as a side effect of asking for statistics. Two gates. A loop that parks already gets its theap swept at the park, so doing it here too is pure cost -- an earlier revision that ignored this measured -4.5% rps on fastify (66.1k -> 63.1k, n=6) for no reclaim. And a collection has to have actually finished, which is the one thing the controller could not previously tell: `perform_gc` merely requests one via `collect_async`. That also bounds this to once per collection. `GCCycleObserver` lives on `JSVMClientData`, not in a process global: every worker has its own heap and theap, and a shared counter would let one worker's collection convince another to walk its page queues. It only counts -- `Heap::didFinishCollection` runs under whichever GCConductor holds the conn, and `collectInCollectorThread` conducts async collections, so the work has to happen on the JS thread where `mi_theap_get_default()` returns the right theap. The observer is removed in `~JSVMClientData`, which is safe because `~VM` deletes clientData in its body, before the `Heap` member is destroyed. The hook goes in `WTFTimer::fire`, not `run`: `update` only publishes to `imminent_gc_timer` for a delay <= 0, and `GCActivityCallback::didAllocate` schedules with a positive delay, so JSC's GC callbacks come through the timer heap. Not yet validated against the workload it targets -- nothing reproduces the ratchet outside the repl replay harness.
Benchmark numbers and the history of earlier revisions belong in the commit message, which has them.
The premise was that `mi_theap_collect` on the JS thread is the cure, since sampling heapStats() once a second fixes the ratchet and its one relevant action is `mi_collect(false)` == `mi_theap_collect(default theap)`. Measured against the real 100-turn replay, it is not. The park path already runs that exact call every 100ms -- `mi_on_thread_idle` is `mi_theap_collect(theap0, false)` plus a hole punch -- and the footprint still climbs 264 -> 369MB. Forcing the park sweep to fire on every park (BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS=1e6) changes it to +101MB from +105MB. This hook measured +108MB. All three are the same number. So the theap collect is already happening at 10Hz and is not what heapStats does differently. What is left is `objectTypeCounts()` -> `HeapIterationScope` -> `MarkedSpace::stopAllocating()`, which heapStats calls unconditionally and nothing else on the JS thread does.
376f25a to
3d716a2
Compare
Picks up oven-sh/mimalloc#8 (theap: let a parked thread hand its heaps to the scavenger), now merged into the fork's bun-dev3-v2 branch alongside the upstream/dev3 sync. No-Verification-Needed: dependency pin bump only, no product source
3d716a2 to
d940463
Compare
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Disabled knowledge base sources:
WalkthroughChangesMimalloc idle handling
Possibly related PRs
Suggested reviewers: Comment |
| #if USE(MIMALLOC) && OS(WINDOWS) | ||
| // Collect retired pages, punch free-block holes, hand the arena purge to | ||
| // the scavenger. Rate-limited; nowNs is the tick's shared reading (0 = take | ||
| // one), compared by addition so an out-of-order reading cannot underflow. | ||
| // | ||
| // Windows only: everywhere else `us_loop_run_bun_tick` hands the heaps to the | ||
| // scavenger across the poll instead, so this thread never does the sweep itself. | ||
| // The libuv loop has no handoff yet, so it keeps paying for it here. |
There was a problem hiding this comment.
🟡 Gating this block to OS(WINDOWS) and deleting the if (nowNs == 0) nowNs = us_internal_monotonic_ns(); fallback leaves stale artifacts: the comment's "(0 = take one)" clause now describes code that no longer exists (Windows always passes a reading, and if it passed 0 the sweep would be skipped, not "taken"), and the us_internal_monotonic_ns extern + its 3-line comment at the top of the file is now unreferenced (as is mi_on_thread_idle on non-Windows, and the nowNs parameter itself). Per the landing rules, dead code should be deleted in the same PR that makes it dead — drop the parenthetical, remove the #if !OS(WINDOWS) extern block, and gate the mi_on_thread_idle extern to OS(WINDOWS).
Extended reasoning...
What's stale
This PR made two changes to the #if USE(MIMALLOC) block in Bun__JSC_onBeforeWait:
- Tightened the guard from
#if USE(MIMALLOC)to#if USE(MIMALLOC) && OS(WINDOWS). - Deleted the fallback that used to live inside it:
#if !OS(WINDOWS) if (nowNs == 0) nowNs = us_internal_monotonic_ns(); #endif
Two pieces of surrounding text/code described that fallback and were not updated.
(a) The "(0 = take one)" comment clause
The comment above the rate-limit check still reads:
Rate-limited; nowNs is the tick's shared reading (0 = take one), compared by addition so an out-of-order reading cannot underflow.
"0 = take one" was accurate before this PR: on POSIX, nowNs == 0 triggered nowNs = us_internal_monotonic_ns(). That line is now deleted, and the block only compiles on Windows — where the fallback never existed in the first place (the extern comment at the top of the file explicitly says "Windows always passes a reading, so it needs no fallback", and libuv.c:226 confirms it: Bun__JSC_onBeforeWait(loop->data.jsc_vm, (uint64_t) uv_now(loop->uv_loop) * 1000000ULL)).
Step-by-step: if Windows ever did pass nowNs = 0, the check would be 0 >= lastIdleSweepNs + 100000000. With lastIdleSweepNs starting at 0, that's 0 >= 100000000 → false → sweep skipped. It would not "take one". The parenthetical now describes behavior that exists nowhere in the codebase. The PR touched this exact comment (added the "Windows only:" paragraph right below it), so this isn't pre-existing drift — the stale clause was left in a block the author edited.
(b) Dead extern declarations at the top of the file
#if USE(MIMALLOC)
extern "C" void mi_on_thread_idle(void) noexcept;
#if !OS(WINDOWS)
// uSockets' CLOCK_MONOTONIC reading (packages/bun-usockets/src/loop.c). Must be
// the same clock the caller's `nowNs` came from, or the rate limit below
// compares two epochs. Windows always passes a reading, so it needs no fallback.
extern "C" uint64_t us_internal_monotonic_ns(void);
#endif
#endifus_internal_monotonic_nsis guarded#if !OS(WINDOWS). Its only call site in this TU was the deleted fallback. The declaration and its 3-line explanatory comment are now completely unreferenced — the symbol is never mentioned again in this file.mi_on_thread_idleis declared under#if USE(MIMALLOC)(all platforms) but is now only called under#if USE(MIMALLOC) && OS(WINDOWS), so on Linux/macOS it's an unused extern declaration.- The
nowNsfunction parameter is now unread on non-Windows builds.
Impact
None at runtime. Unused extern declarations emit no code and create no link dependency; a stale comment doesn't execute. This is purely about the repo's landing rule (CLAUDE.md → "Delete dead code in the same PR that makes it dead (required scope)" and "Comments carry only durable non-obvious content") — the PR itself made these dead, so cleanup belongs in the same change.
Fix
- Drop "(0 = take one)" from the comment (e.g. "Rate-limited; nowNs is the tick's shared reading, compared by addition so an out-of-order reading cannot underflow.").
- Delete the
#if !OS(WINDOWS)extern block forus_internal_monotonic_nsentirely (declaration + 3-line comment). - Tighten the
mi_on_thread_idleextern guard to#if USE(MIMALLOC) && OS(WINDOWS)to match its only remaining call site. - Optionally
(void)nowNs;on non-Windows if the build treats unused parameters as errors here.
* upstream/main: (57 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (70 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (52 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ...
* upstream/main: (52 commits) node:http/https/http2: raise Node v26.3.0 compat to ~94%, sync the upstream suites, and fix the Windows/macOS transport-layer teardown bugs they exposed (oven-sh#32488) expect: fix panic in toBeArrayOfSize/toHaveBeenCalledTimes with length > i32 max (oven-sh#32266) lexer: fix TOKEN_TO_STRING[TColon] showing " =" instead of ":" (oven-sh#34253) Bun.Terminal: write() returns bytes accepted, fire drain on POSIX (oven-sh#34289) test(serve-body-leak): give release-asan the same 60s per-test timeout as debug (oven-sh#34297) worker: mark the context terminating before the final concurrent-queue drain (oven-sh#34278) buffer: wrap negative ucs2 indexOf offset against raw byte length for Buffer needles (oven-sh#34273) fs.promises.watch: yield events with a null prototype (oven-sh#34279) child_process: latch stdin write EPIPE as 'error' + destroy, fail later writes with ERR_STREAM_DESTROYED (oven-sh#34268) Fix asString assertion when passing String objects as signals (oven-sh#34265) Buffer: carry size_t through toString/write so length 2^32 doesn't wrap to 0 (oven-sh#34274) test: use tempDir in log-test.test.ts instead of hardcoded /tmp path (oven-sh#34294) tty: track raw mode per handle instead of per process (oven-sh#33527) test: expect the bumped mimalloc SHA in process.versions Return freed memory to the OS on a background thread instead of the JS thread (oven-sh#34181) Move WTFTimer out of the shared timer heap to fix a cross-thread race (oven-sh#33131) test: update block-scoped enum lowering expectations to let (oven-sh#34287) Error.captureStackTrace: install .stack as non-enumerable (oven-sh#34259) js_parser: treat "async as T" / "async satisfies T" as a cast, not an arrow (oven-sh#34246) js_parser: accept `!`, `#name`, and `export @dec` in standard decorator grammar (oven-sh#34245) ... # Conflicts: # test/js/bun/websocket/websocket-server.test.ts
… commit (#34335) Fixes a build break on main when `vendor/mimalloc` is fetched fresh. ## Repro ``` rm -rf vendor/mimalloc && bun bd ``` ``` [mimalloc] applying strnlen-oob-read.patch [mimalloc] error: Patch failed: error: patch failed: src/libc.c:64 [mimalloc] error: src/libc.c: patch does not apply ``` ## Cause #34181 bumped `MIMALLOC_COMMIT` to `24211c6e7610`, and that revision already has the `_mi_strnlen` operand-order fix (`while(len < max_len && s[len] != 0)`) at [`src/libc.c:67`](https://github.com/oven-sh/mimalloc/blob/24211c6e7610ae7c4ec06040758ec90bd21a1c83/src/libc.c#L67). #32488 merged right after with `patches/mimalloc/strnlen-oob-read.patch` written against the old context line (`while(s[len] != 0 && len < max_len)`), so `git apply` rejects it on a fresh fetch. Developers with a cached `vendor/mimalloc` from before #34181 won't hit this because the fetch step's `.ref` identity already matched and the patch step never ran. ## Fix Delete the patch and its `patches:` entry in `scripts/build/deps/mimalloc.ts`. The change it carried is already in the pinned upstream, so no behavior is lost (the comment on the entry even said "Drop when upstream fixes it"). ## Verification `rm -rf vendor/mimalloc && bun bd --revision` now fetches, builds, and runs cleanly. Confirmed the fetched `vendor/mimalloc/src/libc.c` has the bound check before the dereference.
|
The background sweep introduced here made the mimalloc-page-count leak check in |
Bun now returns freed memory to the operating system faster, and off the main thread.
When your JavaScript frees objects, that memory doesn't go back to the OS immediately — the allocator (mimalloc) has to walk its internal free lists and tell the kernel which pages it no longer needs (via
madvise). Until now, Bun did that walk on the JavaScript thread, right before it went idle to wait for I/O. Two problems with that:fetch, a chatty dev server), it barely got the chance to do it at all — so memory piled up.This PR moves that work to mimalloc's background scavenger thread. Right before Bun's event loop blocks waiting for I/O, the JS thread hands its heaps to the scavenger and says "I won't touch these until I wake up." The scavenger does the cleanup while your thread is asleep in the kernel — in parallel, at no cost to your code. When Bun wakes up, it takes the heaps back. If the scavenger is mid-cleanup at that moment, it stops immediately (one page's worth of work), so waking up is never blocked on it.
The upshot: memory comes back faster and more often, your JS thread stops doing allocator housekeeping, and — because the cleanup now runs continuously in the background — busy servers finally get cleaned up too. The trade is a little extra background CPU and a small throughput cost on the very fastest, most synthetic serving path (details below).
Pairs with oven-sh/mimalloc#8, which adds the underlying
mi_on_thread_idle_start()/mi_on_thread_idle_end()API to Bun's mimalloc fork.Benchmarks
Linux x64, 64-core, idle machine. Each row compares three binaries:
mainΔ is this PR vs canary. Memory is peak RSS. Full harness:
bun-perf-tester.🧠 Memory: back to the OS faster — the point of this PR
Peak RSS in MB. Lower is better.
next dev(create-next, lint)elysiatest suitenext startserving requests¹ after subtracting the ~6.5 MB orderfile artifact measured on the control row.
The biggest wins are exactly where the old design failed: long-lived processes with lots of allocation churn that rarely sat fully idle (dev servers,
next, linting large trees). That's the same shape as an agent loop.⚡ Throughput: parity where you have a JS handler, one measured cost where you don't
Requests/sec (higher is better) and mean latency. 1M requests per run except where noted.
node:httpBun.servewith afetch()handlernext start(100k reqs)Bun.servestatic route (no JS at all)Every server that runs JavaScript is within ±2% — a wash. Latency is unchanged across the board.
The one real cost is the
staticroute: a URL served with no JS handler at all, straight from native code. It's the fastest thing Bun can do, so it's the one place where the small per-tick cost of the handoff isn't hidden behind any actual work. It measured −4 to −6% across runs. Real apps don't serve their hot paths this way (there's no JS to run), and it's a deliberate, accepted trade for the memory above — but it's the honest number, so it's here.⚙️ CPU: where the work went
You'd expect this to raise CPU slightly — the same cleanup work still happens, just on another thread, and it now happens more often. That's exactly what shows up where the mechanism is exposed:
Bun.servestatic routemadvisework you're paying for — matches the −4% rpsBun.servefetch, Express, Fastify,node:httpnextlintSo the CPU cost is small, appears only where there's nothing else going on, and on the memory-heavy workloads the total actually goes down.
📝 Observed, not claimed
CLI wall-clock time (
eslint,prettier,tsc) was 10–30% faster on this PR than on canary in both runs, with tight variance. We are not claiming this as a benefit of this PR — those processes exit quickly and the mechanism here shouldn't produce a user-time speedup that large, andcanaryis ~23 commits ahead of this branch's base, so the comparison isn't isolated. Recorded here for honesty; it needs a same-base build to attribute.How it works (for the curious)
Two new calls in the event loop, bracketing the
epoll/kqueuewait:Guarantees the design relies on, all upheld in the mimalloc side (oven-sh/mimalloc#8):
epoll, and JS signal handlers (process.on('SIGINT', …)) are deferred to the event loop rather than run in the signal — so no allocation can sneak into the window.fork()and thread exit while a sweep is in flight are both handled — the thread reclaims its heaps first, so a child process never inherits a half-modified heap.Testing
test-park-handoff) covering the handoff, cross-threadfree,forkwhile parked, and thread exit while a sweep is running — checking not just for crashes but that live memory is byte-for-byte intact afterwards. It runs clean under Debug, Release, ThreadSanitizer, and Release + AddressSanitizer.