Skip to content

GarbageCollectionController: run a full GC + scavenge after the heap has been idle - #36638

Closed
robobun wants to merge 5 commits into
mainfrom
farm/a6ec8dce/idle-memory-reducer
Closed

robobun wants to merge 5 commits into
mainfrom
farm/a6ec8dce/idle-memory-reducer

Conversation

@robobun

@robobun robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the steady-state half of #13666 (bun --bun next dev uses substantially more memory than node next dev).

Repro

Scaffold a minimal Next.js 14.2.29 app (one static pages/index.js), start bun --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:

[GC<0x28f..>: START M 160771kb => EdenCollection ... => 105318kb]
[GC<0x28f..>: START M 160771kb => EdenCollection ... => 105318kb]
[GC<0x28f..>: START M 160771kb => EdenCollection ... => 105319kb]
...

6 FullCollection lines, all during the ~2 s webpack compile; after that, 70+ EdenCollection lines over a minute of idle with h=314872kb never moving.

Cause

GarbageCollectionController::perform_gc calls collectAsync() with no scope. With no allocation pressure Heap::shouldDoFullCollection() is false, 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 own FullGCActivityCallback is 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 includes extraMemorySize(), 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

  • Treat two heap samples within max(prev/32, 64 KiB) of each other as unchanged.
  • After 30 consecutive fast ticks with a stable heap (and heap >= 16 MB, and heap grown since the last reduction), run one collectNow(Sync, Full) with clearSourceProviderCaches() + deleteAllUnlinkedCodeBlocks(PreventCollectionAndDeleteAllCode) (the same work Bun.gc(true) already does) followed by WTF::releaseFastMallocFreeMemory() + mi_collect(true), then drop to the 30 s slow interval.
  • BUN_IDLE_MEMORY_REDUCER_DISABLE=1 opts out of the full GC + scavenge; the tolerance-based slow-mode transition stays.

collectNow (not collectSync) so the sweep finishes before the allocator is asked to decommit. Unlike VM::shrinkFootprintWhenIdle() this does not deleteAllCode, so JIT code survives the next request.

Result

bun --bun next dev on the one-page app above, process-tree RSS after 60 s warm + 60 s idle:

main this PR node 26
tree 512 MB 463 MB 388 MB
next-server child 405 MB 390 MB 295 MB
parent 106 MB 73 MB 94 MB

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.ts spawns a process with ~130 MB of live heap and a 50 ms GC timer interval, and asserts on the IdleMemoryReducer fired line under BUN_JSC_logGC plus the eden-collection count (release only). Fails on main with reducerFires == 0.


