Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/bun_core/env_var.zig
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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 });
Expand Down
99 changes: 72 additions & 27 deletions src/jsc/GarbageCollectionController.zig
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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,
Expand All @@ -42,28 +43,25 @@
}
}

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 {
Expand Down Expand Up @@ -102,6 +100,7 @@
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);
Expand All @@ -111,19 +110,48 @@

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;
}

Comment thread
claude[bot] marked this conversation as resolved.
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;
vm.collectAsyncFull();
return;
}
} else {
this.heap_size_didnt_change_for_repeating_timer_ticks_count = 0;
this.updateGCRepeatTimer(.fast);
}

vm.collectAsync();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
claude[bot] marked this conversation as resolved.
}

pub fn processGCTimer(this: *GarbageCollectionController) void {
Expand All @@ -135,10 +163,20 @@
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;

Check failure on line 171 in src/jsc/GarbageCollectionController.zig

View check run for this annotation

Claude / Claude Code Review

Allocation-path flag clear leaves stable-tick counter at >=30, bypassing the 2-Full-GC cap

Clearing `#idle_full_gcs_fired` here without also resetting `heap_size_didnt_change_for_repeating_timer_ticks_count` (which the first-Full-GC path at line 145 leaves at 30) lets the very next tick re-enter the "first Full GC" branch and fire another `collectAsyncFull()` — bypassing the `< 2` cap entirely, since that cap only lives in the reduction branch this line just exited. Resetting the counter to 0 at line 145 (right after `#idle_full_gcs_fired = 1`) closes the gap without reintroducing the
Comment thread
robobun marked this conversation as resolved.

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 {
Expand All @@ -148,14 +186,21 @@
this.gc_last_heap_size = this_heap_size;
},
.pending => {
if (this_heap_size != prev) {
if (this_heap_size > prev) {
Comment thread
robobun marked this conversation as resolved.
this.updateGCRepeatTimer(.fast);

Comment thread
robobun marked this conversation as resolved.
if (this_heap_size > prev * 2) {
this.performGC();
} 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 => {
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/VM.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/jsc/bindings/headers.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

75 changes: 75 additions & 0 deletions test/js/bun/gc/gc-controller-idle-full.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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. 2.5s of
// pure idle gives ~4x headroom on slow/ASAN builds.
await Bun.sleep(2500);

// 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);
Loading