Skip to content
Open
Show file tree
Hide file tree
Changes from all 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.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
132 changes: 92 additions & 40 deletions src/jsc/GarbageCollectionController.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@

use core::ffi::c_int;

#[cfg(debug_assertions)]
use bun_core::env_var;
use bun_uws as uws;

Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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::<i32>(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::<c_int>(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);
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);
}
}

Expand Down Expand Up @@ -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>(self);
Expand All @@ -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 {
Expand All @@ -254,14 +257,22 @@ 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 {
self.perform_gc();
} 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 => {
Expand Down Expand Up @@ -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)]
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/VM.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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)
}
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 @@ -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);
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.

86 changes: 86 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,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);
Loading