From a23cce003ad0029ae2ea50af72b067814b796809 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 17 Sep 2026 23:42:22 +0000 Subject: [PATCH 1/2] standalone: do not page the module graph out at idle The second idle full collection (75 s after the heap stopped growing) also asked the kernel to reclaim the pages of a standalone executable's embedded module graph (MADV_PAGEOUT). Those pages are clean and file-backed: the kernel drops them by itself, for nothing, when it needs the memory. Evicting them by hand only lowers the process's RSS, and the next thing the program does reads them back from the disk, one major fault at a time. Measured on a ~200 MB compiled command-line program (release build; a scripted run of 20 requests against a stub API, a pause, one more request; a request in steady state is 224 ms and 4 major faults): after a 90 s pause the next request took +240 to +290 ms and 210-220 major faults for the page-out alone (code aging switched off), for 57 MB of file-backed RSS. Idle for 10 minutes right after start-up, its first request took 1402 ms and 402 major faults with the page-out and 774 ms and 40 without. StandaloneModuleGraph::page_out and the trait method go with the call. BUN_FEATURE_FLAG_DISABLE_STANDALONE_MADVISE stays: it still turns off the start-up read-ahead of the module graph (MADV_WILLNEED / F_RDADVISE) and the MADV_DONTNEED hint for the source text once the entry point has been evaluated, both one-off at start-up and unchanged. --- src/jsc/GarbageCollectionController.rs | 25 +++---------------- src/resolver/standalone_module_graph.rs | 3 --- src/standalone_graph/StandaloneModuleGraph.rs | 20 --------------- 3 files changed, 4 insertions(+), 44 deletions(-) diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index db0c181cde01..96faf65e0e98 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -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,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. 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; @@ -114,10 +114,9 @@ impl GarbageCollectionController { /// 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). + /// 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). Returns (full, ms until the next such tick is due). fn idle_tick(&self, vm: &VirtualMachine, grew: bool, interval_ms: i32) -> (bool, Option) { let dues = self.idle_gc_at_ms.get(); if dues[0] == 0 || vm.is_inspector_enabled() { @@ -132,22 +131,6 @@ 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 full = dues.clone().any(crossed); ( full, diff --git a/src/resolver/standalone_module_graph.rs b/src/resolver/standalone_module_graph.rs index b02348865695..e7dc9aee98b5 100644 --- a/src/resolver/standalone_module_graph.rs +++ b/src/resolver/standalone_module_graph.rs @@ -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) {} } diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 453a26890575..168630a1a421 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -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::() as usize + page - 1) & !(page - 1); - let hi = (bytes.cast::() 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)] From 16c091f3c44d62111ad1c41e573b6aa884596313 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Thu, 17 Sep 2026 23:42:30 +0000 Subject: [PATCH 2/2] GC controller: idle collections at 10 s, 2 min and 10 min; the last drops re-decodable bytecode The idle full collections (BUN_IDLE_GC_SECONDS) exist so that JSC can age out code that no longer runs and hand its memory back. That memory is anonymous: only the runtime can release it. What the program pays is a re-warm, once, the next time it does something: it compiles again what a collection aged out. Measured on a ~200 MB compiled command-line program (a request is 224 ms and 0.9 G instructions in steady state): the first collection, 10 s in, frees 56 MB of garbage and costs the next request nothing; the second frees 55 MB more and costs the next request +200 to +340 ms and +1.3 to +2.2 G instructions; the third frees another 24-34 MB. - The default list is "10,110,480": collections 10 s, 2 min and 10 min after the heap stopped growing (it was 10 s, 75 s, 140 s). The saving is the same; a program that is used again within two minutes no longer pays for the second collection. After a 90 s pause the next request took +600 ms (main, with its page-out) / +332 ms (without) and takes +6 ms now. - Before the last collection JSC lets go of what it can get back cheaply (VM::shrinkFootprintNow with LeaveCollectionToCaller | KeepCodeInUse): the unlinked bytecode of functions that have no linked code any more and that a bytecode cache can hand back (a --compile --bytecode executable's embedded bytecode), and the parser's caches. Nothing that would have to be parsed again, nothing that is in use. Deleting code waits for a collection that is under way, and JSC declines with JS on the stack: in either case nothing is dropped, this tick's quiet is not counted, and the next tick tries again. The collection that follows is the same requested, concurrent one. For every process: without embedded bytecode there is little to drop and nothing that costs anything to get back. - Every JS thread runs them, for its own heap, as it did: the controller was written for the main thread only, but `is_main_thread()` asks whether the VM has a Worker and a Worker's VM is initialised before it is given one, so the test was always true. It is gone rather than fixed: a pool of 8 Workers with 50 MB of garbage each gives 400 MB back 12 s after its burst, and held on to it for good with the ladder on the main thread only. Nothing the ladder does is process-wide any more. - An idle collection is requested, not run: it proceeds at the mutator's safepoints, which in a program that runs no JS are the timer's ticks. The second and third were requested on the 30 s tick, so a server held on to a burst's garbage for a minute and more after it. After an idle collection the timer is on its fast tick for the next 30. Nothing is paged out. "Busy" is what it was: the heap grew by more than 2 MB since the last tick. A wrong "idle" costs a concurrent collection and a re-warm, which does not justify more than that. Tests: a Worker's 100 MB of old-generation garbage are given back by the Worker's own idle collection; a --compile --bytecode executable loses its re-decodable unlinked code with the second collection of "1,1" and gives the same results afterwards, and still has it after the first of "1,30" has been logged; 300 MB of old-generation garbage next to 50 MB of live data are back within seconds of the idle collection on a 20 ms tick (they stayed for half a minute). The last two of those behaviours are new. The file takes 6 s, as before. --- src/jsc/GarbageCollectionController.rs | 57 ++--- src/jsc/VM.rs | 9 + src/jsc/bindings/bindings.cpp | 9 + src/jsc/bindings/headers.h | 1 + test/expected-durations.json | 10 +- test/js/bun/gc/gc-controller-cadence.test.ts | 212 ++++++++++++++++++- 6 files changed, 265 insertions(+), 33 deletions(-) diff --git a/src/jsc/GarbageCollectionController.rs b/src/jsc/GarbageCollectionController.rs index 96faf65e0e98..f54b83bdde3c 100644 --- a/src/jsc/GarbageCollectionController.rs +++ b/src/jsc/GarbageCollectionController.rs @@ -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. 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; @@ -93,30 +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::(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::(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 + /// 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). Returns (full, ms until the next such tick is due). + /// 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) { let dues = self.idle_gc_at_ms.get(); if dues[0] == 0 || vm.is_inspector_enabled() { @@ -131,11 +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; - let full = dues.clone().any(crossed); - ( - full, - dues.clone().find(|&due| quiet < due).map(|due| due - quiet), - ) + let mut full = dues.clone().any(crossed); + 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; + } + (full, next.map(|due| due - quiet)) } /// Idempotent. Must run before JSC teardown: `~RunLoop::Timer` frees the @@ -185,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. @@ -206,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() diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 13738b686f2a..ce53cdaeb33c 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -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); @@ -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) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 43ec86d4291e..ebfbfbbe123f 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -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); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 7e790c2a04df..3d3a4286f09e 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -263,6 +263,7 @@ CPP_DECL void JSC__JSValue__toZigException(JSC::EncodedJSValue JSValue0, JSC::JS CPP_DECL size_t JSC__VM__blockBytesAllocated(JSC::VM* arg0); CPP_DECL void JSC__VM__collectAsync(JSC::VM* arg0, bool full); CPP_DECL void JSC__VM__collectAsyncIdle(JSC::VM* arg0); +CPP_DECL bool JSC__VM__shrinkFootprintNow(JSC::VM* arg0); CPP_DECL void JSC__VM__setStartupJITDeferralScale(JSC::VM* arg0, double scale); CPP_DECL JSC::VM* JSC__VM__create(unsigned char HeapType0); CPP_DECL void JSC__VM__deleteAllCode(JSC::VM* arg0, JSC::JSGlobalObject* arg1); diff --git a/test/expected-durations.json b/test/expected-durations.json index 96043f32b76a..577773d0a44e 100644 --- a/test/expected-durations.json +++ b/test/expected-durations.json @@ -2829,11 +2829,11 @@ "windows-aarch64": 30 }, "js/bun/gc/gc-controller-cadence.test.ts": { - "default": 2060, - "asan": 2500, - "musl": 2050, - "windows": 2110, - "windows-aarch64": 2190 + "default": 6000, + "asan": 12000, + "musl": 6000, + "windows": 6500, + "windows-aarch64": 7000 }, "js/bun/glob/leak.test.ts": { "default": 2727, diff --git a/test/js/bun/gc/gc-controller-cadence.test.ts b/test/js/bun/gc/gc-controller-cadence.test.ts index 15c1d242c668..d8d57ba0528f 100644 --- a/test/js/bun/gc/gc-controller-cadence.test.ts +++ b/test/js/bun/gc/gc-controller-cadence.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, isDebug, isLinux, tempDir } from "harness"; +import { afterAll, describe, expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunEnv, bunExe, isASAN, isDebug, isLinux, isWindows, tempDir } from "harness"; +import { join } from "path"; // Bun's GarbageCollectionController used to sample `blockBytesAllocated + // extraMemorySize` on every event-loop tick and arm a 16 ms one-shot whenever @@ -193,6 +195,212 @@ describe("idle release", () => { expect(fulls).toBe(0); expect(exitCode).toBe(0); }); + + // Every JS thread runs them for its own heap: a Worker that has finished a burst and sits idle gives its garbage back + // like the main thread does (a pool of 8 Workers with 50 MB each held on to 400 MB for good when only the main thread + // ran them). The Worker's 100 MB have survived a full collection before nothing refers to them any more, so only + // another full collection of the Worker's heap frees them, and nothing but its idle collection asks for one. Linux: it + // reads /proc; ASAN and debug builds take too long to fill the heap. + (!isLinux || isASAN || isDebug ? test.skip : test.concurrent)( + "a Worker's garbage is given back by the Worker's own idle collection", + async () => { + using dir = tempDir("idle-worker", { + "worker.js": ` + let junk = Array.from({ length: 100 }, (_, i) => new Array(128 * 1024).fill(i)); + Bun.gc(true); + postMessage("full"); + setTimeout(() => { junk = null; }, 300); + setInterval(() => {}, 1000); + `, + "main.js": ` + const resident = () => Number(/^VmRSS:\\s+(\\d+) kB/m.exec(require("fs").readFileSync("/proc/self/status", "utf8"))[1]) >> 10; + globalThis.worker = new Worker(new URL("./worker.js", import.meta.url).href); + worker.onmessage = () => { + const full = resident(); + const deadline = performance.now() + 8000; + const timer = setInterval(() => { + if (resident() > full - 80 && performance.now() < deadline) return; + console.log(JSON.stringify({ full, now: resident() })); + process.exit(0); + }, 100); + }; + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "main.js")], + env: { ...bunEnv, BUN_IDLE_GC_SECONDS: "2", BUN_GC_TIMER_DISABLE: undefined, BUN_GC_TIMER_INTERVAL: undefined }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const { full, now } = JSON.parse(stdout); + expect(full).toBeGreaterThan(100); + expect(now).toBeLessThan(full - 80); + expect(exitCode).toBe(0); + }, + 15_000, // It fills 100 MB and waits for the collection. + ); + + // An idle collection is requested, not run: it proceeds at the mutator's safepoints, and in a program that runs no JS + // those are the timer's ticks. With the collection requested on the 30 s tick (a 20 ms tick goes slow after 0.6 s) a + // server held on to 1.5 GB of a burst's garbage for a minute and a half after its traffic stopped: the timer now stays on + // its fast tick for 30 ticks after one. 50 MB stay alive so that the collection does not finish in the step that starts + // it; the 300 MB that nothing refers to any more a second in have survived a full collection, so only another one frees + // them. Linux: it reads /proc; ASAN and debug builds take too long to fill the heap. + (!isLinux || isASAN || isDebug ? test.skip : test.concurrent)( + "a burst's garbage is given back within seconds of the idle collection", + async () => { + using dir = tempDir("idle-garbage", { + "child.js": ` + const entry = i => ({ id: i, name: "user-" + i + "-" + "x".repeat(200), tags: ["a" + i, "b" + i], extra: { a: i, c: [i, i + 1] } }); + globalThis.live = new Map(); + for (let i = 0; i < 90_000; i++) live.set(i, entry(i)); + globalThis.junk = Array.from({ length: 300 }, (_, i) => new Array(128 * 1024).fill(i)); + Bun.gc(true); + console.log("full"); + setTimeout(() => { globalThis.junk = null; }, 1000); + process.stdin.once("data", () => process.exit(0)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), join(String(dir), "child.js")], + env: { ...bunEnv, BUN_IDLE_GC_SECONDS: "2", BUN_GC_TIMER_DISABLE: undefined, BUN_GC_TIMER_INTERVAL: "20" }, + stdin: "pipe", + stdout: "pipe", + stderr: "inherit", + }); + const resident = () => + Number(/^VmRSS:\s+(\d+) kB/m.exec(readFileSync(`/proc/${proc.pid}/status`, "utf8"))![1]) >> 10; + await proc.stdout.getReader().read(); + const full = resident(); + expect(full).toBeGreaterThan(330); + const deadline = performance.now() + 6000; + while (resident() > full - 250 && performance.now() < deadline) await Bun.sleep(100); + expect(resident()).toBeLessThan(full - 250); + proc.stdin.write("exit\n"); + await proc.stdin.flush(); + expect(await proc.exited).toBe(0); + }, + 15_000, // It fills 350 MB and waits for the collection. + ); +}); + +// Before the last idle collection the controller asks JSC to let go of what it gets back cheaply +// (VM::shrinkFootprintNow): for a --compile --bytecode executable, the unlinked bytecode of functions that have no linked +// code any more (an earlier idle collection unlinked it), which is decoded again from the executable when such a +// function is next called. Debug and ASAN executables are too big to compile a copy of per run. +let dir: ReturnType | undefined; +afterAll(() => dir?.[Symbol.dispose]()); +describe.skipIf(isDebug || isASAN)("the last idle collection drops code that can be decoded again", () => { + const app = ` + import { heapStats } from "bun:jsc"; + ${Array.from({ length: 60 }, (_, i) => `function f${i}(a) { let s = a + ${i}; for (let k = 0; k < 3; k++) s += k * ${i + 1}; return [s, "f${i}"].join(":"); }`).join("\n ")} + const all = [${Array.from({ length: 60 }, (_, i) => `f${i}`).join(", ")}]; + const run = () => all.map((f, i) => f(i)).join("|"); + const count = () => heapStats().objectTypeCounts.UnlinkedFunctionCodeBlock ?? 0; + const expected = run(); + Bun.gc(true); + const before = count(); + console.error("COUNTED " + before); + // Says what the count is four times a second, on the stream the collections are logged on, and ends when it has dropped. + const report = after => { + console.log(JSON.stringify({ before, after, same: run() === expected })); + process.exit(0); + }; + setInterval(() => { + const now = count(); + console.error("COUNT " + now); + if (now < before - 40) report(now); + }, 250); + setTimeout(() => report(count()), 12_000); + `; + + // Built by whichever of the two tests gets there first (no hooks in here: the tests run alongside the rest of the file). + let built: Promise | undefined; + const executable = () => + (built ??= (async () => { + dir = tempDir("idle-drop-code", { "app.js": app }); + await using build = Bun.spawn({ + cmd: [bunExe(), "build", "--compile", "--bytecode", "--format=esm", "--outfile", "app", "app.js"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([build.stdout.text(), build.stderr.text(), build.exited]); + expect(exitCode, out + err).toBe(0); + return join(String(dir), "app" + (isWindows ? ".exe" : "")); + })()); + + // `endAfter`: the run is ended (the child killed) once it has said its count after that many full collections have been + // logged since it counted (the collection it forces in order to count is logged before). + async function run(seconds: string, endAfter = Infinity) { + const exe = await executable(); + await using proc = Bun.spawn({ + cmd: [exe], + env: { + ...bunEnv, + BUN_IDLE_GC_SECONDS: seconds, + BUN_JSC_logGC: "1", + BUN_GC_TIMER_DISABLE: undefined, + BUN_GC_TIMER_INTERVAL: undefined, + // Code ages in milliseconds instead of the seconds it normally takes, so that an idle collection finds the + // functions' CodeBlocks old as it would a minute into a real idle period. + BUN_JSC_useEagerCodeBlockJettisonTiming: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + let log = ""; + // The last count the child has said, and how many full collections had been logged before it (tearing the VM down + // at exit, as the ASAN lanes do, logs one after). + const observed = () => { + const [, before, rest = ""] = /COUNTED (\d+)([^]*)/.exec(log) ?? []; + const said = rest.slice(0, rest.lastIndexOf("COUNT ")); + const after = /COUNT (\d+)\s*$/.exec(rest.slice(said.length).split("\n")[0])?.[1]; + return { + before: Number(before), + idleCollections: said.split("=> FullCollection").length - 1, + after: after === undefined ? undefined : Number(after), + }; + }; + const reading = (async () => { + const decoder = new TextDecoder(); + for await (const chunk of proc.stderr) { + log += decoder.decode(chunk, { stream: true }); + const { idleCollections, after } = observed(); + if (idleCollections >= endAfter && after !== undefined) proc.kill(); + } + })(); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited, reading]); + const same = stdout.trim().startsWith("{") ? (JSON.parse(stdout.trim()).same as boolean) : undefined; + return { ...observed(), same, log, exitCode }; + } + + test.concurrent( + "the second of two drops it, and it comes back", + async () => { + const { before, after, same, idleCollections, log, exitCode } = await run("1,1"); + expect(before, log).toBeGreaterThan(60); + expect(idleCollections, log).toBeGreaterThanOrEqual(2); + expect(after, log).toBeLessThan(before - 40); + expect(same, log).toBe(true); + expect(exitCode).toBe(0); + }, + 15_000, // It may be the one that writes the executable, of 100 MB and more. + ); + + // The first of two is a collection like any other: it has run, and the code is still there. + test.concurrent( + "the first of two does not", + async () => { + const { before, after, idleCollections, log } = await run("1,30", 1); + expect(before, log).toBeGreaterThan(60); + expect(idleCollections, log).toBe(1); + expect(after, log).toBeGreaterThan(before - 40); + }, + 15_000, + ); }); // Those idle full collections are tagged (GCRequest::isIdle) so JSC may also let idle FTL code — which has no execution