Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
68 changes: 56 additions & 12 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,8 +43,11 @@
}
}

// init() runs from ensureWaker() before Transpiler.runEnvLoader() has
// copied the process environment into vm.transpiler.env, so read these
// directly from the OS environment.
var gc_timer_interval: i32 = 1000;
if (vm.transpiler.env.get("BUN_GC_TIMER_INTERVAL")) |timer| {
if (bun.getenvZ("BUN_GC_TIMER_INTERVAL")) |timer| {

Check warning on line 50 in src/jsc/GarbageCollectionController.zig

View check run for this annotation

Claude / Claude Code Review

New bun.getenvZ() call sites added despite deprecation; prefer bun.env_var pattern

nit: `bun.getenvZ()` is marked for sunset ("You likely do not need this function. See the pattern in env_var.zig… TODO: Sunset this function when its last usage is removed" — src/bun.zig:925-927), and this file already uses the preferred `bun.env_var.BUN_TRACK_LAST_FN_NAME.get()` pattern eight lines up. Consider adding `BUN_GC_TIMER_INTERVAL` / `BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS` (kind.unsigned) and `BUN_GC_TIMER_DISABLE` (kind.boolean) to `src/bun_core/env_var.zig` and reading them via `bun
Comment thread
robobun marked this conversation as resolved.
Outdated
if (std.fmt.parseInt(i32, timer, 10)) |parsed| {
if (parsed > 0) {
gc_timer_interval = parsed;
Expand All @@ -52,15 +56,15 @@
}
this.gc_timer_interval = gc_timer_interval;

if (vm.transpiler.env.get("BUN_GC_RUNS_UNTIL_SKIP_RELEASE_ACCESS")) |val| {
if (bun.getenvZ("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 |_| {}
}

this.disabled = vm.transpiler.env.has("BUN_GC_TIMER_DISABLE");
this.disabled = bun.getenvZ("BUN_GC_TIMER_DISABLE") != null;

if (!this.disabled)
this.gc_repeating_timer.set(this, onGCRepeatingTimer, gc_timer_interval, gc_timer_interval);
Expand Down Expand Up @@ -102,6 +106,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 +116,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 @@ -137,8 +171,11 @@

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 via updateGCRepeatTimer(.fast) 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 +185,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);

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

View check run for this annotation

Claude / Claude Code Review

#idle_full_gcs_fired not cleared by allocation-driven path while already in fast mode (regression of resolved review)

Commit af48886 re-gated the `.fast` resets in `updateGCRepeatTimer` on a genuine slow→fast transition, which re-opens the issue from the resolved review thread at this line: when `processGCTimerWithHeapSize()` observes growth while already in fast mode, `updateGCRepeatTimer(.fast)` is now a complete no-op and `#idle_full_gcs_fired` is never cleared. So allocation that resumes between tick 30 and tick 31 cannot cancel reduction mode, and the next repeating-timer tick can fire a spurious second `c
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