Conversation
… the heap has been idle The idle GC timer's collectAsync() lets JSC pick the scope, and with no allocation pressure JSC picks Eden every time. Old-generation garbage from an allocation burst (webpack compile, big JSON parse) is never swept, so RSS stays at the post-burst peak indefinitely. With BUN_JSC_logGC=1 a one-page Next.js 14 dev server shows 6 full collections during the ~2s compile and then 70+ eden-only collections over a minute of idle with the heap pinned at ~585 MB. The stability check also compared exact equality, but block_bytes_allocated() includes extraMemorySize() which jitters by a few KB between eden sweeps, so the counter never advanced and the timer never dropped to its 30 s slow interval either. After 30 consecutive fast ticks within max(prev/32, 64 KiB) of each other the controller now runs one synchronous full collectNow (plus clearSourceProviderCaches and deleteAllUnlinkedCodeBlocks, matching Bun.gc(true)) followed by WTF::releaseFastMallocFreeMemory() and mi_collect(true), then drops to the 30 s slow interval. A 16 MB heap floor skips the full GC for small heaps, and the reducer only re-fires once the heap has grown past the last post-reduction size. BUN_IDLE_MEMORY_REDUCER_DISABLE=1 opts out. For `bun --bun next dev` on a minimal Next.js 14.2 app (#13666): process-tree RSS settles at ~463 MB vs ~512 MB on main after 60 s warm + 60 s idle; Node 26 is ~388 MB for the same app.
WalkthroughChangesThe GC controller now performs synchronous idle memory reduction after stable fast ticks on sufficiently large heaps. The change adds VM and C++ bindings, an environment-variable disable switch, allocator cleanup, and cadence tests. Idle memory reduction
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 4:05 AM PT - Aug 1st, 2026
❌ @robobun, your commit cda7a12 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 36638That installs a local version of the PR into your bun-36638 --bun |
|
Found 8 issues this PR may fix:
🤖 Generated with Claude Code |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Re #30725: same root cause (idle |
There was a problem hiding this comment.
I didn't find any bugs, but this changes default GC behavior for every Bun process — a synchronous collectNow(Sync, Full) + deleteAllUnlinkedCodeBlocks + mi_collect(true) after 30 s of a stable heap. The heuristic constants (30 ticks, 16 MB floor, prev/32 tolerance) and the memory-vs-first-request-latency tradeoff are design calls that a maintainer should sign off on.
What was reviewed:
- State machine in
on_gc_repeating_timer: fast→slow transition,gc_last_reduction_heap_sizeguard preventing repeated reductions on a truly idle process, and reset-to-fast on heap growth all look correct. - New C++ binding mirrors the existing
JSC__VM__runGCshape (JSLockHolder,finalizeSynchronousJSExecution, samedeleteAllUnlinkedCodeBlocks/collectNowcalls). heap_size_is_stableusesabs_diffso no underflow; env var wiring and test fixture (subprocess pipes drained concurrently,bunEnvspread, release-only assertion gated onisDebug/isASAN) follow harness conventions.
Extended reasoning...
Overview
The PR modifies GarbageCollectionController to (1) replace exact-equality heap-size stability checks with a tolerance of max(prev/32, 64 KiB), and (2) after 30 consecutive stable fast-mode ticks with a heap ≥ 16 MB that has grown since the last reduction, run one synchronous full GC (collectNow(Sync, Full)) plus clearSourceProviderCaches / deleteAllUnlinkedCodeBlocks / WTF::releaseFastMallocFreeMemory / mi_collect(true), then drop to the 30 s slow interval. A new BUN_IDLE_MEMORY_REDUCER_DISABLE env var opts out of the reduction step. Touches GarbageCollectionController.rs, VM.rs, bindings.cpp, env_var.rs, and adds two tests to gc-controller-cadence.test.ts.
Security risks
None. No untrusted input is parsed; the only new external surface is a boolean env var read through the existing env_var machinery.
Level of scrutiny
High. This is a runtime-wide behavioral change to GC pacing that fires by default in every Bun process (including workers, since the controller is per-VM). A synchronous full GC on a large heap can be a multi-hundred-ms pause; if a request lands right after the 30 s idle window on a bursty server, that request eats the pause. deleteAllUnlinkedCodeBlocks may also cost recompilation time on the next request. The PR argues the 30-tick threshold is long enough to avoid this on busy servers, and provides an opt-out — but whether 30 s / 16 MB / prev÷32 are the right defaults, and whether this should be opt-in vs opt-out, are judgment calls a maintainer should make.
Other factors
The implementation itself looks solid: the C++ binding follows the exact shape of the existing JSC__VM__runGC(sync=true) path (which backs Bun.gc(true)); the gc_last_reduction_heap_size guard correctly prevents a truly-idle process from re-running the full GC every 30 ticks (initial value 0 means heap_size_is_stable(0, ≥16MB) is false, so the first reduction fires); gc_repeating_timer_fast gating means the reduction cannot fire from slow mode; and the tolerance check uses usize::abs_diff so there's no underflow risk. The tests follow harness conventions (concurrent pipe drain, bunEnv spread with explicit unset of BUN_GC_TIMER_DISABLE, release-only eden-count assertion gated on isDebug/isASAN). The PR description is thorough with before/after RSS numbers. Given the scope of the behavioral change and the heuristic tuning involved, deferring to human review.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/jsc/bindings/bindings.cpp`:
- Around line 2800-2812: Update the comments for
JSC__VM__reduceMemoryFootprintOnIdle in src/jsc/bindings/bindings.cpp (lines
2800-2812) to state that it deletes unlinked code blocks while retaining linked
code blocks; update the wrapper documentation in src/jsc/VM.rs (lines 106-109)
to mention unlinked-code-block deletion as well.
In `@test/js/bun/gc/gc-controller-cadence.test.ts`:
- Around line 110-115: Update the env configuration in the GC cadence test to
set BUN_IDLE_MEMORY_REDUCER_DISABLE to undefined before spreading extraEnv,
clearing any inherited process.env value while allowing the disable test’s
override to take precedence.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1f7391d6-22af-4640-bf5c-2619491e04cd
📒 Files selected for processing (5)
src/bun_core/env_var.rssrc/jsc/GarbageCollectionController.rssrc/jsc/VM.rssrc/jsc/bindings/bindings.cpptest/js/bun/gc/gc-controller-cadence.test.ts
…omments; clear inherited BUN_IDLE_MEMORY_REDUCER_DISABLE in test env
…se PreventCollectionAndDeleteAllCode Sample block_bytes_allocated() directly in on_gc_repeating_timer and only call collect_async() when the reducer is not firing. The reducer's collectNow(Sync, Full) subsumes the async eden request, and firing both dropped and re-acquired the JSLockHolder between them, which let a concurrent collection set m_collectionScope and short-circuit deleteAllUnlinkedCodeBlocks(DeleteAllCodeIfNotCollecting). Switch the effort to PreventCollectionAndDeleteAllCode to match JSC__VM__runGC(sync=true) for robustness against a FullGCActivityCallback landing at the same time.
|
CI on cda7a12: |
There was a problem hiding this comment.
No bugs found in this revision — my earlier nits (doc-comment precision, the collectAsync/DeleteAllCodeIfNotCollecting race, stale comment) are all addressed. Deferring to a human because this changes default GC behavior for every process: a synchronous collectNow(Sync, Full) + deleteAllUnlinkedCodeBlocks + allocator scavenge after 30 s of heap stability is a latency/memory trade-off whose tuning constants (30 ticks, 16 MB floor, prev/32 tolerance) and the choice to drop unlinked bytecode should get maintainer sign-off.
What was reviewed
on_gc_repeating_timerrestructure: heap sampled before the decision,collect_asyncskipped only on the reducer tick, timer re-armed on every path,gc_last_heap_sizestill updated.heap_size_is_stablereused for thegc_last_reduction_heap_sizegate — initial 0 vscurrent >= 16 MBcannot satisfyabs_diff <= tolerance, so first reduction fires; subsequent idle cycles are suppressed.JSC__VM__reduceMemoryFootprintOnIdle:JSLockHolderheld,PreventCollectionAndDeleteAllCodewaits out any concurrent collection,collectNow(notcollectSync) so sweep completes beforereleaseFastMallocFreeMemory.- Test hermeticity:
BUN_IDLE_MEMORY_REDUCER_DISABLEcleared beforeextraEnvspread; eden-count assertion gated on!isDebug && !isASAN.
Extended reasoning...
Overview
Adds an idle memory reducer to GarbageCollectionController: after STABLE_TICKS_BEFORE_REDUCTION (30) consecutive fast ticks with a stable heap ≥ 16 MB that has grown since the last reduction, it runs a synchronous full collectNow + clearSourceProviderCaches + deleteAllUnlinkedCodeBlocks(PreventCollectionAndDeleteAllCode) + WTF::releaseFastMallocFreeMemory + mi_collect(true), then drops to the 30 s slow interval. Also replaces the exact-equality heap-stability check with a max(prev/32, 64 KiB) tolerance so the timer actually reaches slow mode on an idle heap. Touches GarbageCollectionController.rs, VM.rs (new FFI wrapper), bindings.cpp (new JSC__VM__reduceMemoryFootprintOnIdle), env_var.rs (opt-out), and adds tests to the existing cadence test file.
Security risks
None. No untrusted input parsing, no auth/crypto, no FFI surface exposed to JS. The new env var is a boolean opt-out read once at controller init.
Level of scrutiny
High — this is a default-on behavioral change to the runtime's GC pacing that affects every long-running Bun process. The reducer introduces a stop-the-world full-GC pause (potentially tens of ms on a large heap) after ~30 s of idle, plus discards unlinked bytecode so the next request pays re-link cost. The PR is well-argued and the implementation looks correct, but the specific heuristic values and the decision to include deleteAllUnlinkedCodeBlocks (vs. just the full GC + scavenge) are policy choices a maintainer should confirm — e.g. a server with a 30–40 s inter-request lull would now eat the sync pause on the request that ends the lull.
Other factors
- All prior review threads (comment-cop, CodeRabbit, my three inline nits across earlier revisions) are resolved as of cda7a12.
perform_gc()remains live (called fromevent_loop.rs,VirtualMachine.rs,runtime/server,jsc_hooks.rs); the timer callback now inlines the equivalent so it can sample the heap before deciding.- Test asserts on the
IdleMemoryReducer firedlog line underBUN_JSC_logGCand the eden count; the disable-flag test covers the negative. The primary assertion (reducerFires >= 1) runs on debug too. - CI build #86931 was still running at last timeline update; no result visible yet.
Bun's GC fires on allocation/timer thresholds and can land mid-turn, stealing CPU exactly when a session is streaming — the same behavior Bun is fixing upstream for Claude Code (oven-sh/bun#36638, not yet released). Collect deliberately instead: when the last busy session goes idle, schedule one non-blocking full collection (Bun.gc(false)) after a 5s debounce; any new busy activity cancels it. Bun-only — the Node sidecar's V8 GC already schedules around mutator activity. Remove this once Bun ships an idle-driven GC controller natively.
|
Closing: the idle full GC landed on main in #41083 (561641b). The controller now requests Full collections once the heap has been quiet ( Verified on a debug build of main: about 80 MB of arrays promoted to old gen, references dropped, then idle. Eden collections free nothing, then the idle The allocator scavenge after the idle full GC ( This branch conflicts with main. |
What does this PR do?
Fixes the steady-state half of #13666 (
bun --bun next devuses substantially more memory thannode next dev).Repro
Scaffold a minimal Next.js 14.2.29 app (one static
pages/index.js), startbun --bun next dev, fetch/every 2 s for 60 s, then idle 60 s, sampling process-tree RSS. Peak is similar to Node (~430 MB), but Bun never comes back down while Node releases ~130 MB during idle.Running the child under
BUN_JSC_logGC=1:6
FullCollectionlines, all during the ~2 s webpack compile; after that, 70+EdenCollectionlines over a minute of idle withh=314872kbnever moving.Cause
GarbageCollectionController::perform_gccallscollectAsync()with no scope. With no allocation pressureHeap::shouldDoFullCollection()isfalse, so every idle tick is an eden collection and the ~100 MB of webpack temporaries that were promoted during the compile are never swept. JSC's ownFullGCActivityCallbackis allocation-driven, so it never re-arms while the process is idle.The fast/slow stability check also compared
block_bytes_allocated()for exact equality. That value includesextraMemorySize(), which jitters by a few KB between eden sweeps on an otherwise-idle heap, so the counter never advanced and the timer never dropped to its 30 s slow interval either (~60 pointless eden GCs per minute on an idle server).Fix
max(prev/32, 64 KiB)of each other as unchanged.heap >= 16 MB, and heap grown since the last reduction), run onecollectNow(Sync, Full)withclearSourceProviderCaches()+deleteAllUnlinkedCodeBlocks(PreventCollectionAndDeleteAllCode)(the same workBun.gc(true)already does) followed byWTF::releaseFastMallocFreeMemory()+mi_collect(true), then drop to the 30 s slow interval.BUN_IDLE_MEMORY_REDUCER_DISABLE=1opts out of the full GC + scavenge; the tolerance-based slow-mode transition stays.collectNow(notcollectSync) so the sweep finishes before the allocator is asked to decommit. UnlikeVM::shrinkFootprintWhenIdle()this does notdeleteAllCode, so JIT code survives the next request.Result
bun --bun next devon the one-page app above, process-tree RSS after 60 s warm + 60 s idle:The remaining gap to Node is mimalloc page fragmentation and JSC retaining more bytecode/structures than V8; closing that is out of scope here.
Verification
test/js/bun/gc/gc-controller-cadence.test.tsspawns a process with ~130 MB of live heap and a 50 ms GC timer interval, and asserts on theIdleMemoryReducer firedline underBUN_JSC_logGCplus the eden-collection count (release only). Fails on main withreducerFires == 0.[review] gate passed · iteration 1 · 5 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 1 rejected · iteration 1
evidence per changed file