diff --git a/src/bun_core/env_var.zig b/src/bun_core/env_var.zig index 858604d50d4..6d678c9c48c 100644 --- a/src/bun_core/env_var.zig +++ b/src/bun_core/env_var.zig @@ -62,6 +62,16 @@ pub const BUN_ENABLE_CRASH_REPORTING = New(kind.boolean, "BUN_ENABLE_CRASH_REPOR /// so nothing it spawned outlives it. See `src/ParentDeathWatchdog.zig`. pub const BUN_FEATURE_FLAG_NO_ORPHANS = New(kind.boolean, "BUN_FEATURE_FLAG_NO_ORPHANS", .{ .default = false }); pub const BUN_FEATURE_FLAG_DUMP_CODE = New(kind.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`. +pub const BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS = New(kind.unsigned, "BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS", .{}); +/// Disables the GarbageCollectionController's repeating GC timer. +pub const BUN_GC_TIMER_DISABLE = New(kind.boolean, "BUN_GC_TIMER_DISABLE", .{ .default = false }); +/// GarbageCollectionController fast-mode repeating timer interval in +/// milliseconds. After 30 non-growing ticks the controller fires a Full GC +/// and drops to a fixed 30s slow interval. +pub const BUN_GC_TIMER_INTERVAL = New(kind.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. pub const BUN_INOTIFY_COALESCE_INTERVAL = New(kind.unsigned, "BUN_INOTIFY_COALESCE_INTERVAL", .{ .default = 100_000 }); diff --git a/src/jsc/GarbageCollectionController.zig b/src/jsc/GarbageCollectionController.zig index 583ddaafa6c..c8d281f4c2a 100644 --- a/src/jsc/GarbageCollectionController.zig +++ b/src/jsc/GarbageCollectionController.zig @@ -24,6 +24,7 @@ gc_timer: *uws.Timer = undefined, gc_last_heap_size: usize = 0, gc_last_heap_size_on_repeating_timer: usize = 0, heap_size_didnt_change_for_repeating_timer_ticks_count: u8 = 0, +#idle_full_gcs_fired: u8 = 0, gc_timer_state: GCTimerState = GCTimerState.pending, gc_repeating_timer: *uws.Timer = undefined, gc_timer_interval: i32 = 0, @@ -42,28 +43,25 @@ pub fn init(this: *GarbageCollectionController, vm: *VirtualMachine) void { } } - var gc_timer_interval: i32 = 1000; - if (vm.transpiler.env.get("BUN_GC_TIMER_INTERVAL")) |timer| { - if (std.fmt.parseInt(i32, timer, 10)) |parsed| { - if (parsed > 0) { - gc_timer_interval = parsed; - } - } else |_| {} - } - this.gc_timer_interval = gc_timer_interval; - - if (vm.transpiler.env.get("BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS")) |val| { - if (std.fmt.parseInt(c_int, val, 10)) |parsed| { - if (parsed >= 0) { - VirtualMachine.Bun__defaultRemainingRunsUntilSkipReleaseAccess = parsed; - } - } else |_| {} + // init() runs from ensureWaker() before Transpiler.runEnvLoader() has + // copied the process environment into vm.transpiler.env, so these go + // through bun.env_var (process-environment backed), not vm.transpiler.env. + this.gc_timer_interval = if (bun.env_var.BUN_GC_TIMER_INTERVAL.get()) |interval| + std.math.cast(i32, interval) orelse 1000 + else + 1000; + if (this.gc_timer_interval <= 0) this.gc_timer_interval = 1000; + + if (bun.env_var.BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS.get()) |runs| { + if (std.math.cast(c_int, runs)) |val| { + VirtualMachine.Bun__defaultRemainingRunsUntilSkipReleaseAccess = val; + } } - this.disabled = vm.transpiler.env.has("BUN_GC_TIMER_DISABLE"); + this.disabled = bun.env_var.BUN_GC_TIMER_DISABLE.get(); if (!this.disabled) - this.gc_repeating_timer.set(this, onGCRepeatingTimer, gc_timer_interval, gc_timer_interval); + this.gc_repeating_timer.set(this, onGCRepeatingTimer, this.gc_timer_interval, this.gc_timer_interval); } pub fn deinit(this: *GarbageCollectionController) void { @@ -102,6 +100,7 @@ pub fn updateGCRepeatTimer(this: *GarbageCollectionController, comptime setting: this.gc_repeating_timer_fast = true; this.gc_repeating_timer.set(this, onGCRepeatingTimer, this.gc_timer_interval, this.gc_timer_interval); this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; + this.#idle_full_gcs_fired = 0; } else if (setting == .slow and this.gc_repeating_timer_fast) { this.gc_repeating_timer_fast = false; this.gc_repeating_timer.set(this, onGCRepeatingTimer, 30_000, 30_000); @@ -111,19 +110,54 @@ pub fn updateGCRepeatTimer(this: *GarbageCollectionController, comptime setting: pub fn onGCRepeatingTimer(timer: *uws.Timer) callconv(.c) void { var this = timer.as(*GarbageCollectionController); + if (this.disabled) return; const prev_heap_size = this.gc_last_heap_size_on_repeating_timer; - this.performGC(); - 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) { - this.heap_size_didnt_change_for_repeating_timer_ticks_count +|= 1; - if (this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30) { - // make the timer interval longer + var vm = this.bunVM().jsc_vm; + const current = vm.blockBytesAllocated(); + this.gc_last_heap_size_on_repeating_timer = current; + + // Reduction mode: previous tick fired collectAsyncFull(); 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.collectAsync(); + } else if (prev_heap_size - current > (1 << 20) and this.#idle_full_gcs_fired < 2) { + this.#idle_full_gcs_fired += 1; + vm.collectAsyncFull(); + } else { + this.#idle_full_gcs_fired = 0; + this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; this.updateGCRepeatTimer(.slow); } + return; + } + + if (current <= prev_heap_size) { + this.heap_size_didnt_change_for_repeating_timer_ticks_count +|= 1; + if (this.gc_repeating_timer_fast and this.heap_size_didnt_change_for_repeating_timer_ticks_count >= 30) { + // 30 stable fast ticks of Eden GCs. collectAsync() 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.collectAsyncFull(); + return; + } } else { this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0; this.updateGCRepeatTimer(.fast); } + + vm.collectAsync(); } pub fn processGCTimer(this: *GarbageCollectionController) void { @@ -135,10 +169,20 @@ pub fn processGCTimer(this: *GarbageCollectionController) void { fn processGCTimerWithHeapSize(this: *GarbageCollectionController, vm: *jsc.VM, this_heap_size: usize) void { const prev = this.gc_last_heap_size; + // Growth here means allocation resumed. updateGCRepeatTimer(.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) this.#idle_full_gcs_fired = 0; + switch (this.gc_timer_state) { .run_on_next_tick => { - // 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) { this.scheduleGCTimer(); this.updateGCRepeatTimer(.fast); } else { @@ -148,7 +192,7 @@ fn processGCTimerWithHeapSize(this: *GarbageCollectionController, vm: *jsc.VM, t this.gc_last_heap_size = this_heap_size; }, .pending => { - if (this_heap_size != prev) { + if (this_heap_size > prev) { this.updateGCRepeatTimer(.fast); if (this_heap_size > prev * 2) { @@ -156,6 +200,13 @@ fn processGCTimerWithHeapSize(this: *GarbageCollectionController, vm: *jsc.VM, t } else { this.scheduleGCTimer(); } + } 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. + this.gc_last_heap_size = this_heap_size; } }, .scheduled => { diff --git a/src/jsc/VM.zig b/src/jsc/VM.zig index 2e0bb0a774e..aba45e20eba 100644 --- a/src/jsc/VM.zig +++ b/src/jsc/VM.zig @@ -91,6 +91,11 @@ pub const VM = opaque { return JSC__VM__collectAsync(vm); } + extern fn JSC__VM__collectAsyncFull(vm: *VM) void; + pub fn collectAsyncFull(vm: *VM) void { + return JSC__VM__collectAsyncFull(vm); + } + extern fn JSC__VM__setExecutionForbidden(vm: *VM, forbidden: bool) void; pub fn setExecutionForbidden(vm: *VM, forbidden: bool) void { JSC__VM__setExecutionForbidden(vm, forbidden); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 22ac3806337..8742f0dd62d 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2827,6 +2827,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..698cd61fdc7 --- /dev/null +++ b/test/js/bun/gc/gc-controller-idle-full.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe } 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). + +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 () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: { + ...bunEnv, + BUN_GC_TIMER_INTERVAL: "20", + }, + 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);