Skip to content
Merged
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
76 changes: 32 additions & 44 deletions src/jsc/GarbageCollectionController.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
//! Idle GC timer: JSC's own `GCActivityCallback` (via `WTFTimer`) paces eden/full against allocation rate; this adds a 1 s / 30 s idle `collect_async()` so a process that stops allocating still releases memory, and once the heap has been quiet for `BUN_IDLE_GC_SECONDS` (default "10,65,65": first after 10 s of quiet, then one per CodeBlock-aging lease; 0 = off; main thread only) full collections so JSC can age out code that no longer runs, plus a page-out of a standalone executable's embedded module graph. Knobs: `BUN_GC_TIMER_INTERVAL` (ms), `BUN_GC_TIMER_DISABLE`. One per JS thread, not thread-safe.
//! Idle GC timer: JSC's own `GCActivityCallback` (via `WTFTimer`) paces eden/full against allocation rate; this adds a 1 s / 30 s idle `collect_async()` so a process that stops allocating still releases memory, and once the heap has been quiet for `BUN_IDLE_GC_SECONDS` (default "10,110,480": 10 s, 2 min and 10 min of quiet; 0 = off) full collections so JSC can age out code that no longer runs, the last of which also drops the bytecode JSC can decode again. Knobs: `BUN_GC_TIMER_INTERVAL` (ms), `BUN_GC_TIMER_DISABLE`. One per JS thread, not thread-safe.

