diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 142987436a4..67381cb71ad 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -77,6 +77,16 @@ new!(pub BUN_ENABLE_CRASH_REPORTING: boolean, "BUN_ENABLE_CRASH_REPORTING", {}); // so nothing it spawned outlives it. See `src/io/ParentDeathWatchdog.rs`. new!(pub BUN_FEATURE_FLAG_NO_ORPHANS: boolean, "BUN_FEATURE_FLAG_NO_ORPHANS", { default: false }); new!(pub BUN_FEATURE_FLAG_DUMP_CODE: string, "BUN_FEATURE_FLAG_DUMP_CODE", {}); +// How many GarbageCollectionController.onGCTimer passes occur before the +// event loop skips releasing JSC access while idle. See +// `Bun__defaultRemainingRunsUntilSkipReleaseAccess`. +new!(pub BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS: unsigned, "BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS", {}); +// Disables the GarbageCollectionController's repeating GC timer. +new!(pub BUN_GC_TIMER_DISABLE: boolean, "BUN_GC_TIMER_DISABLE", { default: false }); +// GarbageCollectionController fast-mode repeating timer interval in +// milliseconds. After 30 non-growing ticks the controller fires up to 2 +// Full GCs and then drops to a fixed 30s slow interval. +new!(pub BUN_GC_TIMER_INTERVAL: unsigned, "BUN_GC_TIMER_INTERVAL", {}); // TODO(markovejnovic): It's unclear why the default here is 100_000, but this was legacy behavior // so we'll keep it for now. new!(pub BUN_INOTIFY_COALESCE_INTERVAL: unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", { default: 100_000 }); diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index c35111234f5..fec6fc2997f 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -20,7 +20,6 @@ use core::ffi::c_int; -#[cfg(debug_assertions)] use bun_core::env_var; use bun_uws as uws; @@ -34,6 +33,7 @@ pub struct GarbageCollectionController { pub gc_last_heap_size: usize, pub gc_last_heap_size_on_repeating_timer: usize, pub heap_size_didnt_change_for_repeating_timer_ticks_count: u8, + pub idle_full_gcs_fired: u8, pub gc_timer_state: GCTimerState, // Raw FFI handle created by `uws::Timer::create_fallthrough` in `init`, // freed in Drop. @@ -50,6 +50,7 @@ impl Default for GarbageCollectionController { gc_last_heap_size: 0, gc_last_heap_size_on_repeating_timer: 0, heap_size_didnt_change_for_repeating_timer_ticks_count: 0, + idle_full_gcs_fired: 0, gc_timer_state: GCTimerState::Pending, gc_repeating_timer: None, gc_timer_interval: 0, @@ -129,44 +130,32 @@ impl GarbageCollectionController { } } - // `Transpiler::init` is deferred to the high-tier - // `init_runtime_state` hook (which runs *after* `ensure_waker` → - // this `init`), so `vm.transpiler.env` is still the zeroed null ptr - // here on the main boot path. Fall back to defaults when null — these are debug/tuning - // knobs (BUN_GC_TIMER_INTERVAL / BUN_GC_TIMER_DISABLE / - // BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS) and the dot_env loader would - // just be reading process env anyway. - let env = vm.env_loader_opt(); - - let mut gc_timer_interval: i32 = 1000; - if let Some(timer) = env.and_then(|e| e.get(b"BUN_GC_TIMER_INTERVAL")) { - if let Some(parsed) = bun_core::fmt::parse_decimal::(timer) { - if parsed > 0 { - gc_timer_interval = parsed; - } - } + // init() runs from ensure_waker() before Transpiler::init has copied + // the process environment into vm.transpiler.env, so these go + // through bun_core::env_var (process-environment backed), not + // vm.env_loader_opt(). + self.gc_timer_interval = match env_var::BUN_GC_TIMER_INTERVAL.get() { + Some(interval) => i32::try_from(interval).unwrap_or(1000), + None => 1000, + }; + if self.gc_timer_interval <= 0 { + self.gc_timer_interval = 1000; } - self.gc_timer_interval = gc_timer_interval; - if let Some(val) = env.and_then(|e| e.get(b"BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS")) { - if let Some(parsed) = bun_core::fmt::parse_decimal::(val) { - if parsed >= 0 { - crate::virtual_machine::Bun__defaultRemainingRunsUntilSkipReleaseAccess - .store(parsed, core::sync::atomic::Ordering::Relaxed); - } + if let Some(runs) = env_var::BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS.get() { + if let Ok(val) = c_int::try_from(runs) { + crate::virtual_machine::Bun__defaultRemainingRunsUntilSkipReleaseAccess + .store(val, core::sync::atomic::Ordering::Relaxed); } } - self.disabled = env.is_some_and(|e| e.has(b"BUN_GC_TIMER_DISABLE")); + self.disabled = env_var::BUN_GC_TIMER_DISABLE.get().unwrap_or(false); if !self.disabled { let ext = std::ptr::from_mut::(self); - self.gc_repeating_timer_mut().set( - ext, - Some(on_gc_repeating_timer), - gc_timer_interval, - gc_timer_interval, - ); + let interval = self.gc_timer_interval; + self.gc_repeating_timer_mut() + .set(ext, Some(on_gc_repeating_timer), interval, interval); } } @@ -220,6 +209,7 @@ impl GarbageCollectionController { self.gc_repeating_timer_mut() .set(ext, Some(on_gc_repeating_timer), interval, interval); self.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + self.idle_full_gcs_fired = 0; } else if setting == GcRepeatSetting::Slow && self.gc_repeating_timer_fast { self.gc_repeating_timer_fast = false; let ext = std::ptr::from_mut::(self); @@ -241,10 +231,23 @@ impl GarbageCollectionController { fn process_gc_timer_with_heap_size(&mut self, vm: &VM, this_heap_size: usize) { let prev = self.gc_last_heap_size; + // Growth here means allocation resumed. update_gc_repeat_timer(Fast) + // only clears idle_full_gcs_fired on a genuine slow→fast transition; + // while the 30-tick window is running we're still in fast mode, so + // clear it directly. The stable-tick counter is left alone — + // resetting it from this high-frequency path would starve the idle + // Full GC entirely. + if this_heap_size > prev { + self.idle_full_gcs_fired = 0; + } + match self.gc_timer_state { GCTimerState::RunOnNextTick => { - // When memory usage is not stable, run the GC more. - if this_heap_size != prev { + // Only growth signals activity. A decrease is the async GC we + // just requested freeing memory; treating it as activity would + // cancel reduction mode and prevent the slow-interval + // transition from ever being reached. + if this_heap_size > prev { self.schedule_gc_timer(); self.update_gc_repeat_timer(GcRepeatSetting::Fast); } else { @@ -254,7 +257,7 @@ impl GarbageCollectionController { self.gc_last_heap_size = this_heap_size; } GCTimerState::Pending => { - if this_heap_size != prev { + if this_heap_size > prev { self.update_gc_repeat_timer(GcRepeatSetting::Fast); if this_heap_size > prev * 2 { @@ -262,6 +265,14 @@ impl GarbageCollectionController { } else { self.schedule_gc_timer(); } + } else if this_heap_size < prev { + // An async GC shrank the heap. The repeating timer no + // longer writes gc_last_heap_size and the growth branch + // above won't fire until re-growth exceeds the pre-shrink + // value, so lower the baseline here. Don't reschedule or + // touch the repeat timer — that would cancel idle + // reduction. + self.gc_last_heap_size = this_heap_size; } } GCTimerState::Scheduled => { @@ -299,21 +310,62 @@ pub(crate) extern "C" fn on_gc_timer(timer: *mut uws::Timer) { pub(crate) extern "C" fn on_gc_repeating_timer(timer: *mut uws::Timer) { let this = GarbageCollectionController::from_timer_ext(timer); + if this.disabled { + return; + } let prev_heap_size = this.gc_last_heap_size_on_repeating_timer; - this.perform_gc(); - this.gc_last_heap_size_on_repeating_timer = this.gc_last_heap_size; - if prev_heap_size == this.gc_last_heap_size_on_repeating_timer { + let vm = VirtualMachine::get().jsc_vm(); + let current = vm.block_bytes_allocated(); + this.gc_last_heap_size_on_repeating_timer = current; + + // Reduction mode: previous tick fired collect_async_full(); decide + // whether to fire one more or converge. V8 MemoryReducer caps at 2 + // majors per idle. + if this.idle_full_gcs_fired > 0 { + if current > prev_heap_size { + this.idle_full_gcs_fired = 0; + this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + vm.collect_async(); + } else if prev_heap_size - current > (1 << 20) && this.idle_full_gcs_fired < 2 { + this.idle_full_gcs_fired += 1; + vm.collect_async_full(); + } else { + this.idle_full_gcs_fired = 0; + this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + this.update_gc_repeat_timer(GcRepeatSetting::Slow); + } + return; + } + + if current <= prev_heap_size { this.heap_size_didnt_change_for_repeating_timer_ticks_count = this .heap_size_didnt_change_for_repeating_timer_ticks_count .saturating_add(1); - if this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 { - // make the timer interval longer - this.update_gc_repeat_timer(GcRepeatSetting::Slow); + if this.gc_repeating_timer_fast + && this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30 + { + // 30 stable fast ticks of Eden GCs. collect_async() never + // escalates to Full here because Heap::updateAllocationLimits + // ratchets m_maxHeapSize on every Eden, so the 1/3 promotion + // ratio decays instead of crossing. Fire an explicit Full so + // old-gen + age-based CodeBlock jettison run before we go to + // the 30s interval. + this.idle_full_gcs_fired = 1; + // The counter has done its job. If the allocation path observes + // growth between this tick and the next, it clears + // idle_full_gcs_fired (bypassing reduction mode); leaving the + // counter at 30 would immediately re-enter this branch and fire + // another Full GC, skipping the < 2 cap in the reduction branch. + this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + vm.collect_async_full(); + return; } } else { this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; this.update_gc_repeat_timer(GcRepeatSetting::Fast); } + + vm.collect_async(); } #[repr(u8)] diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index bfe379e7e72..40483848953 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -32,6 +32,7 @@ unsafe extern "C" { safe fn JSC__VM__runGC(vm: &VM, sync: bool) -> usize; safe fn JSC__VM__heapSize(vm: &VM) -> usize; safe fn JSC__VM__collectAsync(vm: &VM); + safe fn JSC__VM__collectAsyncFull(vm: &VM); safe fn JSC__VM__setExecutionForbidden(vm: &VM, forbidden: bool); safe fn JSC__VM__setExecutionTimeLimit(vm: &VM, timeout: f64); safe fn JSC__VM__clearExecutionTimeLimit(vm: &VM); @@ -130,6 +131,10 @@ impl VM { JSC__VM__collectAsync(self) } + pub fn collect_async_full(&self) { + JSC__VM__collectAsyncFull(self) + } + pub fn set_execution_forbidden(&self, forbidden: bool) { JSC__VM__setExecutionForbidden(self, forbidden) } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 5ddf563c32c..4780cc1d1c5 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2867,6 +2867,12 @@ void JSC__VM__collectAsync(JSC::VM* vm) vm->heap.collectAsync(); } +void JSC__VM__collectAsyncFull(JSC::VM* vm) +{ + JSC::JSLockHolder lock(*vm); + vm->heap.collectAsync(JSC::CollectionScope::Full); +} + extern "C" bool JSC__VM__hasExecutionTimeLimit(JSC::VM* vm) { JSC::JSLockHolder locker(vm); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index c85e7379065..00c596a21c0 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -302,6 +302,7 @@ CPP_DECL void JSC__JSValue__toZigString(JSC::EncodedJSValue JSValue0, ZigString* CPP_DECL size_t JSC__VM__blockBytesAllocated(JSC::VM* arg0); CPP_DECL void JSC__VM__clearExecutionTimeLimit(JSC::VM* arg0); CPP_DECL void JSC__VM__collectAsync(JSC::VM* arg0); +CPP_DECL void JSC__VM__collectAsyncFull(JSC::VM* arg0); CPP_DECL JSC::VM* JSC__VM__create(unsigned char HeapType0); CPP_DECL void JSC__VM__deinit(JSC::VM* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void JSC__VM__deleteAllCode(JSC::VM* arg0, JSC::JSGlobalObject* arg1); diff --git a/test/js/bun/gc/gc-controller-idle-full.test.ts b/test/js/bun/gc/gc-controller-idle-full.test.ts new file mode 100644 index 00000000000..1259b41f8d7 --- /dev/null +++ b/test/js/bun/gc/gc-controller-idle-full.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// The repeating GC timer's collectAsync() lets JSC pick Eden vs Full. At idle +// JSC keeps picking Eden because Heap::updateAllocationLimits ratchets +// m_maxHeapSize on every Eden GC, so the 1/3 Full-promotion ratio stays above +// the threshold instead of crossing it. Before #29280 this meant old-gen +// garbage was never reclaimed while idle. Now, after 30 stable fast ticks, +// the controller fires an explicit collectAsync(CollectionScope::Full). +// +// Run the fixture as a file, not via `-e`: the Rust port's one-shot eval +// path (is_one_shot_eval_invocation) sets numberOfGCMarkers=1 for `-e`, +// which stalls the concurrent collector and makes the async Full GC unable +// to complete while the mutator is idle. + +const fixture = /* js */ ` +import { heapSize, fullGC } from "bun:jsc"; + +// ~40 MB of JS-heap-resident data. +let data = []; +for (let i = 0; i < 5000; i++) data.push(new Array(1000).fill(i)); + +// fullGC() while still referenced promotes everything to old gen and sets +// m_maxHeapSize = proportionalHeapSize(~40 MB), which is large enough that +// the post-release edenToOldGenerationRatio stays >= 1/3 and JSC's own +// shouldDoFullCollection() heuristic never fires. This is the shape a +// long-running server reaches organically; we force it here so the test is +// deterministic. +fullGC(); +fullGC(); + +data = null; + +const initial = heapSize(); +process.stdout.write(\`INITIAL=\${initial}\\n\`); + +// Keep the event loop alive without allocating. With BUN_GC_TIMER_INTERVAL=20 +// the controller ticks every 20ms; once it sees 30 non-growing ticks (~600ms) +// it requests an async Full GC and converges to the slow interval. 5s of +// pure idle gives ~8x headroom for loaded darwin-x64 hosts where uws timers +// can be coalesced to coarser effective intervals under CPU pressure. +await Bun.sleep(5000); + +// The Full GC is async — poll until its result is visible in heapSize(). +// Without the idle Full GC the loop runs to completion with heap unchanged. +const threshold = initial / 4; +let final = heapSize(); +for (let i = 0; i < 30 && final >= threshold; i++) { + await Bun.sleep(100); + final = heapSize(); +} +process.stdout.write(\`FINAL=\${final}\\n\`); +`; + +test("GC controller fires a Full GC at idle so old-gen garbage is reclaimed", async () => { + using dir = tempDir("gc-controller-idle-full", { + "fixture.ts": fixture, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: { + ...bunEnv, + BUN_GC_TIMER_INTERVAL: "20", + }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + + const initial = Number(/INITIAL=(\d+)/.exec(stdout)?.[1]); + const final = Number(/FINAL=(\d+)/.exec(stdout)?.[1]); + expect(initial).toBeGreaterThan(20 * 1024 * 1024); + expect(Number.isFinite(final)).toBe(true); + + // Without the idle Full GC, the repeating timer only runs Eden collections + // and `final` stays within a few hundred KB of `initial`. With it, the + // ~40 MB of promoted arrays is reclaimed and the heap drops to ~1 MB. + expect(final).toBeLessThan(initial / 4); + + expect(exitCode).toBe(0); +}, 30_000);