[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 1 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/gc/gc-controller-cadence.test.ts
bun test v1.4.0 (cda7a1230)

test/js/bun/gc/gc-controller-cadence.test.ts:
(skip) GarbageCollectionController eden cadence > low-allocation setInterval does not trigger an eden GC per tick
(skip) GarbageCollectionController eden cadence > BUN_GC_TIMER_DISABLE=1 disables the controller
(pass) GarbageCollectionController idle memory reducer > BUN_IDLE_MEMORY_REDUCER_DISABLE=1 turns the reducer off [3650.89ms]
128 |   test.concurrent(
129 |     "runs a full GC + scavenge once the heap has been stable",
130 |     async () => {
131 |       const r = await runFixture({});
132 |       // Before the fix the reducer did not exist (0 fires).
133 |       expect(r.reducerFires).toBeGreaterThanOrEqual(1);
                                   ^
error: expect(received).toBeGreaterThanOrEqual(expected)

Expected: >= 1
Received: 0

      at <anonymous> (/workspace/bun/test/js/bun/gc/gc-controller-cadence.test.ts:133:30)
(fail) GarbageCollectionController idle memory reducer > runs a full GC + scavenge once the
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (654a749a4)

test/js/bun/gc/gc-controller-cadence.test.ts:
(pass) GarbageCollectionController eden cadence > low-allocation setInterval does not trigger an eden GC per tick [2032.19ms]
(pass) GarbageCollectionController eden cadence > BUN_GC_TIMER_DISABLE=1 disables the controller [2049.95ms]
(pass) GarbageCollectionController idle memory reducer > runs a full GC + scavenge once the heap has been stable [2605.41ms]
(pass) GarbageCollectionController idle memory reducer > BUN_IDLE_MEMORY_REDUCER_DISABLE=1 turns the reducer off [2639.53ms]

 4 pass
 0 fail
 9 expect() calls
Ran 4 tests across 1 file. [2.87s]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/gc/gc-controller-cadence.test.ts
bun test v1.4.0 (cda7a1230)

test/js/bun/gc/gc-controller-cadence.test.ts:
(skip) GarbageCollectionController eden cadence > low-allocation setInterval does not trigger an eden GC per tick
(skip) GarbageCollectionController eden cadence > BUN_GC_TIMER_DISABLE=1 disables the controller
(pass) GarbageCollectionController idle memory reducer > runs a full GC + scavenge once the heap has been stable [3319.76ms]
(pass) GarbageCollectionController idle memory reducer > BUN_IDLE_MEMORY_REDUCER_DISABLE=1 turns the reducer off [3330.37ms]

 2 pass
 2 skip
 0 fail
 4 expect() calls
Ran 4 tests across 1 file. [6.66s]
__F:0:S:2

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1064ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/52] gen cpp.rs (cppbind)
[2/52] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[2/52] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[9
... (truncated)
diff hotspot
src/bun_core/env_var.rs                      |  2 +
 src/jsc/GarbageCollectionController.rs       | 52 +++++++++++++++++--
 src/jsc/VM.rs                                |  6 +++
 src/jsc/bindings/bindings.cpp                | 12 +++++
 test/js/bun/gc/gc-controller-cadence.test.ts | 76 +++++++++++++++++++++++++++-
 5 files changed, 142 insertions(+), 6 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                          reads  edits  tests
src/bun_core/env_var.rs                           1      1      0
src/jsc/GarbageCollectionController.rs            9     18      0
src/jsc/VM.rs                                     3      4      0
src/jsc/bindings/bindings.cpp                     4      7      0
test/js/bun/gc/gc-controller-cadence.test.ts      1      8      0

… 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.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The 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

Layer / File(s) Summary
VM memory reduction binding
src/jsc/VM.rs, src/jsc/bindings/bindings.cpp
Adds the VM wrapper and FFI binding for synchronous full GC, VM cleanup, and unused allocator memory release.
Stable heap scheduling
src/bun_core/env_var.rs, src/jsc/GarbageCollectionController.rs
Adds stability tolerance, heap-size thresholds, reduction tracking, and BUN_IDLE_MEMORY_REDUCER_DISABLE handling.
Cadence validation
test/js/bun/gc/gc-controller-cadence.test.ts
Tests reducer activation after heap stabilization and suppression when the environment variable is enabled.

Possibly related PRs

  • oven-sh/bun#35356: Both changes modify GarbageCollectionController and cadence tests, including heap-growth and timer behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title accurately summarizes the main objective: adding idle-triggered full GC and allocator scavenging to GarbageCollectionController.
Description check ✅ Passed The description provides comprehensive coverage: it explains the problem, root cause, solution, results, and verification. Both required template sections are present and complete.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Aug 1, 2026
@robobun

robobun commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:05 AM PT - Aug 1st, 2026

❌ @robobun, your commit cda7a12 has 1 failures in Build #86931 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36638

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

bun-36638 --bun

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 8 issues this PR may fix:

  1. Memory (RSS) in Bun Spawned Child Process Grows Slowly, Even When Idle #21560 - RSS grows slowly even when idle; eden-only GC never sweeps old-gen, mimalloc never scavenges
  2. Memory leak in Next.js SSR under bun --bun next start — JSC GC fails to reclaim heap after concurrent requests #29267 - Next.js SSR heap not reclaimed after request bursts; idle full GC would sweep promoted temporaries
  3. macOS Apple Silicon: memory invisible to RSS — bmalloc slabs, worker cleanup gaps, GC safety bugs #28318 - Meta-issue proposing exactly this: call shrinkFootprintWhenIdle from GarbageCollectionController during idle
  4. Likely memoryleak inside bun runtime on service http requests #14065 - Long-running HTTP server RSS grows while heapSize stays flat; idle scavenge would return native memory to OS
  5. Memory leak since miggration from node 20 to bun 1.1.43 of a nextjs website #16339 - Next.js server memory grows after SSR bursts and never reclaims during idle periods
  6. discord.js bot memory usage 6x higher and climbing with switch from Node to Bun #8856 - discord.js bot memory 6x higher than Node and climbing; bursty activity with idle periods would benefit from idle GC
  7. Memory leak (typescript-lsp) #9769 - TypeScript LSP memory grows ~1GB per file save and never drops during idle between saves
  8. 3.6x memory usage over node while running NestJS application #4796 - NestJS server 3.6x memory vs Node; idle full GC + mimalloc scavenge would reduce steady-state RSS

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #21560
Fixes #29267
Fixes #28318
Fixes #14065
Fixes #16339
Fixes #8856
Fixes #9769
Fixes #4796

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. GarbageCollectionController: fire up to 2 Full GCs before going to slow interval #30725 - Also modifies GarbageCollectionController idle behavior to fire explicit Full GCs before transitioning to slow interval, fixing the same root cause (collectAsync only picks Eden collections at idle, leaving promoted old-gen garbage uncollected)

🤖 Generated with Claude Code

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Re #30725: same root cause (idle collectAsync picks Eden), but that PR predates the #35356 refactor of GarbageCollectionController (no more per-tick sampler / GCTimerState), so it conflicts with the current file. It also uses collectAsync(Full) with no allocator scavenge, which leaves the freed pages in mimalloc; this PR uses collectNow so the sweep completes before releaseFastMallocFreeMemory() + mi_collect(true) can decommit them, and fixes the exact-equality stability check that keeps the timer in fast mode indefinitely. If this lands, #30725 can be closed.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_size guard 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__runGC shape (JSLockHolder, finalizeSynchronousJSExecution, same deleteAllUnlinkedCodeBlocks/collectNow calls).
  • heap_size_is_stable uses abs_diff so no underflow; env var wiring and test fixture (subprocess pipes drained concurrently, bunEnv spread, release-only assertion gated on isDebug/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.

Comment thread src/jsc/GarbageCollectionController.rs Outdated
Comment thread src/jsc/GarbageCollectionController.rs Outdated
Comment thread src/jsc/GarbageCollectionController.rs Outdated
Comment thread src/jsc/GarbageCollectionController.rs Outdated
Comment thread src/jsc/VM.rs Outdated
Comment thread src/jsc/bindings/bindings.cpp Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5f7e62d and 6439f52.

📒 Files selected for processing (5)
  • src/bun_core/env_var.rs
  • src/jsc/GarbageCollectionController.rs
  • src/jsc/VM.rs
  • src/jsc/bindings/bindings.cpp
  • test/js/bun/gc/gc-controller-cadence.test.ts

Comment thread src/jsc/bindings/bindings.cpp Outdated
Comment thread test/js/bun/gc/gc-controller-cadence.test.ts
Comment thread src/jsc/VM.rs Outdated
…omments; clear inherited BUN_IDLE_MEMORY_REDUCER_DISABLE in test env
Comment thread src/jsc/bindings/bindings.cpp Outdated
…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.
Comment thread src/jsc/GarbageCollectionController.rs Outdated
Comment thread src/jsc/GarbageCollectionController.rs Outdated
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI on cda7a12: gc-controller-cadence.test.ts passes on every lane. The one hard failure is test/cli/install/bun-upgrade.test.ts on Windows aarch64 (Canary builds are not available for this platform yet), which is unrelated to this change; everything else is marked flaky (passed on retry). Diff is ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_timer restructure: heap sampled before the decision, collect_async skipped only on the reducer tick, timer re-armed on every path, gc_last_heap_size still updated.
  • heap_size_is_stable reused for the gc_last_reduction_heap_size gate — initial 0 vs current >= 16 MB cannot satisfy abs_diff <= tolerance, so first reduction fires; subsequent idle cycles are suppressed.
  • JSC__VM__reduceMemoryFootprintOnIdle: JSLockHolder held, PreventCollectionAndDeleteAllCode waits out any concurrent collection, collectNow (not collectSync) so sweep completes before releaseFastMallocFreeMemory.
  • Test hermeticity: BUN_IDLE_MEMORY_REDUCER_DISABLE cleared before extraEnv spread; 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 from event_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 fired log line under BUN_JSC_logGC and 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.

kianwoon added a commit to kianwoon/opencode-app that referenced this pull request Aug 20, 2026
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.
@robobun

robobun commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the idle full GC landed on main in #41083 (561641b). The controller now requests Full collections once the heap has been quiet (BUN_IDLE_GC_SECONDS, default 10,65,65), and the heap sample has a 2 MB growth tolerance, which covers the core of what this PR set out to do.

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 FullCollection fires and the heap drops from 78756 kb to 609 kb. test/js/bun/gc/gc-controller-cadence.test.ts passes on main.

The allocator scavenge after the idle full GC (WTF::releaseFastMallocFreeMemory() and mi_collect(true)) is not part of #41083. If measurements show it still matters on main, it can be proposed as a small follow-up PR on top of the new code.

This branch conflicts with main.

@robobun robobun closed this Sep 1, 2026
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.

2 participants