use core::cell::Cell;
use core::ffi::c_int;
Expand Down Expand Up @@ -93,31 +93,32 @@ impl GarbageCollectionController {
self.disabled
.set(env_var::BUN_GC_TIMER_DISABLE::get().unwrap_or(false));

if vm.is_main_thread() {
// "a,b,c,...": seconds of quiet before the first idle full collection, then between consecutive ones (spaced a
// CodeBlock-aging lease apart so each can expire what has not run since the previous); "0"/"" = off.
let spec = env_var::BUN_IDLE_GC_SECONDS::get().unwrap_or(b"10,65,65");
let mut at = [0u32; 3];
let mut sum = 0u32;
for (slot, part) in at.iter_mut().zip(bun_core::strings::split(spec, b",")) {
let secs = bun_core::fmt::parse_int::<u32>(bun_core::strings::trim(part, b" "), 10)
.unwrap_or(0);
if secs == 0 {
break;
}
sum = sum.saturating_add(secs.min(3600) * 1000);
*slot = sum;
// "a,b,c": seconds of quiet before the first idle full collection, then between consecutive ones (at least a
// CodeBlock-aging lease apart so each can expire what has not run since the previous); "0"/"" = off. Every JS
// thread, for its own heap: a Worker that has finished a burst gives its garbage back too.
// The second at 2 min: what a program does next compiles again what that collection aged out.
let spec = env_var::BUN_IDLE_GC_SECONDS::get().unwrap_or(b"10,110,480");
let mut at = [0u32; 3];
let mut sum = 0u32;
for (slot, part) in at.iter_mut().zip(bun_core::strings::split(spec, b",")) {
let secs = bun_core::fmt::parse_int::<u32>(bun_core::strings::trim(part, b" "), 10)
.unwrap_or(0);
if secs == 0 {
break;
}
self.idle_gc_at_ms.set(at);
sum = sum.saturating_add(secs.min(3600) * 1000);
*slot = sum;
}
self.idle_gc_at_ms.set(at);
}

/// Decides whether this tick's collection should be a full one. After the first `BUN_IDLE_GC_SECONDS` entry (main
/// thread only) of ticks in which the heap did not grow, the tick's collection is made Full (it collects what the
/// last burst left and lets JSC snapshot which code is still running), and again after each further entry of quiet
/// (the second also pages out a standalone executable's embedded module graph): JSC drops code that has not run since the
/// previous one, and each round makes a little more releasable (code whose last owner died in that collection,
/// pages it emptied). Returns (full, ms until the next such tick is due).
/// Decides whether this tick's collection should be a full one. After the first `BUN_IDLE_GC_SECONDS` entry
/// of ticks in which the heap did not grow, the tick's collection is made Full (it collects what the
/// last burst left and lets JSC snapshot which code is still running), and again after each further entry of quiet:
/// JSC drops code that has not run since the previous one, and each round makes a little more releasable (code whose
/// last owner died in that collection, pages it emptied). Before the last one JSC also lets go of what it can get back
/// cheaply (`shrink_footprint_now`); if it cannot right now, this tick's quiet is not counted and the next one tries
/// again. Returns (full, ms until the next such tick is due).
fn idle_tick(&self, vm: &VirtualMachine, grew: bool, interval_ms: i32) -> (bool, Option<u32>) {
let dues = self.idle_gc_at_ms.get();
if dues[0] == 0 || vm.is_inspector_enabled() {
Expand All @@ -132,27 +133,13 @@ impl GarbageCollectionController {
self.idle_quiet_ms.set(quiet);
let dues = dues.into_iter().filter(|&due| due != 0);
let crossed = |due: u32| before < due && quiet >= due;
// The module-graph page-out goes with the second collection (or the only one): after a pause of a few seconds
// the user is likely to come straight back, and those file-backed pages would just fault in again.
#[cfg(target_os = "linux")]
{
let at = self.idle_gc_at_ms.get();
if let Some(graph) = vm
.standalone_module_graph
.filter(|_| crossed(if at[1] != 0 { at[1] } else { at[0] }))
{
// SAFETY: VM-free — `graph` is the process-lifetime, immutable embedded module graph; the thread only
// madvise()s its pages and touches no VM or JS state.
let _ = std::thread::Builder::new()
.name("idle page-out".into())
.spawn(move || graph.page_out());
}
let mut full = dues.clone().any(crossed);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Maintainers get a red "Source lints" GitHub check on this PR: test/internal/source-lints/vm-thread-door.test.ts fails for src/jsc/GarbageCollectionController.rs. The diff deletes the file's only std::thread::Builder::new() (the "idle page-out" spawn) but test/internal/source-lints/vm-thread-door.inventory.json:19-21 still records "thread spawn": 1 for it. Fix: regenerate the inventory in this PR so the frozen thread-crossing list matches the sources, e.g. bun ./test/internal/source-lints/vm-thread-door.test.ts --update, which drops the GarbageCollectionController.rs entry.

Extended reasoning...

The lint scans every tracked src/jsc/**/*.rs file with the pattern \bthread::(?:Builder::new\s*\(|spawn\s*\() (vm-thread-door.test.ts:51) and compares the per-file result with the committed inventory (vm-thread-door.test.ts:96-113). On the base commit GarbageCollectionController.rs:146 contained std::thread::Builder::new(), so the inventory entry "src/jsc/GarbageCollectionController.rs": { "thread spawn": 1 } was correct. The PR removes that spawn together with graph.page_out(); the current file has no thread:: occurrence at all (grep confirms), and git diff fd8422ce HEAD --stat -- test/internal shows the inventory was not touched. The test iterates the union of inventory keys and found keys (line 99), so for this file expected is {"thread spawn":1} and actual is {}; Bun.deepEquals is false and the test throws "src/jsc/GarbageCollectionController.rs: thread-crossing inventory changed" (lines 103-111). .github/workflows/source-lints.yml runs bun test test/internal/source-lints/ on every pull_request that touches src/**/*.rs (lines 37-39, 87), which…

Verification: normal — triggering condition: any push of this PR (the source-lints workflow runs on pull_request for src/**/*.rs, .github/workflows/source-lints.yml:38-39, step bun test test/internal/source-lints/ at line 87). Mechanism verified: the diff removes the only std::thread::Builder::new() in src/jsc/GarbageCollectionController.rs (diff line `- let _ =… | normal. Triggering…

let next = dues.clone().find(|&due| quiet < due);
if full && next.is_none() && !vm.jsc_vm().shrink_footprint_now() {
self.idle_quiet_ms.set(before);
full = false;
}
let full = dues.clone().any(crossed);
(
full,
dues.clone().find(|&due| quiet < due).map(|due| due - quiet),
)
(full, next.map(|due| due - quiet))
}

/// Idempotent. Must run before JSC teardown: `~RunLoop::Timer` frees the
Expand Down Expand Up @@ -202,7 +189,7 @@ impl GarbageCollectionController {
self.gc_last_heap_size.set(vm.block_bytes_allocated());
}

/// `Tag::GcRepeating` fire body: `BUN_GC_TIMER_INTERVAL` (default 1 s) in fast mode, 30 s in slow mode; drops to slow after 30 fires with no heap growth, back to fast when it grows.
/// `Tag::GcRepeating` fire body: `BUN_GC_TIMER_INTERVAL` (default 1 s) in fast mode, 30 s in slow mode; drops to slow after 30 fires with no heap growth, back to fast when it grows or an idle full collection was requested.
///
/// # Safety
/// `this` is the live per-VM controller; `vm` is the per-thread VM.
Expand All @@ -223,7 +210,8 @@ impl GarbageCollectionController {
let (full, idle_gc_due_in) = this.idle_tick(vm_ref, grew, this.repeat_interval());
this.perform_gc(full);
// Only growth is activity; a shrinking heap is a collection (possibly the one requested above) doing its job.
if this.gc_last_heap_size.get() <= prev_heap_size {
// An idle full collection proceeds at this timer's ticks in a program that runs no JS: fast ones for the next 30.
if !full && this.gc_last_heap_size.get() <= prev_heap_size {
let ticks = this
.heap_size_didnt_change_for_repeating_timer_ticks_count
.get()
Expand Down
9 changes: 9 additions & 0 deletions src/jsc/VM.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ unsafe extern "C" {
safe fn JSC__VM__heapSize(vm: &VM) -> usize;
safe fn JSC__VM__collectAsync(vm: &VM, full: bool);
safe fn JSC__VM__collectAsyncIdle(vm: &VM);
safe fn JSC__VM__shrinkFootprintNow(vm: &VM) -> bool;
safe fn JSC__VM__setStartupJITDeferralScale(vm: &VM, scale: f64);
safe fn JSC__VM__executionForbidden(vm: &VM) -> bool;
safe fn JSC__VM__notifyNeedTermination(vm: &VM);
Expand Down Expand Up @@ -106,6 +107,14 @@ impl VM {
JSC__VM__collectAsyncIdle(self)
}

/// Let go of what JSC gets back cheaply, of functions that have no linked code any more (an idle collection has found
/// them not running): unlinked bytecode it can decode again from a bytecode cache, the parser's caches. Nothing that
/// would have to be parsed again. The caller's next full collection frees it. `false`: nothing was done, because JS
/// is on the stack or a collection is under way (JSC would wait for it to finish).
pub(crate) fn shrink_footprint_now(&self) -> bool {
JSC__VM__shrinkFootprintNow(self)
}

/// Multiply JSC's LLInt->Baseline and Baseline->DFG tier-up thresholds by `scale` (1 = normal). Mutator thread only.
pub fn set_startup_jit_deferral_scale(&self, scale: f64) {
JSC__VM__setStartupJITDeferralScale(self, scale)
Expand Down
9 changes: 9 additions & 0 deletions src/jsc/bindings/bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3167,6 +3167,15 @@ void JSC__VM__collectAsyncIdle(JSC::VM* vm)
vm->heap.collectAsync(request);
}

bool JSC__VM__shrinkFootprintNow(JSC::VM* vm)
{
JSC::JSLockHolder lock(*vm);
// Deleting code waits for a collection that is under way (Heap::preventCollection).
if (vm->heap.collectionScope())
return false;
return vm->shrinkFootprintNow({ JSC::VM::ShrinkFootprint::LeaveCollectionToCaller, JSC::VM::ShrinkFootprint::KeepCodeInUse });
}

void JSC__VM__setStartupJITDeferralScale(JSC::VM* vm, double scale)
{
vm->setStartupJITDeferralScale(scale);
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.

3 changes: 0 additions & 3 deletions src/resolver/standalone_module_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,4 @@ pub trait StandaloneModuleGraph: Send + Sync {
fn module_graph_load_bytes(&self) -> usize {
0
}
/// Ask the kernel to reclaim the resident pages of the embedded graph (clean file-backed pages are dropped and
/// re-read from the executable when touched). May block on the syscall; call off the JS thread.
fn page_out(&self) {}
}
20 changes: 0 additions & 20 deletions src/standalone_graph/StandaloneModuleGraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -395,26 +395,6 @@ impl bun_resolver::StandaloneModuleGraph for StandaloneModuleGraph {
.sum();
modules + builtins + self.bytecode_string_table.len()
}
fn page_out(&self) {
#[cfg(target_os = "linux")]
{
if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE::get()
.unwrap_or(false)
{
return;
}
let bytes = self.bytes;
let page = bun_alloc::page_size();
let lo = (bytes.cast::<u8>() as usize + page - 1) & !(page - 1);
let hi = (bytes.cast::<u8>() as usize + bytes.len()) & !(page - 1);
if hi > lo {
// SAFETY: `[lo, hi)` is inside the mapped executable image. MADV_PAGEOUT reclaims the pages without
// losing data: clean file-backed pages are dropped and re-read from the file on the next access, the
// few dirtied (COW) ones go to swap if there is any and otherwise stay.
unsafe { libc::madvise(lo as *mut core::ffi::c_void, hi - lo, libc::MADV_PAGEOUT) };
}
}
}
}

#[repr(C)]
Expand Down
10 changes: 5 additions & 5 deletions test/expected-durations.json

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

Loading
Loading