From 6607b9bad6c43519fd4e7cc23882307c1c41dd58 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:16:32 +0000 Subject: [PATCH 01/16] bun test --isolate: reclaim the previous file's module graph on global swap Under --isolate, each test file runs in a fresh global within one process. The per-file swap (createForTestIsolation) unprotected the outgoing global but never dropped its module registry or require cache, and nothing forced a collection afterward. The finished file's module records, namespace objects, and module environments were therefore retained, so peak RSS grew linearly with the number of files (proportional to each file's imported graph size), OOMing large suites. Default (shared-global) mode loads the graph once and stays flat. Clear the outgoing global's module loader registry and require map on swap (mirroring GlobalObject::reload and Zig__GlobalObject__destructOnExit) to sever moduleLoader -> record -> environment -> bindings, then force a focused full collection after the swap to reclaim the detached graph and return the freed blocks to the OS. The collection preserves the VM-scoped bytecode and IsolatedModuleCache so the next file stays fast. Peak RSS is now flat across files (48.9 -> 52.3 MB at N=1/20 on a 1000-module graph, was 48.9 -> 101.8), matching default mode. --- src/jsc/VM.rs | 8 ++ src/jsc/VirtualMachine.rs | 10 +++ src/jsc/bindings/ZigGlobalObject.cpp | 30 +++++++- src/jsc/bindings/bindings.cpp | 13 ++++ src/jsc/bindings/headers.h | 1 + test/cli/test/isolation.test.ts | 5 +- test/regression/issue/31771.test.ts | 110 +++++++++++++++++++++++++++ 7 files changed, 173 insertions(+), 4 deletions(-) create mode 100644 test/regression/issue/31771.test.ts diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 092d29a0f87a..c0df839e8c0b 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -29,6 +29,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__collectNowFull(vm: &VM); safe fn JSC__VM__executionForbidden(vm: &VM) -> bool; safe fn JSC__VM__notifyNeedTermination(vm: &VM); safe fn JSC__VM__isEntered(vm: &VM) -> bool; @@ -98,6 +99,13 @@ impl VM { JSC__VM__collectAsync(self) } + /// Full, synchronous collection that preserves the VM-scoped bytecode and + /// source-provider caches (unlike [`VM::run_gc`]). + pub fn collect_now_full(&self) { + JSC__VM__collectNowFull(self) + } + + pub fn execution_forbidden(&self) -> bool { JSC__VM__executionForbidden(self) } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index f24fecbb6a49..81cb081020b9 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5131,6 +5131,16 @@ impl VirtualMachine { } } } + + // `create_for_test_isolation` detached the previous file's module graph + // from the (now-unrooted) old global, but nothing in the per-file loop + // applies enough allocation pressure to trigger a full collection, so + // the detached graph would otherwise sit in the old heap until the end + // of the run — peak RSS growing linearly with the number of files and + // OOMing large suites. Force a full collection here to reclaim it and + // return the freed blocks to the OS, keeping RSS flat across files. + self.global().vm().collect_now_full(); + bun_core::Global::mimalloc_cleanup(false); } /// Loads and evaluates a macro entry module, waiting for its promise. diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b2f8ffd52eed..90111086d2b9 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -700,9 +700,33 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G globalObject->m_processEnvObject.set(vm, globalObject, Bun::createSharedEnvironmentVariablesMap(globalObject).getObject()); } - // Drop the permanent root on the previous global so its module registry, - // require.cache, and user objects become collectable. JSC's CodeCache and - // Bun's RuntimeTranspilerCache are VM/process scoped and survive. + // Drop the module registry and require.cache on the outgoing global before + // unprotecting it. Every value stored in a module top-level binding is + // rooted through these maps (the module record holds its environment, which + // holds the bindings), so they survive a collection as long as the global + // is reachable at all — and the global can stay transiently reachable across + // the swap (e.g. a ScriptExecutionContext map entry, a pending native task, + // or a not-yet-swept cell). Clearing the maps here severs + // moduleLoader -> record -> environment -> bindings so the per-file module + // graph is reclaimed even while the global shell lingers. Without this, the + // graph accumulates linearly with the number of test files and OOMs large + // suites. Mirrors GlobalObject::reload() and Zig__GlobalObject__destructOnExit(). + { + auto scope = DECLARE_THROW_SCOPE(vm); + auto* moduleLoader = oldGlobal->moduleLoader(); + // JSModuleLoader::visitChildrenImpl iterates these maps on the GC thread + // under cellLock(); take the same lock so clearAll() can't race it. + { + WTF::Locker locker { moduleLoader->cellLock() }; + moduleLoader->clearAll(); + } + oldGlobal->requireMap()->clear(oldGlobal); + scope.assertNoException(); + } + + // Drop the permanent root on the previous global so the global shell itself + // (and anything else it still roots) becomes collectable. JSC's CodeCache + // and Bun's RuntimeTranspilerCache are VM/process scoped and survive. oldGlobal->isThreadLocalDefaultGlobalObject = false; JSC::gcUnprotect(oldGlobal); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 87b704a4b042..3e1165ba7109 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -3046,6 +3046,19 @@ void JSC__VM__collectAsync(JSC::VM* vm) vm->heap.collectAsync(); } +// Full, synchronous collection that does NOT delete unlinked code blocks or +// clear the source-provider caches (unlike JSC__VM__runGC). Used by the +// `bun test --isolate` per-file global swap to reclaim the previous file's +// detached module graph while preserving the VM-scoped caches (bytecode, +// IsolatedModuleCache) that keep the next file fast. +void JSC__VM__collectNowFull(JSC::VM* vm) +{ + JSC::JSLockHolder lock(*vm); + vm->finalizeSynchronousJSExecution(); + vm->heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); + vm->finalizeSynchronousJSExecution(); +} + size_t JSC__VM__heapSize(JSC::VM* arg0) { return arg0->heap.size(); diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 07b00db396a0..bda5c27f7d47 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -298,6 +298,7 @@ CPP_DECL void JSC__JSValue__toZigString(JSC::EncodedJSValue JSValue0, ZigString* CPP_DECL size_t JSC__VM__blockBytesAllocated(JSC::VM* arg0); CPP_DECL void JSC__VM__collectAsync(JSC::VM* arg0); +CPP_DECL void JSC__VM__collectNowFull(JSC::VM* arg0); CPP_DECL JSC::VM* JSC__VM__create(unsigned char HeapType0); CPP_DECL void JSC__VM__deleteAllCode(JSC::VM* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void JSC__VM__drainMicrotasks(JSC::VM* arg0); diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 5d011b966c87..e9e8ef8b8845 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -730,7 +730,10 @@ test.concurrent("--isolate: cached SourceProvider's module_info rebuilds correct expect(stderr).toContain("3 pass"); expect(stderr).toContain("0 fail"); expect(exitCode).toBe(0); -}); + // Transpiling three files against a 2000-export module and spawning a child + // takes well over the 5s default under ASAN (and runs concurrently with the + // other heavy cases in this file); give it explicit headroom. +}, 30_000); test.concurrent( "--isolate: cached module_info handles `import * as ns; export { ns }` as a Namespace export", diff --git a/test/regression/issue/31771.test.ts b/test/regression/issue/31771.test.ts new file mode 100644 index 000000000000..3b646ab4a998 --- /dev/null +++ b/test/regression/issue/31771.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import fs from "node:fs"; +import path from "node:path"; + +// https://github.com/oven-sh/bun/issues/31771 +// +// `bun test --isolate` runs each file in a fresh global within ONE process. +// The module records / namespace objects instantiated for one file must be +// reclaimed when the global is swapped for the next file. Before the fix the +// outgoing global's module registry and require cache were never cleared, so +// each finished file's module graph was retained and peak RSS grew linearly +// with the number of files (proportional to each file's imported graph size), +// OOMing large suites. After the fix peak RSS stays roughly flat, as it does +// in default (shared-global) mode. +// +// Measured by polling VmHWM (peak RSS high-water mark, monotonic) of the child +// from /proc. Serial --isolate is a single process, so this is exact. The +// signal is the SLOPE (RSS grows with file count), not an absolute number, so +// the threshold is robust across platforms and build types. + +// A 500-module import graph shared by every test file. Before the fix this +// retained several MB/file; the difference between a small and large file +// count is tens of MB — far above allocator noise. Kept modest so the run +// completes well within the timeout under ASAN (the debug/gate build). +const MODULE_COUNT = 500; +const SMALL_N = 4; +const LARGE_N = 14; + +function makeFixtures() { + const files: Record = {}; + let graph = ""; + for (let i = 0; i < MODULE_COUNT; i++) { + files[`mods/m${i}.ts`] = `export function f${i}() { return ${i}; }\n`; + graph += `import "./mods/m${i}";\n`; + } + files["graph.ts"] = graph; + for (let i = 0; i < LARGE_N; i++) { + files[`t${i}.test.ts`] = + `import "./graph";\nimport { test } from "bun:test";\ntest("noop", () => {});\n`; + } + return files; +} + +async function peakRssMb(dir: string, n: number): Promise { + const files = Array.from({ length: n }, (_, i) => `./t${i}.test.ts`); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", ...files], + env: bunEnv, + cwd: dir, + stdout: "ignore", + stderr: "pipe", + }); + + let peakKb = 0; + const statusPath = `/proc/${proc.pid}/status`; + let running = true; + const poll = (async () => { + while (running) { + try { + const status = fs.readFileSync(statusPath, "utf8"); + const m = status.match(/VmHWM:\s*(\d+)\s*kB/); + if (m) peakKb = Math.max(peakKb, parseInt(m[1], 10)); + } catch { + // process gone + } + await Bun.sleep(2); + } + })(); + + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + running = false; + await poll; + + // One last read in case it exited between polls (VmHWM persists until exit). + try { + const m = fs.readFileSync(statusPath, "utf8").match(/VmHWM:\s*(\d+)\s*kB/); + if (m) peakKb = Math.max(peakKb, parseInt(m[1], 10)); + } catch {} + + expect(stderr).toContain(`${n} pass`); + expect(stderr).toContain("0 fail"); + expect(exitCode).toBe(0); + expect(peakKb).toBeGreaterThan(0); + return peakKb / 1024; +} + +test.skipIf(!isLinux)( + "bun test --isolate does not retain finished files' module graph (peak RSS stays flat)", + async () => { + using dir = tempDir("isolate-rss-31771", makeFixtures()); + const root = String(dir); + + // Sanity: fixtures materialized. + expect(fs.existsSync(path.join(root, "graph.ts"))).toBe(true); + + const small = await peakRssMb(root, SMALL_N); + const large = await peakRssMb(root, LARGE_N); + + const perFile = (large - small) / (LARGE_N - SMALL_N); + + // Before the fix the 500-module graph is retained per file, so the slope is + // several MB/file (tens of MB across the file-count delta). After the fix + // the graph is reclaimed on each swap and the slope is near zero. 0.5 MB/file + // sits comfortably between the two (post-fix is <0.1 MB/file even under + // ASAN's noisier heap accounting). + expect(perFile).toBeLessThan(0.5); + }, + 90_000, +); From 07903ee71859bee979a144750421f719faa58253 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:18:48 +0000 Subject: [PATCH 02/16] [autofix.ci] apply automated fixes --- test/cli/test/isolation.test.ts | 68 +++++++++++++++-------------- test/regression/issue/31771.test.ts | 3 +- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index e9e8ef8b8845..801022224a8a 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -689,18 +689,20 @@ test.concurrent("--isolate: SourceProvider cache covers node_modules .mjs and ty expect(exitCode).toBe(0); }); -test.concurrent("--isolate: cached SourceProvider's module_info rebuilds correct exports", async () => { - // A wide module so the printer-generated module_info has thousands of - // export entries. Under --isolate, file b hits the SourceProvider cache and - // rebuilds JSModuleRecord from the cached module_info (Bun__analyzeTranspiledModule) - // instead of re-parsing. If the record is wrong, named imports would be - // undefined or the count would mismatch. - const N = 2000; - let big = ""; - for (let i = 0; i < N; i++) big += `export function f${i}(x){return x+${i};}\n`; - big += `export const COUNT = ${N};\n`; +test.concurrent( + "--isolate: cached SourceProvider's module_info rebuilds correct exports", + async () => { + // A wide module so the printer-generated module_info has thousands of + // export entries. Under --isolate, file b hits the SourceProvider cache and + // rebuilds JSModuleRecord from the cached module_info (Bun__analyzeTranspiledModule) + // instead of re-parsing. If the record is wrong, named imports would be + // undefined or the count would mismatch. + const N = 2000; + let big = ""; + for (let i = 0; i < N; i++) big += `export function f${i}(x){return x+${i};}\n`; + big += `export const COUNT = ${N};\n`; - const tBody = (name: string) => ` + const tBody = (name: string) => ` import { test, expect } from "bun:test"; import { f0, f1, f${N - 1}, COUNT } from "./big"; import * as all from "./big"; @@ -713,27 +715,29 @@ test.concurrent("--isolate: cached SourceProvider's module_info rebuilds correct }); `; - using dir = tempDir("isolate-module-info", { - "big.ts": big, - "a.test.ts": tBody("a"), - "b.test.ts": tBody("b"), - "c.test.ts": tBody("c"), - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--isolate", "./a.test.ts", "./b.test.ts", "./c.test.ts"], - env: bunEnv, - cwd: String(dir), - stderr: "pipe", - stdout: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain("3 pass"); - expect(stderr).toContain("0 fail"); - expect(exitCode).toBe(0); - // Transpiling three files against a 2000-export module and spawning a child - // takes well over the 5s default under ASAN (and runs concurrently with the - // other heavy cases in this file); give it explicit headroom. -}, 30_000); + using dir = tempDir("isolate-module-info", { + "big.ts": big, + "a.test.ts": tBody("a"), + "b.test.ts": tBody("b"), + "c.test.ts": tBody("c"), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "./a.test.ts", "./b.test.ts", "./c.test.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("3 pass"); + expect(stderr).toContain("0 fail"); + expect(exitCode).toBe(0); + // Transpiling three files against a 2000-export module and spawning a child + // takes well over the 5s default under ASAN (and runs concurrently with the + // other heavy cases in this file); give it explicit headroom. + }, + 30_000, +); test.concurrent( "--isolate: cached module_info handles `import * as ns; export { ns }` as a Namespace export", diff --git a/test/regression/issue/31771.test.ts b/test/regression/issue/31771.test.ts index 3b646ab4a998..dd4f4e61d263 100644 --- a/test/regression/issue/31771.test.ts +++ b/test/regression/issue/31771.test.ts @@ -36,8 +36,7 @@ function makeFixtures() { } files["graph.ts"] = graph; for (let i = 0; i < LARGE_N; i++) { - files[`t${i}.test.ts`] = - `import "./graph";\nimport { test } from "bun:test";\ntest("noop", () => {});\n`; + files[`t${i}.test.ts`] = `import "./graph";\nimport { test } from "bun:test";\ntest("noop", () => {});\n`; } return files; } From 3d2491f3c41689761596311dee654936b0015e89 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:28:32 +0000 Subject: [PATCH 03/16] review: extract module-registry teardown into GlobalObject::clearModuleRegistry Centralize the cellLock() + moduleLoader->clearAll() + requireMap()->clear() sequence shared by reload(), the --isolate global swap, and process-exit teardown so the paths can't drift. Each caller keeps its own exception policy. Also assert the subprocess exit code last in the regression test so stderr/RSS diagnostics surface first on failure. --- src/jsc/bindings/ZigGlobalObject.cpp | 24 ++++++++++++------------ src/jsc/bindings/ZigGlobalObject.h | 6 ++++++ test/regression/issue/31771.test.ts | 2 +- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 90111086d2b9..c17eebb6295b 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -713,14 +713,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G // suites. Mirrors GlobalObject::reload() and Zig__GlobalObject__destructOnExit(). { auto scope = DECLARE_THROW_SCOPE(vm); - auto* moduleLoader = oldGlobal->moduleLoader(); - // JSModuleLoader::visitChildrenImpl iterates these maps on the GC thread - // under cellLock(); take the same lock so clearAll() can't race it. - { - WTF::Locker locker { moduleLoader->cellLock() }; - moduleLoader->clearAll(); - } - oldGlobal->requireMap()->clear(oldGlobal); + oldGlobal->clearModuleRegistry(); scope.assertNoException(); } @@ -3602,16 +3595,23 @@ template void GlobalObject::visitOutputConstraints(JSCell*, SlotVisitor&); // DEFINE_VISIT_CHILDREN(Zig::GlobalObject); -void GlobalObject::reload() +void GlobalObject::clearModuleRegistry() { - auto& vm = this->vm(); - auto scope = DECLARE_THROW_SCOPE(vm); + auto* moduleLoader = this->moduleLoader(); { - auto* moduleLoader = this->moduleLoader(); + // JSModuleLoader::visitChildrenImpl iterates these maps on the GC thread + // under cellLock(); take the same lock so clearAll() can't race it. WTF::Locker locker { moduleLoader->cellLock() }; moduleLoader->clearAll(); } this->requireMap()->clear(this); +} + +void GlobalObject::reload() +{ + auto& vm = this->vm(); + auto scope = DECLARE_THROW_SCOPE(vm); + this->clearModuleRegistry(); RETURN_IF_EXCEPTION(scope, ); // If we run the GC every time, we will never get the SourceProvider cache hit. diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 09559a1e4b5a..5efeb72d0187 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -769,6 +769,12 @@ class GlobalObject : public Bun::GlobalScope { void reload(); + // Drop this global's module loader registry (ESM) and require cache (CJS), + // severing moduleLoader -> record -> environment -> bindings so module + // top-level state becomes collectable. Used by reload(), the + // `bun test --isolate` global swap, and process-exit teardown. + void clearModuleRegistry(); + JSC::Structure* jsonlParseResultStructure() { return m_jsonlParseResultStructure.get(this); } JSC::Structure* pathParsedObjectStructure() { return m_pathParsedObjectStructure.get(this); } JSC::Structure* pendingVirtualModuleResultStructure() { return m_pendingVirtualModuleResultStructure.get(this); } diff --git a/test/regression/issue/31771.test.ts b/test/regression/issue/31771.test.ts index dd4f4e61d263..252aa21f25a0 100644 --- a/test/regression/issue/31771.test.ts +++ b/test/regression/issue/31771.test.ts @@ -79,8 +79,8 @@ async function peakRssMb(dir: string, n: number): Promise { expect(stderr).toContain(`${n} pass`); expect(stderr).toContain("0 fail"); - expect(exitCode).toBe(0); expect(peakKb).toBeGreaterThan(0); + expect(exitCode).toBe(0); return peakKb / 1024; } From d894fb9b340fe99f6c91ec5ba723b351d0e904d9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:51:07 +0000 Subject: [PATCH 04/16] ci: retrigger From 7825f61da1aca7d6379bc580073cc847f075b676 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:29:39 +0000 Subject: [PATCH 05/16] isolate: reclaim detached module graph via async GC, not a sync full GC Per review: dropping the outgoing global's module graph on the --isolate swap is correct, but forcing a synchronous full collection per file stalls the event loop. Keep the registry/require-cache teardown (clearModuleRegistry) and replace collectNow(Sync, Full) with a non-blocking collectAsync(Full) so the concurrent collector reclaims the old global's heap between files. Peak RSS stays flat (release: 50.2 -> 51.4 MB at N=1/20 on a 1000-module graph, matching default mode) and the per-file swap no longer blocks (N=20 completes in ~133ms). --- src/jsc/VM.rs | 10 +++++----- src/jsc/VirtualMachine.rs | 11 ++++++----- src/jsc/bindings/bindings.cpp | 20 +++++++++++--------- src/jsc/bindings/headers.h | 2 +- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index c0df839e8c0b..d09256e0243f 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -29,7 +29,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__collectNowFull(vm: &VM); + safe fn JSC__VM__collectFullAsync(vm: &VM); safe fn JSC__VM__executionForbidden(vm: &VM) -> bool; safe fn JSC__VM__notifyNeedTermination(vm: &VM); safe fn JSC__VM__isEntered(vm: &VM) -> bool; @@ -99,10 +99,10 @@ impl VM { JSC__VM__collectAsync(self) } - /// Full, synchronous collection that preserves the VM-scoped bytecode and - /// source-provider caches (unlike [`VM::run_gc`]). - pub fn collect_now_full(&self) { - JSC__VM__collectNowFull(self) + /// Request a full collection on the GC thread without blocking the event + /// loop (unlike [`VM::run_gc`], which collects synchronously). + pub fn collect_full_async(&self) { + JSC__VM__collectFullAsync(self) } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 81cb081020b9..c46bf2cbaf2c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5134,13 +5134,14 @@ impl VirtualMachine { // `create_for_test_isolation` detached the previous file's module graph // from the (now-unrooted) old global, but nothing in the per-file loop - // applies enough allocation pressure to trigger a full collection, so + // applies enough allocation pressure to schedule a full collection, so // the detached graph would otherwise sit in the old heap until the end // of the run — peak RSS growing linearly with the number of files and - // OOMing large suites. Force a full collection here to reclaim it and - // return the freed blocks to the OS, keeping RSS flat across files. - self.global().vm().collect_now_full(); - bun_core::Global::mimalloc_cleanup(false); + // OOMing large suites. Request a full collection on the GC thread to + // reclaim it between files; this runs concurrently and does not stall + // the event loop. Freed blocks are returned to the OS by the + // `mimalloc_cleanup` the per-file loop already runs before each swap. + self.global().vm().collect_full_async(); } /// Loads and evaluates a macro entry module, waiting for its promise. diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 3e1165ba7109..c01df3d55bc3 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -3046,17 +3046,19 @@ void JSC__VM__collectAsync(JSC::VM* vm) vm->heap.collectAsync(); } -// Full, synchronous collection that does NOT delete unlinked code blocks or -// clear the source-provider caches (unlike JSC__VM__runGC). Used by the -// `bun test --isolate` per-file global swap to reclaim the previous file's -// detached module graph while preserving the VM-scoped caches (bytecode, -// IsolatedModuleCache) that keep the next file fast. -void JSC__VM__collectNowFull(JSC::VM* vm) +// Request a full collection on the GC thread without blocking the event loop +// (unlike JSC__VM__runGC, which does a synchronous collectNow). Used by the +// `bun test --isolate` per-file global swap: the previous file's module graph +// has just been detached (moduleLoader registry + require cache cleared), but +// the tight per-file loop applies no allocation pressure, so the heap would +// otherwise never schedule a full collection and the detached graph would +// accumulate across files. Requesting a full (not eden) async collection lets +// the concurrent collector reclaim the old global's heap while the next file +// loads, keeping peak RSS flat without stalling the event loop. +void JSC__VM__collectFullAsync(JSC::VM* vm) { JSC::JSLockHolder lock(*vm); - vm->finalizeSynchronousJSExecution(); - vm->heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); - vm->finalizeSynchronousJSExecution(); + vm->heap.collectAsync(JSC::GCRequest(JSC::CollectionScope::Full)); } size_t JSC__VM__heapSize(JSC::VM* arg0) diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index bda5c27f7d47..ce2a057f704d 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -298,7 +298,7 @@ CPP_DECL void JSC__JSValue__toZigString(JSC::EncodedJSValue JSValue0, ZigString* CPP_DECL size_t JSC__VM__blockBytesAllocated(JSC::VM* arg0); CPP_DECL void JSC__VM__collectAsync(JSC::VM* arg0); -CPP_DECL void JSC__VM__collectNowFull(JSC::VM* arg0); +CPP_DECL void JSC__VM__collectFullAsync(JSC::VM* arg0); CPP_DECL JSC::VM* JSC__VM__create(unsigned char HeapType0); CPP_DECL void JSC__VM__deleteAllCode(JSC::VM* arg0, JSC::JSGlobalObject* arg1); CPP_DECL void JSC__VM__drainMicrotasks(JSC::VM* arg0); From c542deba18434b4aeb9ef03bcf5e9c984ffc5e69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:36:50 +0000 Subject: [PATCH 06/16] parallel workers: run mimalloc_cleanup before each isolation swap Gives --parallel workers the same per-file RSS hygiene as the serial --isolate loop, and makes the swap's comment about freed blocks accurate for both callers. --- src/jsc/VirtualMachine.rs | 3 ++- src/runtime/cli/test/parallel/runner.rs | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index c46bf2cbaf2c..533de139a465 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5140,7 +5140,8 @@ impl VirtualMachine { // OOMing large suites. Request a full collection on the GC thread to // reclaim it between files; this runs concurrently and does not stall // the event loop. Freed blocks are returned to the OS by the - // `mimalloc_cleanup` the per-file loop already runs before each swap. + // `mimalloc_cleanup` both per-file loops (the serial --isolate loop + // and the --parallel worker) run before each swap. self.global().vm().collect_full_async(); } diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 7430871524cc..02eb0b510384 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -575,6 +575,9 @@ impl<'a> WorkerLoop<'a> { } if vm.test_isolation_enabled { crate::jsc_hooks::stop_active_handles_for_test_isolation(vm); + // Mirrors the serial per-file loop: return blocks freed by the + // previous file's collection to the OS before the next swap. + Global::mimalloc_cleanup(false); vm.swap_global_for_test_isolation(); self.reporter .jest From 5c8a351bb0a5501d6b74354aab0b8a2c934c79c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:49:57 +0000 Subject: [PATCH 07/16] isolation.test.ts: drop per-test timeout that undercut the ASAN file default The explicit 30s timeout predates the rebase; main now sets setDefaultTimeout(isASAN ? 120_000 : 30_000) for this file, and a per-test timeout overrides it, shrinking the ASAN budget from 120s to 30s. Revert to the file-level default. --- test/cli/test/isolation.test.ts | 65 +++++++++++++++------------------ 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 801022224a8a..5d011b966c87 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -689,20 +689,18 @@ test.concurrent("--isolate: SourceProvider cache covers node_modules .mjs and ty expect(exitCode).toBe(0); }); -test.concurrent( - "--isolate: cached SourceProvider's module_info rebuilds correct exports", - async () => { - // A wide module so the printer-generated module_info has thousands of - // export entries. Under --isolate, file b hits the SourceProvider cache and - // rebuilds JSModuleRecord from the cached module_info (Bun__analyzeTranspiledModule) - // instead of re-parsing. If the record is wrong, named imports would be - // undefined or the count would mismatch. - const N = 2000; - let big = ""; - for (let i = 0; i < N; i++) big += `export function f${i}(x){return x+${i};}\n`; - big += `export const COUNT = ${N};\n`; +test.concurrent("--isolate: cached SourceProvider's module_info rebuilds correct exports", async () => { + // A wide module so the printer-generated module_info has thousands of + // export entries. Under --isolate, file b hits the SourceProvider cache and + // rebuilds JSModuleRecord from the cached module_info (Bun__analyzeTranspiledModule) + // instead of re-parsing. If the record is wrong, named imports would be + // undefined or the count would mismatch. + const N = 2000; + let big = ""; + for (let i = 0; i < N; i++) big += `export function f${i}(x){return x+${i};}\n`; + big += `export const COUNT = ${N};\n`; - const tBody = (name: string) => ` + const tBody = (name: string) => ` import { test, expect } from "bun:test"; import { f0, f1, f${N - 1}, COUNT } from "./big"; import * as all from "./big"; @@ -715,29 +713,24 @@ test.concurrent( }); `; - using dir = tempDir("isolate-module-info", { - "big.ts": big, - "a.test.ts": tBody("a"), - "b.test.ts": tBody("b"), - "c.test.ts": tBody("c"), - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--isolate", "./a.test.ts", "./b.test.ts", "./c.test.ts"], - env: bunEnv, - cwd: String(dir), - stderr: "pipe", - stdout: "pipe", - }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stderr).toContain("3 pass"); - expect(stderr).toContain("0 fail"); - expect(exitCode).toBe(0); - // Transpiling three files against a 2000-export module and spawning a child - // takes well over the 5s default under ASAN (and runs concurrently with the - // other heavy cases in this file); give it explicit headroom. - }, - 30_000, -); + using dir = tempDir("isolate-module-info", { + "big.ts": big, + "a.test.ts": tBody("a"), + "b.test.ts": tBody("b"), + "c.test.ts": tBody("c"), + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "./a.test.ts", "./b.test.ts", "./c.test.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toContain("3 pass"); + expect(stderr).toContain("0 fail"); + expect(exitCode).toBe(0); +}); test.concurrent( "--isolate: cached module_info handles `import * as ns; export { ns }` as a Namespace export", From 270a691f1e6775789e27cea78b49899c73509ddf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:40:13 +0000 Subject: [PATCH 08/16] worker teardown: route module-registry clear through clearModuleRegistry Fourth copy of the cellLock + clearAll + requireMap clear sequence, in WebWorker__teardownJSCVM, now uses the shared helper. Also report the child's stderr tail, exit code, and signal in one combined assertion in the 31771 regression test so a failed child is fully diagnosable from CI output. --- src/jsc/bindings/ZigGlobalObject.h | 4 ++-- test/regression/issue/31771.test.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 5efeb72d0187..62041d2a3f42 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -771,8 +771,8 @@ class GlobalObject : public Bun::GlobalScope { // Drop this global's module loader registry (ESM) and require cache (CJS), // severing moduleLoader -> record -> environment -> bindings so module - // top-level state becomes collectable. Used by reload(), the - // `bun test --isolate` global swap, and process-exit teardown. + // top-level state becomes collectable. Used by reload() and the + // `bun test --isolate` global swap. void clearModuleRegistry(); JSC::Structure* jsonlParseResultStructure() { return m_jsonlParseResultStructure.get(this); } diff --git a/test/regression/issue/31771.test.ts b/test/regression/issue/31771.test.ts index 252aa21f25a0..9829945be019 100644 --- a/test/regression/issue/31771.test.ts +++ b/test/regression/issue/31771.test.ts @@ -77,10 +77,15 @@ async function peakRssMb(dir: string, n: number): Promise { if (m) peakKb = Math.max(peakKb, parseInt(m[1], 10)); } catch {} - expect(stderr).toContain(`${n} pass`); - expect(stderr).toContain("0 fail"); - expect(peakKb).toBeGreaterThan(0); - expect(exitCode).toBe(0); + // Combined assertion so a failed/killed child surfaces its stderr tail, + // exit code, and signal together in one diff. + const summaryOk = stderr.includes(`${n} pass`) && stderr.includes("0 fail"); + expect({ + summary: summaryOk ? "ok" : stderr.slice(-2000), + exitCode, + signalCode: proc.signalCode, + sawPeakRss: peakKb > 0, + }).toEqual({ summary: "ok", exitCode: 0, signalCode: null, sawPeakRss: true }); return peakKb / 1024; } From 1749f67cd7d29f25acbc94e499eed4e6711a3f20 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:16:09 +0000 Subject: [PATCH 09/16] isolate swap: satisfy exception-check validation across the FFI boundary Zig__GlobalObject__createForTestIsolation declares a ThrowScope (around clearModuleRegistry); its destructor runs simulateThrow(), so with JSC exception-check validation enabled (the x64-asan CI lane), the next ThrowScope (the next file's loadAndEvaluateModule) aborted with 'exception check validation failed' (SIGABRT), killing the --isolate child and --parallel workers mid-run. Open a Rust-side TopExceptionScope across the create_for_test_isolation call and query it afterward, same as the generated check_slow wrappers. Reproduced deterministically with the validateExceptionChecks JSC option on a release-asan build; 10/10 isolate and 5/5 parallel runs pass after. Note: the previous version of this message had a wrapped line beginning with the literal option assignment, which the Windows CI env plumbing interpreted as a real JSC environment variable and failed every child process on every Windows shard. --- src/jsc/VirtualMachine.rs | 20 +++++++++++++++----- src/jsc/bindings/ZigGlobalObject.cpp | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 533de139a465..1b4e2fae14c8 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5115,11 +5115,21 @@ impl VirtualMachine { let old_global = self.global; // `old_global` valid for VM lifetime (safe ZST-handle deref); - // `console` is the live per-VM ConsoleObject. - let new_global: *mut JSGlobalObject = JSGlobalObject::create_for_test_isolation( - JSGlobalObject::opaque_ref(old_global), - self.console.cast(), - ); + // `console` is the live per-VM ConsoleObject. The C++ side opens a + // `DECLARE_THROW_SCOPE` (around clearModuleRegistry); under + // `BUN_JSC_validateExceptionChecks=1` its dtor sets + // `m_needExceptionCheck`, so a Rust-side scope must be live across the + // call and queried afterward, or the next file's first ThrowScope + // asserts in `verifyExceptionCheckNeedIsSatisfied`. + let new_global: *mut JSGlobalObject = { + crate::top_scope!(scope, self.global()); + let new_global = JSGlobalObject::create_for_test_isolation( + JSGlobalObject::opaque_ref(old_global), + self.console.cast(), + ); + scope.assert_no_exception(); + new_global + }; self.global = new_global; VMHolder::set_cached_global_object(Some(new_global)); self.regular_event_loop.global = NonNull::new(new_global); diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index c17eebb6295b..e8a5dbc4f35c 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -710,7 +710,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G // moduleLoader -> record -> environment -> bindings so the per-file module // graph is reclaimed even while the global shell lingers. Without this, the // graph accumulates linearly with the number of test files and OOMs large - // suites. Mirrors GlobalObject::reload() and Zig__GlobalObject__destructOnExit(). + // suites. Mirrors GlobalObject::reload(). { auto scope = DECLARE_THROW_SCOPE(vm); oldGlobal->clearModuleRegistry(); From 5a1544e11e5eac6556c417e0f6c7c7cd1bd1f383 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:03:40 +0000 Subject: [PATCH 10/16] isolate swap: use release-safe exception assert; trim comments - assert_no_exception() is debug/asan-only, so the release build-bun lanes failed to compile (no method on TopExceptionScope in release). Use assert_no_exception_except_termination(), which exists in all build configs, still queries the scope to satisfy the asan exception-check validator, and tolerates a pending worker-termination exception. - Shorten the explanatory comments flagged by the source-lint comment cap. --- src/jsc/VM.rs | 3 +-- src/jsc/VirtualMachine.rs | 23 +++++------------------ src/jsc/bindings/ZigGlobalObject.cpp | 20 ++++---------------- src/jsc/bindings/ZigGlobalObject.h | 6 ++---- src/jsc/bindings/bindings.cpp | 11 ++--------- src/runtime/cli/test/parallel/runner.rs | 3 +-- 6 files changed, 15 insertions(+), 51 deletions(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index d09256e0243f..6a57ccea2fd5 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -99,8 +99,7 @@ impl VM { JSC__VM__collectAsync(self) } - /// Request a full collection on the GC thread without blocking the event - /// loop (unlike [`VM::run_gc`], which collects synchronously). + /// Non-blocking full collection on the GC thread (unlike [`VM::run_gc`]). pub fn collect_full_async(&self) { JSC__VM__collectFullAsync(self) } diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 1b4e2fae14c8..4e615658a065 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5114,20 +5114,15 @@ impl VirtualMachine { self.unhandled_error_counter = 0; let old_global = self.global; - // `old_global` valid for VM lifetime (safe ZST-handle deref); - // `console` is the live per-VM ConsoleObject. The C++ side opens a - // `DECLARE_THROW_SCOPE` (around clearModuleRegistry); under - // `BUN_JSC_validateExceptionChecks=1` its dtor sets - // `m_needExceptionCheck`, so a Rust-side scope must be live across the - // call and queried afterward, or the next file's first ThrowScope - // asserts in `verifyExceptionCheckNeedIsSatisfied`. + // The C++ callee declares a ThrowScope; hold a Rust-side scope across the + // call and query it so exception-check validation stays balanced. let new_global: *mut JSGlobalObject = { crate::top_scope!(scope, self.global()); let new_global = JSGlobalObject::create_for_test_isolation( JSGlobalObject::opaque_ref(old_global), self.console.cast(), ); - scope.assert_no_exception(); + let _ = scope.assert_no_exception_except_termination(); new_global }; self.global = new_global; @@ -5142,16 +5137,8 @@ impl VirtualMachine { } } - // `create_for_test_isolation` detached the previous file's module graph - // from the (now-unrooted) old global, but nothing in the per-file loop - // applies enough allocation pressure to schedule a full collection, so - // the detached graph would otherwise sit in the old heap until the end - // of the run — peak RSS growing linearly with the number of files and - // OOMing large suites. Request a full collection on the GC thread to - // reclaim it between files; this runs concurrently and does not stall - // the event loop. Freed blocks are returned to the OS by the - // `mimalloc_cleanup` both per-file loops (the serial --isolate loop - // and the --parallel worker) run before each swap. + // Reclaim the old file's now-detached module graph; the per-file loop + // has no allocation pressure to trigger a collection on its own. self.global().vm().collect_full_async(); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index e8a5dbc4f35c..03b7324a3ec2 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -700,26 +700,15 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G globalObject->m_processEnvObject.set(vm, globalObject, Bun::createSharedEnvironmentVariablesMap(globalObject).getObject()); } - // Drop the module registry and require.cache on the outgoing global before - // unprotecting it. Every value stored in a module top-level binding is - // rooted through these maps (the module record holds its environment, which - // holds the bindings), so they survive a collection as long as the global - // is reachable at all — and the global can stay transiently reachable across - // the swap (e.g. a ScriptExecutionContext map entry, a pending native task, - // or a not-yet-swept cell). Clearing the maps here severs - // moduleLoader -> record -> environment -> bindings so the per-file module - // graph is reclaimed even while the global shell lingers. Without this, the - // graph accumulates linearly with the number of test files and OOMs large - // suites. Mirrors GlobalObject::reload(). + // Detach the outgoing file's module graph so it's reclaimed even if the old + // global lingers transiently past the swap (otherwise RSS grows per file). { auto scope = DECLARE_THROW_SCOPE(vm); oldGlobal->clearModuleRegistry(); scope.assertNoException(); } - // Drop the permanent root on the previous global so the global shell itself - // (and anything else it still roots) becomes collectable. JSC's CodeCache - // and Bun's RuntimeTranspilerCache are VM/process scoped and survive. + // Drop the permanent root on the previous global so it becomes collectable. oldGlobal->isThreadLocalDefaultGlobalObject = false; JSC::gcUnprotect(oldGlobal); @@ -3599,8 +3588,7 @@ void GlobalObject::clearModuleRegistry() { auto* moduleLoader = this->moduleLoader(); { - // JSModuleLoader::visitChildrenImpl iterates these maps on the GC thread - // under cellLock(); take the same lock so clearAll() can't race it. + // cellLock() pairs with the GC thread's visitChildrenImpl over these maps. WTF::Locker locker { moduleLoader->cellLock() }; moduleLoader->clearAll(); } diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 62041d2a3f42..5c407437aa93 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -769,10 +769,8 @@ class GlobalObject : public Bun::GlobalScope { void reload(); - // Drop this global's module loader registry (ESM) and require cache (CJS), - // severing moduleLoader -> record -> environment -> bindings so module - // top-level state becomes collectable. Used by reload() and the - // `bun test --isolate` global swap. + // Drop the ESM module registry and CJS require cache so module-level state + // becomes collectable. Used by reload() and the `--isolate` global swap. void clearModuleRegistry(); JSC::Structure* jsonlParseResultStructure() { return m_jsonlParseResultStructure.get(this); } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index c01df3d55bc3..9e80911b6cca 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -3046,15 +3046,8 @@ void JSC__VM__collectAsync(JSC::VM* vm) vm->heap.collectAsync(); } -// Request a full collection on the GC thread without blocking the event loop -// (unlike JSC__VM__runGC, which does a synchronous collectNow). Used by the -// `bun test --isolate` per-file global swap: the previous file's module graph -// has just been detached (moduleLoader registry + require cache cleared), but -// the tight per-file loop applies no allocation pressure, so the heap would -// otherwise never schedule a full collection and the detached graph would -// accumulate across files. Requesting a full (not eden) async collection lets -// the concurrent collector reclaim the old global's heap while the next file -// loads, keeping peak RSS flat without stalling the event loop. +// Full (not eden) collection on the GC thread, non-blocking (unlike +// JSC__VM__runGC's synchronous collectNow). void JSC__VM__collectFullAsync(JSC::VM* vm) { JSC::JSLockHolder lock(*vm); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 02eb0b510384..38dea0f31e74 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -575,8 +575,7 @@ impl<'a> WorkerLoop<'a> { } if vm.test_isolation_enabled { crate::jsc_hooks::stop_active_handles_for_test_isolation(vm); - // Mirrors the serial per-file loop: return blocks freed by the - // previous file's collection to the OS before the next swap. + // Return freed blocks to the OS before the swap (as the serial loop does). Global::mimalloc_cleanup(false); vm.swap_global_for_test_isolation(); self.reporter From 4cbd6b03aecc80a9f24950e570b0a1db10cfcf17 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:18:48 +0000 Subject: [PATCH 11/16] isolate: dedup forbidExecution through clearModuleRegistry; one-line comments - Route GlobalObject::forbidExecution() (VM/worker teardown) through the shared clearModuleRegistry() helper instead of its inline cellLock + clearAll + requireMap clear copy. Behavior-equivalent (verified: worker terminate tests behave identically with and without this change). - Collapse the explanatory comments flagged by the source-lint comment cap to single lines. --- src/jsc/VirtualMachine.rs | 6 ++---- src/jsc/bindings/ZigGlobalObject.cpp | 14 +++----------- src/jsc/bindings/ZigGlobalObject.h | 3 +-- src/jsc/bindings/bindings.cpp | 3 +-- src/runtime/cli/test/parallel/runner.rs | 2 +- 5 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 4e615658a065..702871c3a6b8 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -5114,8 +5114,7 @@ impl VirtualMachine { self.unhandled_error_counter = 0; let old_global = self.global; - // The C++ callee declares a ThrowScope; hold a Rust-side scope across the - // call and query it so exception-check validation stays balanced. + // Hold a Rust-side scope across the FFI call so the C++ ThrowScope's exception check is balanced. let new_global: *mut JSGlobalObject = { crate::top_scope!(scope, self.global()); let new_global = JSGlobalObject::create_for_test_isolation( @@ -5137,8 +5136,7 @@ impl VirtualMachine { } } - // Reclaim the old file's now-detached module graph; the per-file loop - // has no allocation pressure to trigger a collection on its own. + // Reclaim the old file's now-detached module graph (the per-file loop has no GC pressure). self.global().vm().collect_full_async(); } diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 03b7324a3ec2..6a7bfc4afd1f 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -700,8 +700,7 @@ extern "C" JSC::JSGlobalObject* Zig__GlobalObject__createForTestIsolation(Zig::G globalObject->m_processEnvObject.set(vm, globalObject, Bun::createSharedEnvironmentVariablesMap(globalObject).getObject()); } - // Detach the outgoing file's module graph so it's reclaimed even if the old - // global lingers transiently past the swap (otherwise RSS grows per file). + // Detach the outgoing file's module graph so it's reclaimed even if the old global lingers. { auto scope = DECLARE_THROW_SCOPE(vm); oldGlobal->clearModuleRegistry(); @@ -4308,17 +4307,10 @@ void GlobalObject::forbidExecution() // MicrotaskQueue references Heap. vm.defaultMicrotaskQueue().clear(); - // Drop the module registry and require() cache so module-level bindings become unreachable - // for the final collection (their ExternalStringImpl deallocators must run before ~VM). - { - auto* moduleLoader = this->moduleLoader(); - // JSModuleLoader::visitChildrenImpl iterates these maps on the GC thread under cellLock(). - WTF::Locker locker { moduleLoader->cellLock() }; - moduleLoader->clearAll(); - } + // Drop module registry + require cache so module bindings die before ~VM. { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); - requireMap()->clear(this); + this->clearModuleRegistry(); scope.clearException(); } diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 5c407437aa93..748ef5bd5a9d 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -769,8 +769,7 @@ class GlobalObject : public Bun::GlobalScope { void reload(); - // Drop the ESM module registry and CJS require cache so module-level state - // becomes collectable. Used by reload() and the `--isolate` global swap. + // Drop the ESM module registry and CJS require cache so module state becomes collectable. void clearModuleRegistry(); JSC::Structure* jsonlParseResultStructure() { return m_jsonlParseResultStructure.get(this); } diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 9e80911b6cca..e7b62715e380 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -3046,8 +3046,7 @@ void JSC__VM__collectAsync(JSC::VM* vm) vm->heap.collectAsync(); } -// Full (not eden) collection on the GC thread, non-blocking (unlike -// JSC__VM__runGC's synchronous collectNow). +// Non-blocking full (not eden) collection on the GC thread (unlike JSC__VM__runGC's collectNow). void JSC__VM__collectFullAsync(JSC::VM* vm) { JSC::JSLockHolder lock(*vm); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 38dea0f31e74..dee452576f1d 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -575,7 +575,7 @@ impl<'a> WorkerLoop<'a> { } if vm.test_isolation_enabled { crate::jsc_hooks::stop_active_handles_for_test_isolation(vm); - // Return freed blocks to the OS before the swap (as the serial loop does). + // Return freed blocks to the OS before the swap, as the serial loop does. Global::mimalloc_cleanup(false); vm.swap_global_for_test_isolation(); self.reporter From b07840141859d2120affafa70cfffa899d6d9386 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:21:27 +0000 Subject: [PATCH 12/16] test(31771): shrink isolate-RSS workload, tighten timeout Per test/CLAUDE.md and REVIEW.md: reduce LARGE_N 14 -> 10 to cut child wall-clock, and drop the per-test timeout 90s -> 30s. A memory-slope test that spawns two child bun test --isolate runs transpiling a 500-module graph under debug+ASAN can't fit the default budget without shrinking the graph below the fail-before discrimination floor, so it keeps an explicit timeout as the root CLAUDE.md 'rare outlier'. --- test/regression/issue/31771.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/regression/issue/31771.test.ts b/test/regression/issue/31771.test.ts index 9829945be019..020c29eedc01 100644 --- a/test/regression/issue/31771.test.ts +++ b/test/regression/issue/31771.test.ts @@ -25,7 +25,7 @@ import path from "node:path"; // completes well within the timeout under ASAN (the debug/gate build). const MODULE_COUNT = 500; const SMALL_N = 4; -const LARGE_N = 14; +const LARGE_N = 10; function makeFixtures() { const files: Record = {}; @@ -110,5 +110,8 @@ test.skipIf(!isLinux)( // ASAN's noisier heap accounting). expect(perFile).toBeLessThan(0.5); }, - 90_000, + // Outlier per root CLAUDE.md: spawns two child `bun test --isolate` runs that + // each transpile a 500-module graph under debug+ASAN (~14s total), which no + // module count small enough to fit the default budget would still discriminate. + 30_000, ); From d89f27357ffb76586e3e004127be65f020b2cc05 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:03:11 +0000 Subject: [PATCH 13/16] test(31771): drop dead post-exit VmHWM read proc.exited resolves only after the child is reaped, so /proc/ is gone by then (and a zombie's status has no Vm* lines anyway); the poll loop already captures the monotonic peak while the child runs. --- test/regression/issue/31771.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/regression/issue/31771.test.ts b/test/regression/issue/31771.test.ts index 020c29eedc01..4145599f59ce 100644 --- a/test/regression/issue/31771.test.ts +++ b/test/regression/issue/31771.test.ts @@ -71,12 +71,6 @@ async function peakRssMb(dir: string, n: number): Promise { running = false; await poll; - // One last read in case it exited between polls (VmHWM persists until exit). - try { - const m = fs.readFileSync(statusPath, "utf8").match(/VmHWM:\s*(\d+)\s*kB/); - if (m) peakKb = Math.max(peakKb, parseInt(m[1], 10)); - } catch {} - // Combined assertion so a failed/killed child surfaces its stderr tail, // exit code, and signal together in one diff. const summaryOk = stderr.includes(`${n} pass`) && stderr.includes("0 fail"); From 480f4fe9d79a3bfa8826e47067dde25f74bb0c46 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:04:02 +0000 Subject: [PATCH 14/16] [autofix.ci] apply automated fixes --- src/jsc/VM.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 6a57ccea2fd5..7aeeed95402b 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -104,7 +104,6 @@ impl VM { JSC__VM__collectFullAsync(self) } - pub fn execution_forbidden(&self) -> bool { JSC__VM__executionForbidden(self) } From 8ae0d7081f6b91efe16981078d98f49806052472 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:15:47 +0000 Subject: [PATCH 15/16] VM: narrow collect_full_async to pub(crate), matching collect_async --- src/jsc/VM.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/jsc/VM.rs b/src/jsc/VM.rs index 7aeeed95402b..e1234a1c32d4 100644 --- a/src/jsc/VM.rs +++ b/src/jsc/VM.rs @@ -100,7 +100,7 @@ impl VM { } /// Non-blocking full collection on the GC thread (unlike [`VM::run_gc`]). - pub fn collect_full_async(&self) { + pub(crate) fn collect_full_async(&self) { JSC__VM__collectFullAsync(self) } From ea01f7c42d9525d5d66fbe1e1f088de07ffe3ffa Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:40:20 +0000 Subject: [PATCH 16/16] parallel worker: hoist mimalloc_cleanup above the isolation branch Matches the serial per-file loop's shape exactly instead of duplicating the call in both arms. --- src/runtime/cli/test/parallel/runner.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index dee452576f1d..4301b8eae441 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -573,17 +573,14 @@ impl<'a> WorkerLoop<'a> { ) { test_command::handle_top_level_test_error_before_javascript_start(&err); } + Global::mimalloc_cleanup(false); if vm.test_isolation_enabled { crate::jsc_hooks::stop_active_handles_for_test_isolation(vm); - // Return freed blocks to the OS before the swap, as the serial loop does. - Global::mimalloc_cleanup(false); vm.swap_global_for_test_isolation(); self.reporter .jest .bun_test_root .reset_hook_scope_for_test_isolation(); - } else { - Global::mimalloc_cleanup(false); } self.reporter.jest.default_timeout_override = u32::MAX;