Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
61 changes: 49 additions & 12 deletions src/jsc/GarbageCollectionController.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -42,8 +43,11 @@ pub fn init(this: *GarbageCollectionController, vm: *VirtualMachine) void {
}
}

// 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| {
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 @@ pub fn init(this: *GarbageCollectionController, vm: *VirtualMachine) void {
}
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 @@ 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);
Expand All @@ -111,19 +116,48 @@ 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;
}

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 @@ fn processGCTimerWithHeapSize(this: *GarbageCollectionController, vm: *jsc.VM, t

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,7 +185,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) {
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) {
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