From 86b8b4b162ef57f181db8609fbf6ca207d5478c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:07:22 +0000 Subject: [PATCH 1/9] bun test --isolate: experimental global-reuse path to keep dependency JIT code alive Under --isolate today, every test file gets a fresh JSGlobalObject. That means every file re-links every dependency JSModuleRecord and re-tiers every hot dependency function up through LLInt/Baseline/DFG/FTL from scratch. On a 128-file suite importing zod/date-fns/lodash this is ~12s of DFG/FTL worklist CPU per run (7.3s wall) vs ~0.3s on a shared global (2.7s wall). This adds an opt-in BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL path that keeps the global between files and evicts only project-source module records (anything not under node_modules/ and not a node:/bun: builtin) from the ESM registry and require.cache. Dependency module records, and the FunctionExecutable/CodeBlock chain hanging off them, survive across files so optimized code is never recompiled. P0 spike only: no pristine-global fingerprint, no taint graph, no preload-once. Default behaviour unchanged. --- bench/test/README.md | 22 +++ bench/test/app/setup.ts | 86 ++++++++++ bench/test/bun.lock | 9 ++ bench/test/package.json | 5 +- src/bun_core/env_var.rs | 5 + src/jsc/VirtualMachine.rs | 43 ++++- src/jsc/bindings/bindings.cpp | 87 ++++++++++ src/runtime/cli/test/parallel/runner.rs | 8 +- src/runtime/cli/test_command.rs | 26 ++- test/cli/test/isolation.test.ts | 202 ++++++++++++++++++++++++ 10 files changed, 485 insertions(+), 8 deletions(-) create mode 100644 bench/test/app/setup.ts diff --git a/bench/test/README.md b/bench/test/README.md index cd4761eb99de..9e073fc8a558 100644 --- a/bench/test/README.md +++ b/bench/test/README.md @@ -37,3 +37,25 @@ hyperfine --warmup 1 \ gets a fresh global; the VM-level SourceProvider cache means the 2MB is transpiled and parsed once, and every subsequent file rebuilds the module record from cached `module_info` with zero re-parsing. + +## `--isolate`: JIT-heavy shared dependencies (global reuse) + +```sh +bun app/setup.ts +hyperfine --warmup 1 \ + -n 'fresh global' 'bun test --isolate ./app/suite' \ + -n 'reuse global' 'BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL=1 bun test --isolate ./app/suite' \ + -n 'no isolate' 'bun test ./app/suite' +``` + +128 test files that each import zod, date-fns and lodash-es and call into them +~2000 times, enough for JSC to tier the hot dependency functions up into +DFG/FTL. With a fresh global per file the dependency `JSModuleRecord`s (and +the `FunctionExecutable` / `CodeBlock` chain hanging off them) are rebuilt and +re-optimized every file; the experimental reuse path keeps the same global and +evicts only project-source module entries, so dependency optimized code +survives. + +To see the JIT-thread cost directly, compare user-CPU time (all threads) to +wall time: the gap is almost entirely DFG/FTL worklist threads recompiling the +same dependency functions. diff --git a/bench/test/app/setup.ts b/bench/test/app/setup.ts new file mode 100644 index 000000000000..cc31d7707f26 --- /dev/null +++ b/bench/test/app/setup.ts @@ -0,0 +1,86 @@ +// Generates a JIT-heavy suite: FILES test files that each import a handful of +// real-world dependencies (zod, date-fns, lodash-es) and call into them enough +// times that JSC tiers the dependency functions up into DFG/FTL. Under +// `--isolate` with a fresh global per file, every file re-links every +// dependency module and re-tiers every hot function from scratch; the +// experimental reuse-global path keeps dependency module records alive so the +// optimized CodeBlocks survive. +// +// Usage: +// bun bench/test/app/setup.ts +// hyperfine --warmup 1 \ +// -n 'fresh global' 'bun test --isolate bench/test/app/suite' \ +// -n 'reuse global' 'BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL=1 bun test --isolate bench/test/app/suite' +// +// To see JIT compile time directly: +// BUN_JSC_reportTotalCompileTimes=1 bun test --isolate bench/test/app/suite +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; + +const FILES = Number(process.env.FILES ?? 128); +const ITERS = Number(process.env.ITERS ?? 2000); +const root = import.meta.dir + "/suite"; + +rmSync(root, { recursive: true, force: true }); +mkdirSync(root, { recursive: true }); + +// Shared project-side helper: lives outside node_modules so the reuse path +// still evicts it (project modules must always be fresh). +writeFileSync( + `${root}/hot.ts`, + `import { z } from "zod"; +import { format, addDays, differenceInMilliseconds, parseISO } from "date-fns"; +import { chunk, sortBy, uniqBy, groupBy, sumBy } from "lodash-es"; + +export const schema = z.object({ + id: z.number().int().positive(), + name: z.string().min(1).max(64), + email: z.string().email(), + tags: z.array(z.string()).max(8), + createdAt: z.string(), +}); +export type Row = z.infer; + +export function hot(iters: number) { + const base = parseISO("2024-01-01T00:00:00.000Z"); + const rows: Row[] = []; + for (let i = 0; i < 64; i++) { + rows.push( + schema.parse({ + id: i + 1, + name: "user" + (i % 7), + email: "u" + i + "@example.com", + tags: ["t" + (i % 3), "t" + (i % 5)], + createdAt: format(addDays(base, i % 30), "yyyy-MM-dd'T'HH:mm:ss.SSSxxx"), + }), + ); + } + let acc = 0; + for (let k = 0; k < iters; k++) { + const grouped = groupBy(rows, r => r.name); + const sorted = sortBy(rows, r => r.id); + const uniq = uniqBy(rows, r => r.tags[0]); + const chunks = chunk(sorted, 8); + acc += sumBy(uniq, r => r.id); + acc += chunks.length; + acc += Object.keys(grouped).length; + acc += differenceInMilliseconds(parseISO(rows[k & 63].createdAt), base) & 1023; + } + return acc; +} +`, +); + +for (let f = 0; f < FILES; f++) { + writeFileSync( + `${root}/t${String(f).padStart(3, "0")}.test.ts`, + `import { hot } from "./hot"; +test("t${f}", () => { + const v = hot(${ITERS}); + expect(typeof v).toBe("number"); + expect(v > 0).toBe(true); +}); +`, + ); +} + +console.log(`wrote ${root}/hot.ts + ${FILES} test files (ITERS=${ITERS})`); diff --git a/bench/test/bun.lock b/bench/test/bun.lock index e951570ff14e..a8df385c2309 100644 --- a/bench/test/bun.lock +++ b/bench/test/bun.lock @@ -5,7 +5,10 @@ "": { "name": "bench-test", "devDependencies": { + "date-fns": "^4.1.0", + "lodash-es": "^4.17.21", "vitest": "^4.1.4", + "zod": "^3.25.76", }, }, }, @@ -84,6 +87,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], @@ -120,6 +125,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -159,5 +166,7 @@ "vitest": ["vitest@4.1.4", "", { "dependencies": { "@vitest/expect": "4.1.4", "@vitest/mocker": "4.1.4", "@vitest/pretty-format": "4.1.4", "@vitest/runner": "4.1.4", "@vitest/snapshot": "4.1.4", "@vitest/spy": "4.1.4", "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.4", "@vitest/browser-preview": "4.1.4", "@vitest/browser-webdriverio": "4.1.4", "@vitest/coverage-istanbul": "4.1.4", "@vitest/coverage-v8": "4.1.4", "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], } } diff --git a/bench/test/package.json b/bench/test/package.json index 4d3964f4f8b2..c5f301f097fb 100644 --- a/bench/test/package.json +++ b/bench/test/package.json @@ -3,6 +3,9 @@ "private": true, "type": "module", "devDependencies": { - "vitest": "^4.1.4" + "date-fns": "^4.1.0", + "lodash-es": "^4.17.21", + "vitest": "^4.1.4", + "zod": "^3.25.76" } } diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 6d1d0f45ef20..84eef325626b 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -207,6 +207,11 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); + // `bun test --isolate`: reuse the existing JSGlobalObject between test + // files (evicting only project-source module records) instead of creating + // a fresh one, so dependency FunctionExecutables keep their DFG/FTL code. + // P0 spike flag; no pristine-global fallback yet. + new_feature_flag!(pub BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL, "BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {}); // Force the event loop to use epoll_pwait(2) instead of epoll_pwait2(2). diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index e64145537196..67fcbf0d356c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4694,14 +4694,16 @@ impl VirtualMachine { Ok(()) } - /// Replaces the global object between test files so each file runs in a fresh realm. + /// Per-file cleanup shared by the fresh-global and reuse-global isolation + /// paths: restore cwd, drain microtasks, close leaked sockets, kill leaked + /// subprocesses, cancel timers, bump the generation, and reset entry-point + /// state. Runs no user JS after the microtask drains. /// /// Callers must run `bun_runtime::jsc_hooks::close_isolation_handles(vm)` /// first so leaked watchers/servers are stopped (dropping their JS-side - /// Strongs, which otherwise pin the outgoing global) before the blind - /// socket-group close below. That helper lives in the higher-tier crate - /// and cannot be called from here. - pub fn swap_global_for_test_isolation(&mut self) { + /// Strongs) before the blind socket-group close below. That helper lives + /// in the higher-tier crate and cannot be called from here. + fn cleanup_between_isolated_test_files(&mut self) { debug_assert!(self.test_isolation_enabled); if let Some(cwd) = self.test_isolation_state.saved_cwd.take() { @@ -4810,6 +4812,12 @@ impl VirtualMachine { self.main_resolved_path.deref(); self.main_resolved_path = bun_core::String::empty(); self.unhandled_error_counter = 0; + } + + /// Replaces the global object between test files so each file runs in a fresh realm. + /// See [`Self::cleanup_between_isolated_test_files`] for caller preconditions. + pub fn swap_global_for_test_isolation(&mut self) { + self.cleanup_between_isolated_test_files(); let old_global = self.global; // `old_global` valid for VM lifetime (safe ZST-handle deref); @@ -4837,6 +4845,31 @@ impl VirtualMachine { } } + /// Experimental `--isolate` path: keep the current global object and evict + /// only project-source module records (anything not under `node_modules/` + /// and not a `node:`/`bun:` built-in) from the ESM registry and + /// `require.cache`, so dependency `JSModuleRecord`s — and the DFG/FTL + /// `CodeBlock`s hung off their `FunctionExecutable`s — survive across + /// files. Gated by `BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL`; + /// does not yet check that the global is pristine. + /// + /// See [`Self::cleanup_between_isolated_test_files`] for caller preconditions. + pub fn reuse_global_for_test_isolation(&mut self) -> (u32, u32) { + self.cleanup_between_isolated_test_files(); + + let mut evicted_esm: u32 = 0; + let mut evicted_cjs: u32 = 0; + // SAFETY: `self.global` is the live per-VM global on the JS thread. + unsafe { + crate::cpp::raw::Bun__evictProjectModulesForTestIsolation( + self.global, + &mut evicted_esm, + &mut evicted_cjs, + ); + } + (evicted_esm, evicted_cjs) + } + /// Loads and evaluates a macro entry module, waiting for its promise. #[inline] pub(crate) fn _load_macro_entry_point( diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index d99045a75ad4..14ae587d0dcb 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2791,6 +2791,93 @@ void JSC__JSGlobalObject__deleteModuleRegistryEntry(JSC::JSGlobalObject* global, moduleLoader->removeEntry(identifier); } +// A module key is "kept" across test files under the experimental +// --isolate global-reuse path when it resolves into a node_modules tree or +// is a built-in specifier. Everything else (project source, the test file +// itself, Bun's synthetic entry key) is evicted so the next file re-evaluates +// it, while dependency JSModuleRecords — and the optimized CodeBlocks hanging +// off their FunctionExecutables — stay live. +static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key) +{ + if (key.isEmpty()) + return false; +#if OS(WINDOWS) + if (key.containsIgnoringASCIICase("/node_modules/"_s) || key.containsIgnoringASCIICase("\\node_modules\\"_s)) + return true; +#else + if (key.contains("/node_modules/"_s)) + return true; +#endif + // Built-in / virtual specifiers never live on disk; dropping them just + // forces a redundant re-link of native modules. + if (key.startsWith("node:"_s) || key.startsWith("bun:"_s) || key.startsWith("internal:"_s)) + return true; + return false; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation(JSC::JSGlobalObject* globalObject, uint32_t* outEvictedEsm, uint32_t* outEvictedCjs) +{ + auto& vm = JSC::getVM(globalObject); + auto* zigGlobal = defaultGlobalObject(globalObject); + auto* moduleLoader = globalObject->moduleLoader(); + + uint32_t evictedEsm = 0; + uint32_t evictedCjs = 0; + + { + WTF::Vector evict; + for (auto& [key, entry] : moduleLoader->moduleMap()) { + if (!key.first) + continue; + WTF::String keyString { key.first }; + if (isKeptAcrossTestIsolation(keyString)) + continue; + evict.append(JSC::Identifier::fromString(vm, keyString)); + } + if (!evict.isEmpty()) { + WTF::Locker locker { moduleLoader->cellLock() }; + for (auto& id : evict) + moduleLoader->removeEntry(id); + } + evictedEsm = evict.size(); + } + + { + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* requireMap = zigGlobal->requireMap(); + WTF::Vector evict; + auto iter = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), requireMap, JSC::IterationKind::Keys); + if (scope.exception()) [[unlikely]] { + scope.clearException(); + } else { + JSC::JSValue keyValue; + while (iter->next(globalObject, keyValue)) { + auto keyString = keyValue.toWTFString(globalObject); + if (scope.exception()) [[unlikely]] { + scope.clearException(); + continue; + } + if (isKeptAcrossTestIsolation(keyString)) + continue; + evict.append(keyValue); + } + if (scope.exception()) [[unlikely]] + scope.clearException(); + for (auto& key : evict) { + requireMap->remove(globalObject, key); + if (scope.exception()) [[unlikely]] + scope.clearException(); + } + } + evictedCjs = evict.size(); + } + + if (outEvictedEsm) + *outEvictedEsm = evictedEsm; + if (outEvictedCjs) + *outEvictedCjs = evictedCjs; +} + void JSC__VM__collectAsync(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 eaf18652a3f3..6af70b852c16 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -547,7 +547,13 @@ impl<'a> WorkerLoop<'a> { test_command::handle_top_level_test_error_before_javascript_start(&err); } crate::jsc_hooks::close_isolation_handles(vm); - vm.swap_global_for_test_isolation(); + if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL::get() + .unwrap_or(false) + { + let _ = vm.reuse_global_for_test_isolation(); + } else { + vm.swap_global_for_test_isolation(); + } self.reporter .jest .bun_test_root diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index f704bf5d06a0..29535dbf19c5 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -26,6 +26,7 @@ use bun_sys::{self, Fd, File}; // Debug log scope for test-runner entrypoint loading. bun_output::declare_scope!(bun_test, hidden); +bun_output::declare_scope!(test_isolate, hidden); // ─── coverage façade ──────────────────────────────────────────────────────── // Thin adapter over `bun_sourcemap_jsc::code_coverage` that preserves the @@ -3098,6 +3099,10 @@ impl TestCommand { debug_assert!(!files.is_empty()); let isolate = vm.test_isolation_enabled; + let reuse_global = isolate + && bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL::get() + .unwrap_or(false); + let mut reused_count: u32 = 0; if files.len() > 1 { for (i, file_name) in files[0..files.len() - 1].iter().enumerate() { @@ -3116,7 +3121,18 @@ impl TestCommand { Global::mimalloc_cleanup(false); if isolate { crate::jsc_hooks::close_isolation_handles(vm); - vm.swap_global_for_test_isolation(); + if reuse_global { + let (esm, cjs) = vm.reuse_global_for_test_isolation(); + reused_count += 1; + bun_output::scoped_log!( + test_isolate, + "reused global (evicted {} esm, {} cjs)", + esm, + cjs + ); + } else { + vm.swap_global_for_test_isolation(); + } reporter .jest .bun_test_root @@ -3124,6 +3140,14 @@ impl TestCommand { } } } + if reuse_global && reused_count > 0 { + bun_output::scoped_log!( + test_isolate, + "reused global for {} of {} files", + reused_count, + files.len() + ); + } if let Err(err) = TestCommand::run( reporter, diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 125347c4d0d5..95a8c581f503 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -968,3 +968,205 @@ test.concurrent("--isolate: require(esm) caches a BunTranspiledModule SourceProv expect(exitCode, `run ${run}`).toBe(0); } }); + +describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", () => { + const reuseEnv = { + ...bunEnv, + BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL: "1", + }; + + async function run(dir: string, files: string[], env = reuseEnv) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", ...files], + env, + cwd: dir, + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // This is the defining semantic: a dependency's JSModuleRecord (and with it + // any DFG/FTL CodeBlocks) survives across files, so its module-scope state + // is the same instance. Project-source modules are evicted and re-evaluate + // fresh. With a fresh global per file (flag off) the dep would ALSO be fresh, + // so file B would see `dep.touchedByA === undefined` and this test would fail. + test("node_modules records survive across files; project modules re-evaluate", async () => { + using dir = tempDir("isolate-reuse-dep", { + "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, + "node_modules/dep/index.js": ` + export const state = { loads: 0 }; + state.loads++; + export function hot(n) { let s = 0; for (let i = 0; i < n; i++) s += i; return s; } + `, + "project.ts": ` + export const state = { loads: 0 }; + state.loads++; + `, + "a.test.ts": ` + import { test, expect } from "bun:test"; + import { state as dep, hot } from "dep"; + import { state as proj } from "./project"; + test("a", () => { + expect(dep.loads).toBe(1); + expect(proj.loads).toBe(1); + dep.touchedByA = true; + proj.touchedByA = true; + expect(hot(1000)).toBe(499500); + }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { state as dep, hot } from "dep"; + import { state as proj } from "./project"; + test("b", () => { + // dep was NOT evicted: same record, same instance + expect(dep).toEqual({ loads: 1, touchedByA: true }); + // project.ts WAS evicted and re-evaluated: fresh instance + expect(proj).toEqual({ loads: 1 }); + expect(hot(1000)).toBe(499500); + }); + `, + }); + const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(exitCode).toBe(0); + }); + + // Without the flag, today's --isolate gives every file a fresh global, so a + // dependency's module-scope state resets per file (loads === 1 each time). + // Run the same b.test.ts above without the flag: still passes, but for the + // opposite reason. The observable difference is the dep instance identity, + // which we assert here. + test("flag off: dep module is a fresh instance per file", async () => { + using dir = tempDir("isolate-reuse-off", { + "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, + "node_modules/dep/index.js": ` + export const state = { loads: 0 }; + state.loads++; + `, + "a.test.ts": ` + import { test, expect } from "bun:test"; + import { state } from "dep"; + test("a", () => { state.touched = true; expect(state.loads).toBe(1); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { state } from "dep"; + test("b", () => { + expect(state.loads).toBe(1); + if (process.env.BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL === "1") { + expect(state.touched).toBe(true); + } else { + expect(state.touched).toBeUndefined(); + } + }); + `, + }); + for (const env of [reuseEnv, bunEnv]) { + const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"], env); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(exitCode).toBe(0); + } + }); + + test("CJS: node_modules entry kept in require.cache; project entry evicted", async () => { + using dir = tempDir("isolate-reuse-cjs", { + "node_modules/cjsdep/package.json": `{"name":"cjsdep","main":"index.js"}`, + "node_modules/cjsdep/index.js": ` + module.exports.state = { loads: (global.__cjsdep_loads = (global.__cjsdep_loads ?? 0) + 1) }; + `, + "project.cjs": ` + module.exports.state = { loads: (global.__proj_loads = (global.__proj_loads ?? 0) + 1) }; + `, + "a.test.ts": ` + import { test, expect } from "bun:test"; + const dep = require("cjsdep"); + const proj = require("./project.cjs"); + test("a", () => { + expect(dep.state.loads).toBe(1); + expect(proj.state.loads).toBe(1); + }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + const dep = require("cjsdep"); + const proj = require("./project.cjs"); + test("b", () => { + // kept: same module object, body not re-run + expect(dep.state.loads).toBe(1); + // evicted: body re-ran in the same global, counter is 2 + expect(proj.state.loads).toBe(2); + }); + `, + }); + const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(exitCode).toBe(0); + }); + + test("leaked timer, server and subprocess are still torn down between files", async () => { + using dir = tempDir("isolate-reuse-cleanup", { + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("leak", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("hi") }); + (globalThis as any).leakedPort = server.port; + setInterval(() => { (globalThis as any).ticks = ((globalThis as any).ticks ?? 0) + 1; }, 5).unref(); + Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(()=>{},1e6)"], stdio: ["ignore","ignore","ignore"] }); + expect(server.port).toBeGreaterThan(0); + }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + test("cleaned", async () => { + // The global object is reused, so props set in file A are still visible + // (P0 spike: no pristine-global fallback yet). What we assert here is + // that the leaked *resources* are gone. + const port = (globalThis as any).leakedPort; + expect(typeof port).toBe("number"); + await expect(fetch("http://127.0.0.1:" + port)).rejects.toThrow(); + }); + `, + }); + const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(exitCode).toBe(0); + }); + + // Preload modules live in project space, so the P0 spike evicts them like + // any other project module and load_preloads re-evaluates them per file in + // the reused global. "Preload once per global" is follow-up work (requires + // tracking resolved preload paths so the eviction pass can skip them); this + // test pins the current behaviour so that change is deliberate. + test("preload runs per file (P0: evicted like project modules)", async () => { + using dir = tempDir("isolate-reuse-preload", { + "preload.ts": ` + (globalThis as any).__preload_count = ((globalThis as any).__preload_count ?? 0) + 1; + `, + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("a", () => { expect((globalThis as any).__preload_count).toBe(1); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + test("b", () => { expect((globalThis as any).__preload_count).toBe(2); }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "--preload", "./preload.ts", "./a.test.ts", "./b.test.ts"], + env: reuseEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(exitCode).toBe(0); + }); +}); From f74845ea04f12de8e982430cf94d2f9ea36763f9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:15:05 +0000 Subject: [PATCH 2/9] clippy: use &raw mut for FFI out-params --- src/jsc/VirtualMachine.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 67fcbf0d356c..3c80f5dff0de 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4863,8 +4863,8 @@ impl VirtualMachine { unsafe { crate::cpp::raw::Bun__evictProjectModulesForTestIsolation( self.global, - &mut evicted_esm, - &mut evicted_cjs, + &raw mut evicted_esm, + &raw mut evicted_cjs, ); } (evicted_esm, evicted_cjs) From 47caca00b99e3d9cb8cf14291556617424682e15 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:17:33 +0000 Subject: [PATCH 3/9] trim doc comments flagged by comment-cop --- src/bun_core/env_var.rs | 4 ---- src/jsc/VirtualMachine.rs | 17 ++++------------- src/jsc/bindings/bindings.cpp | 9 +-------- 3 files changed, 5 insertions(+), 25 deletions(-) diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 84eef325626b..c25fa83164ec 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -207,10 +207,6 @@ pub mod feature_flag { new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG, "BUN_FEATURE_FLAG_DISABLE_ADDRCONFIG", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER, "BUN_FEATURE_FLAG_DISABLE_ASYNC_TRANSPILER", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE, "BUN_FEATURE_FLAG_DISABLE_ISOLATION_SOURCE_CACHE", {}); - // `bun test --isolate`: reuse the existing JSGlobalObject between test - // files (evicting only project-source module records) instead of creating - // a fresh one, so dependency FunctionExecutables keep their DFG/FTL code. - // P0 spike flag; no pristine-global fallback yet. new_feature_flag!(pub BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL, "BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE", {}); new_feature_flag!(pub BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO, "BUN_FEATURE_FLAG_DISABLE_DNS_CACHE_LIBINFO", {}); diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 3c80f5dff0de..d0da114afec4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4694,10 +4694,7 @@ impl VirtualMachine { Ok(()) } - /// Per-file cleanup shared by the fresh-global and reuse-global isolation - /// paths: restore cwd, drain microtasks, close leaked sockets, kill leaked - /// subprocesses, cancel timers, bump the generation, and reset entry-point - /// state. Runs no user JS after the microtask drains. + /// Per-file `--isolate` cleanup shared by the fresh-global and reuse-global paths. /// /// Callers must run `bun_runtime::jsc_hooks::close_isolation_handles(vm)` /// first so leaked watchers/servers are stopped (dropping their JS-side @@ -4845,15 +4842,9 @@ impl VirtualMachine { } } - /// Experimental `--isolate` path: keep the current global object and evict - /// only project-source module records (anything not under `node_modules/` - /// and not a `node:`/`bun:` built-in) from the ESM registry and - /// `require.cache`, so dependency `JSModuleRecord`s — and the DFG/FTL - /// `CodeBlock`s hung off their `FunctionExecutable`s — survive across - /// files. Gated by `BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL`; - /// does not yet check that the global is pristine. - /// - /// See [`Self::cleanup_between_isolated_test_files`] for caller preconditions. + /// Experimental `--isolate` path: keep the current global and evict only + /// project-source module records so dependency DFG/FTL code survives across + /// files. Same caller preconditions as [`Self::swap_global_for_test_isolation`]. pub fn reuse_global_for_test_isolation(&mut self) -> (u32, u32) { self.cleanup_between_isolated_test_files(); diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 14ae587d0dcb..5db4b3598528 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2791,12 +2791,7 @@ void JSC__JSGlobalObject__deleteModuleRegistryEntry(JSC::JSGlobalObject* global, moduleLoader->removeEntry(identifier); } -// A module key is "kept" across test files under the experimental -// --isolate global-reuse path when it resolves into a node_modules tree or -// is a built-in specifier. Everything else (project source, the test file -// itself, Bun's synthetic entry key) is evicted so the next file re-evaluates -// it, while dependency JSModuleRecords — and the optimized CodeBlocks hanging -// off their FunctionExecutables — stay live. +// Kept across files = resolves into node_modules or is a built-in specifier. static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key) { if (key.isEmpty()) @@ -2808,8 +2803,6 @@ static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key) if (key.contains("/node_modules/"_s)) return true; #endif - // Built-in / virtual specifiers never live on disk; dropping them just - // forces a redundant re-link of native modules. if (key.startsWith("node:"_s) || key.startsWith("bun:"_s) || key.startsWith("internal:"_s)) return true; return false; From 29325eacb3990f5032f3975c8e8cc932673e146b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:23:39 +0000 Subject: [PATCH 4/9] P2: pristine-global fingerprint + fallback, preload-once, --isolate=fresh-global Bun__computeIsolationFingerprint digests globalThis + the fixed intrinsic set (ctor/prototype of Object/Function/Array/String/Number/Boolean/Symbol/ BigInt/Date/RegExp/Error/Promise/Map/Set/ArrayBuffer, plus Math/JSON/ Reflect/console/fetch/WeakMap/WeakSet/Error subclasses by slot value, plus process identity + listener presence, plus crypto/Request/Response/Headers/ Buffer identity). Each hashed object's lazy static properties are reified first so test-time reads of e.g. Object.keys don't look like mutation. prepare_global_for_next_test_file runs the shared cleanup then checks hard dirty triggers (mock.module, pending fetches) and the fingerprint. Match: evict project modules (plus anything not in the baseline preload-key snapshot) and reuse. Mismatch: swap to a fresh global, clear baseline + preload snapshot, and let the next load re-run preloads and re-baseline. Preloads run once per global: the module keys live in the registry at baseline time are snapshotted and exempted from eviction, and reload_entry_point_for_test_runner skips load_preloads while a baseline is captured. FirstLast.first tracks the per-global lifecycle so preload-level beforeAll fires once per global; reset_hook_scope_for_test_isolation only runs on the fresh-global path. --isolate now accepts an optional value; --isolate=fresh-global forces the fresh-global path regardless of the feature flag (forwarded to --parallel workers). BUN_DEBUG_test_isolate emits a reused/fresh summary line in release builds too so the isolation tests can assert it. Also: MarkedArgumentBuffer for the require.cache eviction collect, evict non-string require.cache keys instead of swallowing the conversion throw, clearExceptionExceptTermination at every nothrow boundary, and keep node_quic_callbacks.deinit on the fresh-global path only (its one-time initializer lives in a kept built-in module). --- bench/test/README.md | 4 +- src/jsc/VirtualMachine.rs | 155 ++++++++++- src/jsc/bindings/BunPlugin.cpp | 1 + src/jsc/bindings/ZigGlobalObject.h | 2 + src/jsc/bindings/bindings.cpp | 226 ++++++++++++++-- src/options_types/context.rs | 4 + src/runtime/cli/Arguments.rs | 15 +- src/runtime/cli/test/parallel/runner.rs | 37 ++- src/runtime/cli/test_command.rs | 95 +++++-- test/cli/test/isolation.test.ts | 327 +++++++++++++++++------- 10 files changed, 707 insertions(+), 159 deletions(-) diff --git a/bench/test/README.md b/bench/test/README.md index 9e073fc8a558..faa27db31292 100644 --- a/bench/test/README.md +++ b/bench/test/README.md @@ -53,8 +53,8 @@ hyperfine --warmup 1 \ DFG/FTL. With a fresh global per file the dependency `JSModuleRecord`s (and the `FunctionExecutable` / `CodeBlock` chain hanging off them) are rebuilt and re-optimized every file; the experimental reuse path keeps the same global and -evicts only project-source module entries, so dependency optimized code -survives. +evicts only project-source module entries, so dependencies keep their optimized +code. To see the JIT-thread cost directly, compare user-CPU time (all threads) to wall time: the gap is almost entirely DFG/FTL worklist threads recompiling the diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index d0da114afec4..bc83322c382c 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -345,6 +345,51 @@ pub struct VirtualMachine { #[derive(Default)] pub struct TestIsolationState { pub saved_cwd: Option>, + pub reuse: TestIsolationReuseState, +} + +#[derive(Default)] +pub struct TestIsolationReuseState { + /// Opt-in via `BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL`. + /// `--isolate=fresh-global` forces this false even with the flag set. + pub enabled: bool, + /// Intrinsic digest taken after preloads; `None` until the first file completes. + pub baseline_fingerprint: Option, + /// Module keys loaded while `vm.is_in_preload` was true; exempt from eviction. + pub preload_module_keys: Vec, + pub reused_count: u32, + pub fresh_fingerprint_count: u32, + pub fresh_module_mock_count: u32, + pub fresh_pending_fetch_count: u32, +} + +impl TestIsolationReuseState { + pub fn fresh_count(&self) -> u32 { + self.fresh_fingerprint_count + self.fresh_module_mock_count + self.fresh_pending_fetch_count + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestIsolationOutcome { + Reused { evicted_esm: u32, evicted_cjs: u32 }, + Fresh(TestIsolationFreshReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestIsolationFreshReason { + Fingerprint, + ModuleMock, + PendingFetch, +} + +impl TestIsolationFreshReason { + pub const fn as_str(self) -> &'static str { + match self { + Self::Fingerprint => "fingerprint", + Self::ModuleMock => "mock.module", + Self::PendingFetch => "pending-fetch", + } + } } // ────────────────────────────────────────────────────────────────────────── @@ -4551,7 +4596,14 @@ impl VirtualMachine { let _ = self.ensure_debugger(true); - if !self.transpiler.options.disable_transpilation { + let reuse = self.test_isolation_state.reuse.enabled; + // Reuse path with a captured baseline means preloads already ran + // against this global; their module records are in the registry and + // exempt from eviction, so skip load_preloads entirely. + let skip_preloads = + reuse && self.test_isolation_state.reuse.baseline_fingerprint.is_some(); + + if !self.transpiler.options.disable_transpilation && !skip_preloads { if let Some(hooks) = runtime_hooks() { // SAFETY: hook contract. let p = unsafe { (hooks.load_preloads)(self) }?; @@ -4564,6 +4616,10 @@ impl VirtualMachine { } } } + if reuse && !skip_preloads { + self.snapshot_preload_module_keys(); + self.capture_isolation_baseline_fingerprint(); + } // Note: reshaped for borrowck. let global = self.global; @@ -4758,11 +4814,6 @@ impl VirtualMachine { } if let Some(rare) = self.rare_data.as_deref_mut() { rare.listening_sockets_for_watch_mode.lock().clear(); - // `setCallbacks` is once-only (node/src/quic/bindingdata.cc - // `BindingData::SetCallbacks`), so a holder left by the outgoing - // global makes the next file's call a no-op and dispatches - // node:quic events into the dead realm. - rare.node_quic_callbacks.deinit(); } let _ = self.event_loop_mut().drain_microtasks(); @@ -4815,7 +4866,17 @@ impl VirtualMachine { /// See [`Self::cleanup_between_isolated_test_files`] for caller preconditions. pub fn swap_global_for_test_isolation(&mut self) { self.cleanup_between_isolated_test_files(); + self.swap_global_for_test_isolation_inner(); + } + fn swap_global_for_test_isolation_inner(&mut self) { + if let Some(rare) = self.rare_data.as_deref_mut() { + // `setCallbacks` is once-only (node/src/quic/bindingdata.cc + // `BindingData::SetCallbacks`), so a holder left by the outgoing + // global makes the next file's call a no-op and dispatches + // node:quic events into the dead realm. + rare.node_quic_callbacks.deinit(); + } let old_global = self.global; // `old_global` valid for VM lifetime (safe ZST-handle deref); // `console` is the live per-VM ConsoleObject. @@ -4845,20 +4906,96 @@ impl VirtualMachine { /// Experimental `--isolate` path: keep the current global and evict only /// project-source module records so dependency DFG/FTL code survives across /// files. Same caller preconditions as [`Self::swap_global_for_test_isolation`]. - pub fn reuse_global_for_test_isolation(&mut self) -> (u32, u32) { + pub fn prepare_global_for_next_test_file(&mut self) -> TestIsolationOutcome { + debug_assert!(self.test_isolation_state.reuse.enabled); self.cleanup_between_isolated_test_files(); + // SAFETY: `self.global` is the live per-VM global on the JS thread. + let (mock_dirty, pending, fp) = unsafe { + ( + crate::cpp::raw::Bun__takeTestIsolationModuleMockDirty(self.global), + crate::cpp::raw::Bun__hasPendingModuleFetches(self.global), + crate::cpp::raw::Bun__computeIsolationFingerprint(self.global), + ) + }; + + let reason = if mock_dirty { + Some(TestIsolationFreshReason::ModuleMock) + } else if pending { + Some(TestIsolationFreshReason::PendingFetch) + } else if self.test_isolation_state.reuse.baseline_fingerprint != Some(fp) { + Some(TestIsolationFreshReason::Fingerprint) + } else { + None + }; + + if let Some(reason) = reason { + self.swap_global_for_test_isolation_inner(); + let reuse = &mut self.test_isolation_state.reuse; + match reason { + TestIsolationFreshReason::Fingerprint => reuse.fresh_fingerprint_count += 1, + TestIsolationFreshReason::ModuleMock => reuse.fresh_module_mock_count += 1, + TestIsolationFreshReason::PendingFetch => reuse.fresh_pending_fetch_count += 1, + } + reuse.baseline_fingerprint = None; + while let Some(key) = reuse.preload_module_keys.pop() { + key.deref(); + } + return TestIsolationOutcome::Fresh(reason); + } + let mut evicted_esm: u32 = 0; let mut evicted_cjs: u32 = 0; - // SAFETY: `self.global` is the live per-VM global on the JS thread. + let skip = &self.test_isolation_state.reuse.preload_module_keys; + // SAFETY: `self.global` live; `skip` is a contiguous `[bun_core::String]` + // which is ABI-identical to C++ `const BunString*` + len. unsafe { crate::cpp::raw::Bun__evictProjectModulesForTestIsolation( self.global, + skip.as_ptr().cast(), + skip.len(), &raw mut evicted_esm, &raw mut evicted_cjs, ); } - (evicted_esm, evicted_cjs) + self.test_isolation_state.reuse.reused_count += 1; + TestIsolationOutcome::Reused { + evicted_esm, + evicted_cjs, + } + } + + /// Stable-baseline capture: digest twice (the first pass reifies lazy + /// static properties on every hashed intrinsic, so the second is stable). + pub fn capture_isolation_baseline_fingerprint(&mut self) { + // SAFETY: `self.global` is the live per-VM global on the JS thread. + unsafe { + crate::cpp::raw::Bun__computeIsolationFingerprint(self.global); + let fp = crate::cpp::raw::Bun__computeIsolationFingerprint(self.global); + self.test_isolation_state.reuse.baseline_fingerprint = Some(fp); + crate::cpp::raw::Bun__takeTestIsolationModuleMockDirty(self.global); + } + } + + fn snapshot_preload_module_keys(&mut self) { + let keys = &mut self.test_isolation_state.reuse.preload_module_keys; + debug_assert!(keys.is_empty()); + extern "C" fn cb(ctx: *mut core::ffi::c_void, key: *const bun_core::String) { + // SAFETY: `ctx` is the `&mut Vec` we passed below; `key` is the + // caller's live stack `BunString`. + unsafe { (*ctx.cast::>()).push((*key).dupe_ref()) }; + } + // cppbind.ts mis-generates function-pointer params as + // `*const Option`; the hand-written decl here is the real ABI. + #[allow(clashing_extern_declarations)] + unsafe extern "C" { + safe fn Bun__forEachModuleKeyForTestIsolation( + global: *mut JSGlobalObject, + ctx: *mut core::ffi::c_void, + cb: extern "C" fn(*mut core::ffi::c_void, *const bun_core::String), + ); + } + Bun__forEachModuleKeyForTestIsolation(self.global, core::ptr::from_mut(keys).cast(), cb); } /// Loads and evaluates a macro entry module, waiting for its promise. diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index 3352c3f6896a..3a8ea01a0450 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -513,6 +513,7 @@ extern "C" JSC_DEFINE_HOST_FUNCTION(JSMock__jsModuleMock, (JSC::JSGlobalObject * scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "Cannot run mock from a different global context"_s)); return {}; } + globalObject->testIsolationModuleMockDirty = true; if (callframe->argumentCount() < 1) { scope.throwException(lexicalGlobalObject, JSC::createTypeError(lexicalGlobalObject, "mock(module, fn) requires a module and function"_s)); diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 628b1db8e66b..53ef71d5b06f 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -787,6 +787,8 @@ class GlobalObject : public Bun::GlobalScope { bool hasOverriddenModuleWrapper = false; // De-optimization once `require("module").runMain` is written to bool hasOverriddenModuleRunMain = false; + // Set by JSMock__jsModuleMock; read-and-cleared at the --isolate file boundary. + bool testIsolationModuleMockDirty = false; // node:crypto deprecation warnings are emitted at most once per realm, like Node, whose // flags live in per-realm module state (lib/internal/crypto/keys.js). They must not be diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index 5db4b3598528..f0de946976d4 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -62,6 +62,10 @@ #include "JavaScriptCore/JSMapIterator.h" #include "JavaScriptCore/JSModuleLoader.h" #include "JavaScriptCore/JSModuleRecord.h" +#include "JavaScriptCore/ModuleRegistryEntry.h" +#include "JavaScriptCore/RegExpPrototype.h" +#include "JavaScriptCore/JSPromisePrototype.h" +#include "BunProcess.h" #include "JavaScriptCore/JSNativeStdFunction.h" #include "JavaScriptCore/JSONObject.h" #include "JavaScriptCore/JSObject.h" @@ -2791,8 +2795,7 @@ void JSC__JSGlobalObject__deleteModuleRegistryEntry(JSC::JSGlobalObject* global, moduleLoader->removeEntry(identifier); } -// Kept across files = resolves into node_modules or is a built-in specifier. -static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key) +static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key, const BunString* skip, size_t skipLen) { if (key.isEmpty()) return false; @@ -2805,10 +2808,14 @@ static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key) #endif if (key.startsWith("node:"_s) || key.startsWith("bun:"_s) || key.startsWith("internal:"_s)) return true; + for (size_t i = 0; i < skipLen; ++i) { + if (skip[i].toWTFString() == key) + return true; + } return false; } -extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation(JSC::JSGlobalObject* globalObject, uint32_t* outEvictedEsm, uint32_t* outEvictedCjs) +extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation(JSC::JSGlobalObject* globalObject, const BunString* skip, size_t skipLen, uint32_t* outEvictedEsm, uint32_t* outEvictedCjs) { auto& vm = JSC::getVM(globalObject); auto* zigGlobal = defaultGlobalObject(globalObject); @@ -2823,7 +2830,7 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation if (!key.first) continue; WTF::String keyString { key.first }; - if (isKeptAcrossTestIsolation(keyString)) + if (isKeptAcrossTestIsolation(keyString, skip, skipLen)) continue; evict.append(JSC::Identifier::fromString(vm, keyString)); } @@ -2838,28 +2845,32 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation { auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); auto* requireMap = zigGlobal->requireMap(); - WTF::Vector evict; + JSC::MarkedArgumentBuffer evict; auto iter = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), requireMap, JSC::IterationKind::Keys); if (scope.exception()) [[unlikely]] { - scope.clearException(); + scope.clearExceptionExceptTermination(); } else { JSC::JSValue keyValue; while (iter->next(globalObject, keyValue)) { - auto keyString = keyValue.toWTFString(globalObject); + if (!keyValue.isString()) { + evict.append(keyValue); + continue; + } + auto keyString = asString(keyValue)->value(globalObject); if (scope.exception()) [[unlikely]] { - scope.clearException(); + scope.clearExceptionExceptTermination(); continue; } - if (isKeptAcrossTestIsolation(keyString)) + if (isKeptAcrossTestIsolation(keyString, skip, skipLen)) continue; evict.append(keyValue); } if (scope.exception()) [[unlikely]] - scope.clearException(); - for (auto& key : evict) { - requireMap->remove(globalObject, key); + scope.clearExceptionExceptTermination(); + for (unsigned i = 0; i < evict.size(); ++i) { + requireMap->remove(globalObject, evict.at(i)); if (scope.exception()) [[unlikely]] - scope.clearException(); + scope.clearExceptionExceptTermination(); } } evictedCjs = evict.size(); @@ -2871,6 +2882,191 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation *outEvictedCjs = evictedCjs; } +namespace IsolationFingerprint { + +struct Digest { + uint64_t h = 0xcbf29ce484222325ull; + ALWAYS_INLINE void mix(uint64_t x) + { + h ^= x; + h *= 0x100000001b3ull; + } +}; + +static ALWAYS_INLINE uint64_t valueBits(JSC::JSValue v) +{ + return std::bit_cast(JSC::JSValue::encode(v)); +} + +static void hashObject(Digest& d, JSC::VM& vm, JSC::JSGlobalObject* g, JSC::JSObject* obj) +{ + if (!obj) { + d.mix(0); + return; + } + // Reify before walking so forEachProperty sees a stable table regardless + // of which lazy static methods the test happened to touch. + if (!obj->staticPropertiesReified()) + obj->reifyAllStaticProperties(g); + d.mix(obj->structureID().bits()); + obj->structure()->forEachProperty(vm, [&](const JSC::PropertyTableEntry& entry) -> bool { + auto* key = entry.key(); + d.mix(key ? key->hash() : 0); + d.mix(entry.attributes()); + d.mix(valueBits(obj->getDirect(entry.offset()))); + return true; + }); +} + +static void hashCtorAndProto(Digest& d, JSC::VM& vm, JSC::JSGlobalObject* g, JSC::JSObject* proto) +{ + hashObject(d, vm, g, proto); + if (!proto) + return; + auto ctor = proto->getDirect(vm, vm.propertyNames->constructor); + if (ctor.isObject()) + hashObject(d, vm, g, asObject(ctor)); + else + d.mix(valueBits(ctor)); +} + +static void hashGlobalSlot(Digest& d, JSC::VM& vm, JSC::JSGlobalObject* g, const JSC::Identifier& name, bool walkObject) +{ + JSC::PropertySlot slot(g, JSC::PropertySlot::InternalMethodType::VMInquiry, &vm); + if (!g->getOwnPropertySlot(g, g, name, slot)) { + d.mix(1); + return; + } + d.mix(slot.attributes()); + if (slot.isAccessor()) { + d.mix(std::bit_cast(slot.getterSetter())); + } else if (slot.isCustom()) { + d.mix(2); + } else { + JSC::JSValue v = slot.getPureResult(); + d.mix(valueBits(v)); + if (walkObject && v.isObject()) + hashObject(d, vm, g, asObject(v)); + } +} + +} // namespace IsolationFingerprint + +extern "C" [[ZIG_EXPORT(nothrow)]] uint64_t Bun__computeIsolationFingerprint(JSC::JSGlobalObject* globalObject) +{ + using namespace IsolationFingerprint; + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* zigGlobal = defaultGlobalObject(globalObject); + + Digest d; + + hashObject(d, vm, globalObject, globalObject); + + auto hashCP = [&](JSC::JSObject* proto) { + hashCtorAndProto(d, vm, globalObject, proto); + }; + hashCP(globalObject->objectPrototype()); + hashCP(globalObject->functionPrototype()); + hashCP(globalObject->arrayPrototype()); + hashCP(globalObject->stringPrototype()); + hashCP(globalObject->numberPrototype()); + hashCP(globalObject->booleanPrototype()); + hashCP(globalObject->symbolPrototype()); + hashCP(globalObject->bigIntPrototype()); + hashCP(globalObject->datePrototype()); + hashCP(globalObject->regExpPrototype()); + hashCP(globalObject->errorPrototype()); + hashCP(globalObject->promisePrototype()); + hashCP(globalObject->mapPrototype()); + hashCP(globalObject->jsSetPrototype()); + hashCP(globalObject->arrayBufferPrototype(JSC::ArrayBufferSharingMode::Default)); + + auto hashByName = [&](WTF::ASCIILiteral name, bool walkObject = true) { + hashGlobalSlot(d, vm, globalObject, JSC::Identifier::fromString(vm, name), walkObject); + }; + hashByName("Math"_s); + hashByName("JSON"_s); + hashByName("Reflect"_s); + hashByName("console"_s); + hashByName("fetch"_s); + hashByName("WeakMap"_s); + hashByName("WeakSet"_s); + hashByName("EvalError"_s); + hashByName("RangeError"_s); + hashByName("ReferenceError"_s); + hashByName("SyntaxError"_s); + hashByName("TypeError"_s); + hashByName("URIError"_s); + // Identity only: these carry their own lazy static-table properties and + // reading them (e.g. `process.env`) would otherwise look like mutation. + hashByName("crypto"_s, false); + hashByName("Request"_s, false); + hashByName("Response"_s, false); + hashByName("Headers"_s, false); + hashByName("Buffer"_s, false); + hashByName("process"_s, false); + + if (zigGlobal->hasProcessObject()) + d.mix(zigGlobal->processObject()->wrapped().hasEventListeners() ? 3 : 0); + + if (scope.exception()) [[unlikely]] + scope.clearExceptionExceptTermination(); + return d.h; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__forEachModuleKeyForTestIsolation(JSC::JSGlobalObject* globalObject, void* ctx, void (*cb)(void* ctx, const BunString* key)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto* zigGlobal = defaultGlobalObject(globalObject); + + for (auto& [key, entry] : globalObject->moduleLoader()->moduleMap()) { + if (!key.first) + continue; + auto s = Bun::toString(String { key.first }); + cb(ctx, &s); + } + + auto* requireMap = zigGlobal->requireMap(); + auto iter = JSC::JSMapIterator::create(vm, globalObject->mapIteratorStructure(), requireMap, JSC::IterationKind::Keys); + if (scope.exception()) [[unlikely]] { + scope.clearExceptionExceptTermination(); + return; + } + JSC::JSValue keyValue; + while (iter->next(globalObject, keyValue)) { + if (!keyValue.isString()) + continue; + auto str = asString(keyValue)->value(globalObject); + if (scope.exception()) [[unlikely]] { + scope.clearExceptionExceptTermination(); + continue; + } + auto s = Bun::toString(str); + cb(ctx, &s); + } + if (scope.exception()) [[unlikely]] + scope.clearExceptionExceptTermination(); +} + +extern "C" [[ZIG_EXPORT(nothrow)]] bool Bun__hasPendingModuleFetches(JSC::JSGlobalObject* globalObject) +{ + for (auto& [key, entry] : globalObject->moduleLoader()->moduleMap()) { + if (entry && entry->status() == JSC::ModuleRegistryEntry::Status::Fetching) + return true; + } + return false; +} + +extern "C" [[ZIG_EXPORT(nothrow)]] bool Bun__takeTestIsolationModuleMockDirty(JSC::JSGlobalObject* globalObject) +{ + auto* zigGlobal = defaultGlobalObject(globalObject); + bool was = zigGlobal->testIsolationModuleMockDirty; + zigGlobal->testIsolationModuleMockDirty = false; + return was; +} + void JSC__VM__collectAsync(JSC::VM* vm) { JSC::JSLockHolder lock(*vm); @@ -6491,7 +6687,7 @@ extern "C" JSC::EncodedJSValue Bun__REPL__evaluate( *exception = JSC::JSValue::encode(evalException->value()); // Set _error on the globalObject directly (not globalThis proxy) globalObject->putDirect(vm, JSC::Identifier::fromString(vm, "_error"_s), evalException->value()); - scope.clearException(); + scope.clearExceptionExceptTermination(); return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -6499,7 +6695,7 @@ extern "C" JSC::EncodedJSValue Bun__REPL__evaluate( *exception = JSC::JSValue::encode(scope.exception()->value()); // Set _error on the globalObject directly (not globalThis proxy) globalObject->putDirect(vm, JSC::Identifier::fromString(vm, "_error"_s), scope.exception()->value()); - scope.clearException(); + scope.clearExceptionExceptTermination(); return JSC::JSValue::encode(JSC::jsUndefined()); } diff --git a/src/options_types/context.rs b/src/options_types/context.rs index ad3550028311..056e3ea80507 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -419,6 +419,9 @@ pub struct TestOptions { /// `bun test --isolate`: run each test file in a fresh global object on /// the same VM, force-closing leaked handles between files. pub isolate: bool, + /// `bun test --isolate=fresh-global`: force a fresh global per file even + /// when `BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL` is set. + pub isolate_force_fresh_global: bool, /// `bun test --parallel[=N]`: run test files across N worker /// processes. 0 means not requested. Implies `isolate` in workers. pub parallel: u32, @@ -497,6 +500,7 @@ impl Default for TestOptions { // overrides explicitly. max_concurrency: if bun_core::env::ENABLE_ASAN { 5 } else { 20 }, isolate: false, + isolate_force_fresh_global: false, parallel: 0, parallel_delay_ms: None, test_worker: false, diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 0a70ab0db1ee..f9403a507bc7 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -595,7 +595,7 @@ pub(crate) const TEST_ONLY_PARAMS: &[ParamType] = &[ "--changed ? Only run test files affected by changed files according to git. Optionally pass a commit or branch to compare against." ), parse_param!( - "--isolate Run each test file in a fresh global object. Leaked handles from one file cannot affect another." + "--isolate ? Run each test file in a fresh global object. Leaked handles from one file cannot affect another." ), parse_param!( "--parallel ? Run test files in parallel using N worker processes. Implies --isolate. Defaults to CPU core count." @@ -1785,7 +1785,18 @@ fn parse_test_command_options(args: &clap::Args, ctx: Context<'_>) { ctx.test_options.pass_with_no_tests = args.flag(b"--pass-with-no-tests"); ctx.test_options.concurrent = args.flag(b"--concurrent"); ctx.test_options.randomize = args.flag(b"--randomize"); - ctx.test_options.isolate = args.flag(b"--isolate"); + if let Some(isolate_val) = args.option(b"--isolate") { + ctx.test_options.isolate = true; + if isolate_val == b"fresh-global" { + ctx.test_options.isolate_force_fresh_global = true; + } else if !isolate_val.is_empty() { + bun_core::pretty_errorln!( + "error: --isolate expects no value or \"fresh-global\", received \"{}\"", + BStr::new(isolate_val) + ); + Global::exit(1); + } + } ctx.test_options.test_worker = args.flag(b"--test-worker"); if let Some(parallel_str) = args.option(b"--parallel") { diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 6af70b852c16..d58185fed6c6 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -8,7 +8,7 @@ use core::ptr::NonNull; use std::io::Write as _; use bun_core::{Global, Output}; -use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::virtual_machine::{TestIsolationOutcome, VirtualMachine}; use bun_options_types::context::MacroOptions; use bun_ptr::Interned; use bun_resolver::fs::FileSystem; @@ -276,6 +276,9 @@ fn build_worker_argv(ctx: &Command::ContextData) -> crate::Result { reporter: &'a mut CommandLineReporter, vm: *mut VirtualMachine, cmds: WorkerCommands, + next_is_first_on_global: bool, } impl<'a> WorkerLoop<'a> { @@ -533,6 +537,7 @@ impl<'a> WorkerLoop<'a> { let before = *self.reporter.summary(); let before_unhandled = self.reporter.jest.unhandled_errors_between_tests; + let reuse_enabled = vm.test_isolation_state.reuse.enabled; // Workers always run with --isolate; every file is its own // complete run from the preload's perspective. if let Err(err) = TestCommand::run( @@ -540,24 +545,29 @@ impl<'a> WorkerLoop<'a> { vm, self.cmds.pending_path.as_slice(), FirstLast { - first: true, - last: true, + first: !reuse_enabled || self.next_is_first_on_global, + last: !reuse_enabled, }, ) { test_command::handle_top_level_test_error_before_javascript_start(&err); } + self.next_is_first_on_global = false; crate::jsc_hooks::close_isolation_handles(vm); - if bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL::get() - .unwrap_or(false) - { - let _ = vm.reuse_global_for_test_isolation(); + if reuse_enabled { + if let TestIsolationOutcome::Fresh(_) = vm.prepare_global_for_next_test_file() { + self.next_is_first_on_global = true; + self.reporter + .jest + .bun_test_root + .reset_hook_scope_for_test_isolation(); + } } else { vm.swap_global_for_test_isolation(); + self.reporter + .jest + .bun_test_root + .reset_hook_scope_for_test_isolation(); } - self.reporter - .jest - .bun_test_root - .reset_hook_scope_for_test_isolation(); self.reporter.jest.default_timeout_override = u32::MAX; if let Some(junit) = &mut self.reporter.reporters.junit { @@ -591,6 +601,7 @@ impl<'a> WorkerLoop<'a> { } self.cmds.send(wf.finish()); } + test_command::emit_isolate_reuse_summary(vm); } } @@ -614,6 +625,9 @@ pub(crate) fn run_as_worker( let vm_ref = unsafe { &mut *vm }; vm_ref.test_isolation_enabled = true; vm_ref.auto_killer.enabled = true; + vm_ref.test_isolation_state.reuse.enabled = !ctx.test_options.isolate_force_fresh_global + && bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL::get() + .unwrap_or(false); // `vm.arena` is currently a write-only backref: the `MimallocArena.gc()` // reader was dropped from the GC path (see web_worker.rs, which wires its @@ -642,6 +656,7 @@ pub(crate) fn run_as_worker( pending_path: Vec::new(), done: false, }, + next_is_first_on_global: true, }; vm_ref.run_with_api_lock(|| wloop.begin()); diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 29535dbf19c5..1ebb34010d39 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -8,7 +8,7 @@ use bun_collections::{ArrayHashMap, BoundedArray, StringHashMap}; use bun_core::{self as bun, Global, Output, env_var, fmt as bun_fmt}; use bun_core::{pretty_error, pretty_errorln}; use bun_dotenv as DotEnv; -use bun_jsc::virtual_machine::VirtualMachine; +use bun_jsc::virtual_machine::{TestIsolationOutcome, VirtualMachine}; use bun_jsc::{self as jsc}; // `set_time_zone` / `delete_module_registry_entry` take the JSC-side // `ZigString` (repr(C)-identical to `bun_core::ZigString`, but with the @@ -28,6 +28,21 @@ use bun_sys::{self, Fd, File}; bun_output::declare_scope!(bun_test, hidden); bun_output::declare_scope!(test_isolate, hidden); +pub(crate) fn emit_isolate_reuse_summary(vm: &VirtualMachine) { + let r = &vm.test_isolation_state.reuse; + if !r.enabled || bun_core::getenv_z(bun_core::zstr!("BUN_DEBUG_test_isolate")).is_none() { + return; + } + bun_core::pretty_errorln!( + "[test_isolate] isolate reuse summary: {} reused, {} fresh ({} fingerprint, {} mock.module, {} pending-fetch)", + r.reused_count, + r.fresh_count(), + r.fresh_fingerprint_count, + r.fresh_module_mock_count, + r.fresh_pending_fetch_count, + ); +} + // ─── coverage façade ──────────────────────────────────────────────────────── // Thin adapter over `bun_sourcemap_jsc::code_coverage` that preserves the // legacy call paths used in `print_code_coverage` below (the adapter @@ -2320,6 +2335,9 @@ impl TestCommand { if ctx.test_options.isolate { vm.test_isolation_enabled = true; vm.auto_killer.enabled = true; + vm.test_isolation_state.reuse.enabled = !ctx.test_options.isolate_force_fresh_global + && bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL::get() + .unwrap_or(false); } if ctx.test_options.coverage.enabled { @@ -3099,20 +3117,26 @@ impl TestCommand { debug_assert!(!files.is_empty()); let isolate = vm.test_isolation_enabled; - let reuse_global = isolate - && bun_core::env_var::feature_flag::BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL::get() - .unwrap_or(false); - let mut reused_count: u32 = 0; + let reuse_global = vm.test_isolation_state.reuse.enabled; + // `first` = this file is the first to see its global. Under + // reuse it stays false after a reuse and flips back to true + // after a fresh-global fallback. + let mut next_is_first_on_global = true; if files.len() > 1 { for (i, file_name) in files[0..files.len() - 1].iter().enumerate() { + let first = if reuse_global { + core::mem::replace(&mut next_is_first_on_global, false) + } else { + isolate || i == 0 + }; if let Err(err) = TestCommand::run( reporter, vm, file_name.as_bytes(), bun_test::FirstLast { - first: isolate || i == 0, - last: isolate, + first, + last: isolate && !reuse_global, }, ) { handle_top_level_test_error_before_javascript_start(&err); @@ -3122,39 +3146,54 @@ impl TestCommand { if isolate { crate::jsc_hooks::close_isolation_handles(vm); if reuse_global { - let (esm, cjs) = vm.reuse_global_for_test_isolation(); - reused_count += 1; - bun_output::scoped_log!( - test_isolate, - "reused global (evicted {} esm, {} cjs)", - esm, - cjs - ); + match vm.prepare_global_for_next_test_file() { + TestIsolationOutcome::Reused { + evicted_esm, + evicted_cjs, + } => { + bun_output::scoped_log!( + test_isolate, + "reused global (evicted {} esm, {} cjs)", + evicted_esm, + evicted_cjs + ); + } + TestIsolationOutcome::Fresh(reason) => { + bun_output::scoped_log!( + test_isolate, + "fresh global ({})", + reason.as_str() + ); + next_is_first_on_global = true; + reporter + .jest + .bun_test_root + .reset_hook_scope_for_test_isolation(); + } + } } else { vm.swap_global_for_test_isolation(); + reporter + .jest + .bun_test_root + .reset_hook_scope_for_test_isolation(); } - reporter - .jest - .bun_test_root - .reset_hook_scope_for_test_isolation(); } } } - if reuse_global && reused_count > 0 { - bun_output::scoped_log!( - test_isolate, - "reused global for {} of {} files", - reused_count, - files.len() - ); - } + emit_isolate_reuse_summary(vm); + let last_first = if reuse_global { + next_is_first_on_global + } else { + isolate || files.len() == 1 + }; if let Err(err) = TestCommand::run( reporter, vm, files[files.len() - 1].as_bytes(), bun_test::FirstLast { - first: isolate || files.len() == 1, + first: last_first, last: true, }, ) { diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 95a8c581f503..67a535c2dce8 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -969,15 +969,22 @@ test.concurrent("--isolate: require(esm) caches a BunTranspiledModule SourceProv } }); + describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", () => { const reuseEnv = { ...bunEnv, BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL: "1", + BUN_DEBUG_test_isolate: "1", + BUN_DEBUG_QUIET_LOGS: undefined, + }; + const offEnv = { + ...bunEnv, + BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL: undefined, }; - async function run(dir: string, files: string[], env = reuseEnv) { + async function run(dir: string, args: string[], env: Record = reuseEnv) { await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--isolate", ...files], + cmd: [bunExe(), "test", ...args], env, cwd: dir, stderr: "pipe", @@ -987,59 +994,76 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( return { stdout, stderr, exitCode }; } - // This is the defining semantic: a dependency's JSModuleRecord (and with it - // any DFG/FTL CodeBlocks) survives across files, so its module-scope state - // is the same instance. Project-source modules are evicted and re-evaluate - // fresh. With a fresh global per file (flag off) the dep would ALSO be fresh, - // so file B would see `dep.touchedByA === undefined` and this test would fail. - test("node_modules records survive across files; project modules re-evaluate", async () => { - using dir = tempDir("isolate-reuse-dep", { - "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, - "node_modules/dep/index.js": ` - export const state = { loads: 0 }; - state.loads++; - export function hot(n) { let s = 0; for (let i = 0; i < n; i++) s += i; return s; } - `, - "project.ts": ` - export const state = { loads: 0 }; - state.loads++; - `, - "a.test.ts": ` - import { test, expect } from "bun:test"; - import { state as dep, hot } from "dep"; - import { state as proj } from "./project"; - test("a", () => { - expect(dep.loads).toBe(1); - expect(proj.loads).toBe(1); - dep.touchedByA = true; - proj.touchedByA = true; - expect(hot(1000)).toBe(499500); - }); - `, - "b.test.ts": ` - import { test, expect } from "bun:test"; - import { state as dep, hot } from "dep"; - import { state as proj } from "./project"; - test("b", () => { - // dep was NOT evicted: same record, same instance - expect(dep).toEqual({ loads: 1, touchedByA: true }); - // project.ts WAS evicted and re-evaluated: fresh instance - expect(proj).toEqual({ loads: 1 }); - expect(hot(1000)).toBe(499500); - }); - `, + function reuseSummary(stderr: string) { + const m = stderr.match( + /isolate reuse summary: (\d+) reused, (\d+) fresh \((\d+) fingerprint, (\d+) mock.module, (\d+) pending-fetch\)/, + ); + if (!m) throw new Error("no reuse summary in stderr:\n" + stderr); + return { + reused: Number(m[1]), + fresh: Number(m[2]), + fingerprint: Number(m[3]), + moduleMock: Number(m[4]), + pendingFetch: Number(m[5]), + }; + } + + // Defining semantic: a dependency's JSModuleRecord survives across files, + // so its module-scope state is the same instance. Project-source modules are + // evicted and re-evaluate fresh. With a fresh global per file (flag off) + // the dep would ALSO be fresh, so file B would see `dep.touchedByA` unset. + // --parallel=2 with a long scale-up delay makes worker 0 take both files + // (run_as_coordinator short-circuits to serial at k<=1, so --parallel=1 + // would not exercise the worker loop). + for (const [label, extra, extraEnv] of [ + ["", [], {}], + [" (--parallel worker)", ["--parallel=2"], { BUN_TEST_PARALLEL_SCALE_MS: "60000" }], + ] as const) { + test(`node_modules records survive across files; project modules re-evaluate${label}`, async () => { + using dir = tempDir("isolate-reuse-dep", { + "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, + "node_modules/dep/index.js": ` + export const state = { loads: 0 }; + state.loads++; + export function hot(n) { let s = 0; for (let i = 0; i < n; i++) s += i; return s; } + `, + "project.ts": ` + export const state = { loads: 0 }; + state.loads++; + `, + "a.test.ts": ` + import { test, expect } from "bun:test"; + import { state as dep, hot } from "dep"; + import { state as proj } from "./project"; + test("a", () => { + expect(dep.loads).toBe(1); + expect(proj.loads).toBe(1); + dep.touchedByA = true; + proj.touchedByA = true; + expect(hot(1000)).toBe(499500); + }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { state as dep, hot } from "dep"; + import { state as proj } from "./project"; + test("b", () => { + expect(dep).toEqual({ loads: 1, touchedByA: true }); + expect(proj).toEqual({ loads: 1 }); + expect(hot(1000)).toBe(499500); + }); + `, + }); + const { stderr, exitCode } = await run(String(dir), ["--isolate", ...extra, "./a.test.ts", "./b.test.ts"], { + ...reuseEnv, + ...extraEnv, + }); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(exitCode).toBe(0); }); - const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"]); - expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); - expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); - expect(exitCode).toBe(0); - }); + } - // Without the flag, today's --isolate gives every file a fresh global, so a - // dependency's module-scope state resets per file (loads === 1 each time). - // Run the same b.test.ts above without the flag: still passes, but for the - // opposite reason. The observable difference is the dep instance identity, - // which we assert here. test("flag off: dep module is a fresh instance per file", async () => { using dir = tempDir("isolate-reuse-off", { "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, @@ -1065,8 +1089,8 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( }); `, }); - for (const env of [reuseEnv, bunEnv]) { - const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"], env); + for (const env of [reuseEnv, offEnv]) { + const { stderr, exitCode } = await run(String(dir), ["--isolate", "./a.test.ts", "./b.test.ts"], env); expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); expect(exitCode).toBe(0); } @@ -1075,17 +1099,15 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( test("CJS: node_modules entry kept in require.cache; project entry evicted", async () => { using dir = tempDir("isolate-reuse-cjs", { "node_modules/cjsdep/package.json": `{"name":"cjsdep","main":"index.js"}`, - "node_modules/cjsdep/index.js": ` - module.exports.state = { loads: (global.__cjsdep_loads = (global.__cjsdep_loads ?? 0) + 1) }; - `, - "project.cjs": ` - module.exports.state = { loads: (global.__proj_loads = (global.__proj_loads ?? 0) + 1) }; - `, + "node_modules/cjsdep/index.js": `module.exports.state = { loads: 1 };`, + "project.cjs": `module.exports.state = { loads: 1 };`, "a.test.ts": ` import { test, expect } from "bun:test"; const dep = require("cjsdep"); const proj = require("./project.cjs"); test("a", () => { + dep.state.touchedByA = true; + proj.state.touchedByA = true; expect(dep.state.loads).toBe(1); expect(proj.state.loads).toBe(1); }); @@ -1095,55 +1117,131 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( const dep = require("cjsdep"); const proj = require("./project.cjs"); test("b", () => { - // kept: same module object, body not re-run - expect(dep.state.loads).toBe(1); - // evicted: body re-ran in the same global, counter is 2 - expect(proj.state.loads).toBe(2); + expect(dep.state).toEqual({ loads: 1, touchedByA: true }); + expect(proj.state).toEqual({ loads: 1 }); }); `, }); - const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"]); + const { stderr, exitCode } = await run(String(dir), ["--isolate", "./a.test.ts", "./b.test.ts"]); expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); expect(exitCode).toBe(0); }); - test("leaked timer, server and subprocess are still torn down between files", async () => { + test("leaked server, subprocess and timer are torn down on the reuse path", async () => { using dir = tempDir("isolate-reuse-cleanup", { + "node_modules/state/package.json": `{"name":"state","type":"module","main":"index.js"}`, + "node_modules/state/index.js": `export const s = {};`, "a.test.ts": ` import { test, expect } from "bun:test"; + import { s } from "state"; test("leak", async () => { const server = Bun.serve({ port: 0, fetch: () => new Response("hi") }); - (globalThis as any).leakedPort = server.port; - setInterval(() => { (globalThis as any).ticks = ((globalThis as any).ticks ?? 0) + 1; }, 5).unref(); - Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(()=>{},1e6)"], stdio: ["ignore","ignore","ignore"] }); + s.port = server.port; + s.ticks = 0; + setInterval(() => { s.ticks++; }, 5).unref(); + const child = Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(()=>{},1e6)"], stdio: ["ignore","ignore","ignore"] }); + s.pid = child.pid; expect(server.port).toBeGreaterThan(0); }); `, "b.test.ts": ` import { test, expect } from "bun:test"; + import { s } from "state"; test("cleaned", async () => { - // The global object is reused, so props set in file A are still visible - // (P0 spike: no pristine-global fallback yet). What we assert here is - // that the leaked *resources* are gone. - const port = (globalThis as any).leakedPort; - expect(typeof port).toBe("number"); - await expect(fetch("http://127.0.0.1:" + port)).rejects.toThrow(); + expect(s.port).toBeGreaterThan(0); + await expect(fetch("http://127.0.0.1:" + s.port)).rejects.toThrow(/Unable to connect|ECONNREFUSED|ConnectionRefused/); + expect(() => process.kill(s.pid, 0)).toThrow(/ESRCH|No such process|kill/); + const before = s.ticks; + await Bun.sleep(30); + expect(s.ticks).toBe(before); }); `, }); - const { stderr, exitCode } = await run(String(dir), ["./a.test.ts", "./b.test.ts"]); + const { stderr, exitCode } = await run(String(dir), ["--isolate", "./a.test.ts", "./b.test.ts"]); expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(reuseSummary(stderr)).toMatchObject({ reused: 1, fresh: 0 }); expect(exitCode).toBe(0); }); - // Preload modules live in project space, so the P0 spike evicts them like - // any other project module and load_preloads re-evaluates them per file in - // the reused global. "Preload once per global" is follow-up work (requires - // tracking resolved preload paths so the eviction pass can skip them); this - // test pins the current behaviour so that change is deliberate. - test("preload runs per file (P0: evicted like project modules)", async () => { + // Each pollution kind dirties the intrinsic fingerprint; the next file + // boundary detects it and falls back to a fresh global, so file B sees a + // clean world. The debug summary asserts exactly one fallback happened. + const cleanCheck = ` + import { test, expect } from "bun:test"; + test("clean", () => { + expect((globalThis as any).leaked).toBeUndefined(); + expect((Array.prototype as any).foo).toBeUndefined(); + expect(Math.random()).not.toBe(4); + expect(typeof console.log).toBe("function"); + expect(process.listenerCount("exit")).toBe(0); + }); + `; + for (const [name, pollute] of [ + ["globalThis property", `(globalThis as any).leaked = 1;`], + ["Array.prototype patch", `(Array.prototype as any).foo = () => 1;`], + ["Math.random replacement", `Math.random = () => 4;`], + ["console.log replacement", `console.log = () => {};`], + ["process.on listener", `process.on("exit", () => {});`], + ] as const) { + test(`pollution (${name}) in file A is not visible in file B`, async () => { + using dir = tempDir("isolate-reuse-fp", { + "clean.test.ts": cleanCheck, + "dirty.test.ts": ` + import { test, expect } from "bun:test"; + test("dirty", () => { ${pollute}; expect(1).toBe(1); }); + `, + "observe.test.ts": cleanCheck, + }); + const { stderr, exitCode } = await run(String(dir), [ + "--isolate", + "./clean.test.ts", + "./dirty.test.ts", + "./observe.test.ts", + ]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("3 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(reuseSummary(stderr)).toMatchObject({ reused: 1, fresh: 1, fingerprint: 1 }); + expect(exitCode).toBe(0); + }); + } + + test("a clean file pair reuses with no fallback", async () => { + using dir = tempDir("isolate-reuse-clean", { + "a.test.ts": cleanCheck, + "b.test.ts": cleanCheck, + "c.test.ts": cleanCheck, + }); + const { stderr, exitCode } = await run(String(dir), ["--isolate", "./a.test.ts", "./b.test.ts", "./c.test.ts"]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("3 pass"); + expect(reuseSummary(stderr)).toMatchObject({ reused: 2, fresh: 0 }); + expect(exitCode).toBe(0); + }); + + test("mock.module in file A forces a fresh global; file B sees the real module", async () => { + using dir = tempDir("isolate-reuse-mock", { + "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, + "node_modules/dep/index.js": `export const v = "real";`, + "a.test.ts": ` + import { test, expect, mock } from "bun:test"; + mock.module("dep", () => ({ v: "fake" })); + import { v } from "dep"; + test("a", () => { expect(v).toBe("fake"); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { v } from "dep"; + test("b", () => { expect(v).toBe("real"); }); + `, + }); + const { stderr, exitCode } = await run(String(dir), ["--isolate", "./a.test.ts", "./b.test.ts"]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(reuseSummary(stderr)).toMatchObject({ reused: 0, fresh: 1, moduleMock: 1 }); + expect(exitCode).toBe(0); + }); + + test("--preload runs once per global; runs again after a forced fallback", async () => { using dir = tempDir("isolate-reuse-preload", { "preload.ts": ` (globalThis as any).__preload_count = ((globalThis as any).__preload_count ?? 0) + 1; @@ -1154,19 +1252,64 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( `, "b.test.ts": ` import { test, expect } from "bun:test"; - test("b", () => { expect((globalThis as any).__preload_count).toBe(2); }); + test("b", () => { expect((globalThis as any).__preload_count).toBe(1); }); + `, + "c-dirty.test.ts": ` + import { test, expect } from "bun:test"; + test("c", () => { + expect((globalThis as any).__preload_count).toBe(1); + (globalThis as any).pollute = 1; + }); + `, + "d.test.ts": ` + import { test, expect } from "bun:test"; + test("d", () => { + // fresh global after c-dirty, preload re-ran exactly once + expect((globalThis as any).__preload_count).toBe(1); + expect((globalThis as any).pollute).toBeUndefined(); + }); `, }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--isolate", "--preload", "./preload.ts", "./a.test.ts", "./b.test.ts"], - env: reuseEnv, - cwd: String(dir), - stderr: "pipe", - stdout: "pipe", + const { stderr, exitCode } = await run(String(dir), [ + "--isolate", + "--preload", + "./preload.ts", + "./a.test.ts", + "./b.test.ts", + "./c-dirty.test.ts", + "./d.test.ts", + ]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("4 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(reuseSummary(stderr)).toMatchObject({ reused: 2, fresh: 1, fingerprint: 1 }); + expect(exitCode).toBe(0); + }); + + test("--isolate=fresh-global disables reuse even with the flag set", async () => { + using dir = tempDir("isolate-reuse-force-fresh", { + "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, + "node_modules/dep/index.js": `export const state = {};`, + "a.test.ts": ` + import { test, expect } from "bun:test"; + import { state } from "dep"; + test("a", () => { state.touched = true; expect(1).toBe(1); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + import { state } from "dep"; + test("b", () => { expect(state.touched).toBeUndefined(); }); + `, }); - const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const { stderr, exitCode } = await run(String(dir), ["--isolate=fresh-global", "./a.test.ts", "./b.test.ts"]); expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); - expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(stderr).not.toContain("isolate reuse summary"); expect(exitCode).toBe(0); }); + + test("--isolate rejects unknown values", async () => { + using dir = tempDir("isolate-reuse-badval", { "a.test.ts": `test("a", () => {});` }); + const { stderr, exitCode } = await run(String(dir), ["--isolate=nope", "./a.test.ts"]); + expect(stderr).toContain('expects no value or "fresh-global"'); + expect(exitCode).not.toBe(0); + }); }); From 009939738dbae5daa8ca62049fbfba8e856fc6e7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:25:52 +0000 Subject: [PATCH 5/9] [autofix.ci] apply automated fixes --- src/jsc/VirtualMachine.rs | 8 ++++++-- test/cli/test/isolation.test.ts | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index bc83322c382c..0cfc6720eae5 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4600,8 +4600,12 @@ impl VirtualMachine { // Reuse path with a captured baseline means preloads already ran // against this global; their module records are in the registry and // exempt from eviction, so skip load_preloads entirely. - let skip_preloads = - reuse && self.test_isolation_state.reuse.baseline_fingerprint.is_some(); + let skip_preloads = reuse + && self + .test_isolation_state + .reuse + .baseline_fingerprint + .is_some(); if !self.transpiler.options.disable_transpilation && !skip_preloads { if let Some(hooks) = runtime_hooks() { diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 67a535c2dce8..03d5b512c6bd 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -969,7 +969,6 @@ test.concurrent("--isolate: require(esm) caches a BunTranspiledModule SourceProv } }); - describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", () => { const reuseEnv = { ...bunEnv, From 8fc12e52265c1cbf3082b3032c8bc46264674a4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:40:59 +0000 Subject: [PATCH 6/9] guard MarkedArgumentBuffer overflow in the cjs eviction collect --- src/jsc/bindings/bindings.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index f0de946976d4..f56cf6ef1a8f 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2867,13 +2867,15 @@ extern "C" [[ZIG_EXPORT(nothrow)]] void Bun__evictProjectModulesForTestIsolation } if (scope.exception()) [[unlikely]] scope.clearExceptionExceptTermination(); - for (unsigned i = 0; i < evict.size(); ++i) { - requireMap->remove(globalObject, evict.at(i)); - if (scope.exception()) [[unlikely]] - scope.clearExceptionExceptTermination(); + if (!evict.hasOverflowed()) [[likely]] { + for (unsigned i = 0; i < evict.size(); ++i) { + requireMap->remove(globalObject, evict.at(i)); + if (scope.exception()) [[unlikely]] + scope.clearExceptionExceptTermination(); + } + evictedCjs = evict.size(); } } - evictedCjs = evict.size(); } if (outEvictedEsm) From 8b4094f3607b6be017f7bd9005b9ece14bfab2cc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:50:19 +0000 Subject: [PATCH 7/9] reuse: preserve preload setDefaultTimeout across reused files; bound the subprocess-dead poll default_timeout_override was reset to u32::MAX unconditionally after every file, but under reuse preloads don't re-run, so a preload's setDefaultTimeout() was lost for file 2+. Record the value when setDefaultTimeout is called during preload and restore it on the Reused outcome (both serial and parallel); reset to u32::MAX on Fresh / no reuse as before. Also bound the process.kill(pid, 0) assertion in the cleanup test with the same poll the sibling leaked-subprocesses test uses (auto_killer sends SIGTERM without waiting for reap). --- src/jsc/VirtualMachine.rs | 4 ++++ src/runtime/cli/test/parallel/runner.rs | 24 +++++++++++++------ src/runtime/cli/test_command.rs | 10 +++++++- src/runtime/test_runner/jest.rs | 5 ++++ test/cli/test/isolation.test.ts | 31 ++++++++++++++++++++++++- 5 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0cfc6720eae5..1611713ec599 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -355,6 +355,9 @@ pub struct TestIsolationReuseState { pub enabled: bool, /// Intrinsic digest taken after preloads; `None` until the first file completes. pub baseline_fingerprint: Option, + /// `setDefaultTimeout()` value set during preload, restored on reuse. + /// `None` when no preload called it (restore to `u32::MAX`). + pub baseline_default_timeout_override: Option, /// Module keys loaded while `vm.is_in_preload` was true; exempt from eviction. pub preload_module_keys: Vec, pub reused_count: u32, @@ -4942,6 +4945,7 @@ impl VirtualMachine { TestIsolationFreshReason::PendingFetch => reuse.fresh_pending_fetch_count += 1, } reuse.baseline_fingerprint = None; + reuse.baseline_default_timeout_override = None; while let Some(key) = reuse.preload_module_keys.pop() { key.deref(); } diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index d58185fed6c6..c6f2b7b841e5 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -554,21 +554,31 @@ impl<'a> WorkerLoop<'a> { self.next_is_first_on_global = false; crate::jsc_hooks::close_isolation_handles(vm); if reuse_enabled { - if let TestIsolationOutcome::Fresh(_) = vm.prepare_global_for_next_test_file() { - self.next_is_first_on_global = true; - self.reporter - .jest - .bun_test_root - .reset_hook_scope_for_test_isolation(); + match vm.prepare_global_for_next_test_file() { + TestIsolationOutcome::Reused { .. } => { + self.reporter.jest.default_timeout_override = vm + .test_isolation_state + .reuse + .baseline_default_timeout_override + .unwrap_or(u32::MAX); + } + TestIsolationOutcome::Fresh(_) => { + self.next_is_first_on_global = true; + self.reporter.jest.default_timeout_override = u32::MAX; + self.reporter + .jest + .bun_test_root + .reset_hook_scope_for_test_isolation(); + } } } else { vm.swap_global_for_test_isolation(); + self.reporter.jest.default_timeout_override = u32::MAX; self.reporter .jest .bun_test_root .reset_hook_scope_for_test_isolation(); } - self.reporter.jest.default_timeout_override = u32::MAX; if let Some(junit) = &mut self.reporter.reporters.junit { while !junit.suite_stack.is_empty() { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 1ebb34010d39..af85fb610a1a 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -3141,7 +3141,6 @@ impl TestCommand { ) { handle_top_level_test_error_before_javascript_start(&err); } - reporter.jest.default_timeout_override = u32::MAX; Global::mimalloc_cleanup(false); if isolate { crate::jsc_hooks::close_isolation_handles(vm); @@ -3157,6 +3156,11 @@ impl TestCommand { evicted_esm, evicted_cjs ); + reporter.jest.default_timeout_override = vm + .test_isolation_state + .reuse + .baseline_default_timeout_override + .unwrap_or(u32::MAX); } TestIsolationOutcome::Fresh(reason) => { bun_output::scoped_log!( @@ -3165,6 +3169,7 @@ impl TestCommand { reason.as_str() ); next_is_first_on_global = true; + reporter.jest.default_timeout_override = u32::MAX; reporter .jest .bun_test_root @@ -3172,12 +3177,15 @@ impl TestCommand { } } } else { + reporter.jest.default_timeout_override = u32::MAX; vm.swap_global_for_test_isolation(); reporter .jest .bun_test_root .reset_hook_scope_for_test_isolation(); } + } else { + reporter.jest.default_timeout_override = u32::MAX; } } } diff --git a/src/runtime/test_runner/jest.rs b/src/runtime/test_runner/jest.rs index 9d64a4d15733..b6aff0753106 100644 --- a/src/runtime/test_runner/jest.rs +++ b/src/runtime/test_runner/jest.rs @@ -507,6 +507,11 @@ pub mod Jest { if let Some(test_runner) = runner() { test_runner.default_timeout_override = timeout_ms; } + // SAFETY: single-threaded JS thread; `bun_vm_ptr` is the live per-thread VM. + let vm = unsafe { &mut *global_object.bun_vm_ptr() }; + if vm.is_in_preload && vm.test_isolation_state.reuse.enabled { + vm.test_isolation_state.reuse.baseline_default_timeout_override = Some(timeout_ms); + } Ok(JSValue::UNDEFINED) } diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index 03d5b512c6bd..d6b72e4d0893 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -1150,7 +1150,10 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( test("cleaned", async () => { expect(s.port).toBeGreaterThan(0); await expect(fetch("http://127.0.0.1:" + s.port)).rejects.toThrow(/Unable to connect|ECONNREFUSED|ConnectionRefused/); - expect(() => process.kill(s.pid, 0)).toThrow(/ESRCH|No such process|kill/); + // auto_killer only sends SIGTERM; allow the child to exit and be reaped. + const isAlive = pid => { try { process.kill(pid, 0); return true; } catch { return false; } }; + for (let i = 0; i < 50 && isAlive(s.pid); i++) await Bun.sleep(20); + expect(isAlive(s.pid)).toBe(false); const before = s.ticks; await Bun.sleep(30); expect(s.ticks).toBe(before); @@ -1284,6 +1287,32 @@ describe.concurrent("BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL", ( expect(exitCode).toBe(0); }); + test("--preload setDefaultTimeout() survives across reused files", async () => { + using dir = tempDir("isolate-reuse-timeout", { + "preload.ts": `import { setDefaultTimeout } from "bun:test"; setDefaultTimeout(30_000);`, + "a.test.ts": ` + import { test, expect } from "bun:test"; + test("a", async () => { await Bun.sleep(200); expect(1).toBe(1); }); + `, + "b.test.ts": ` + import { test, expect } from "bun:test"; + test("b", async () => { await Bun.sleep(200); expect(1).toBe(1); }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--isolate", "--timeout=50", "--preload", "./preload.ts", "./a.test.ts", "./b.test.ts"], + env: reuseEnv, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(normalizeBunSnapshot(stderr, dir)).toContain("2 pass"); + expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail"); + expect(reuseSummary(stderr)).toMatchObject({ reused: 1, fresh: 0 }); + expect(exitCode).toBe(0); + }); + test("--isolate=fresh-global disables reuse even with the flag set", async () => { using dir = tempDir("isolate-reuse-force-fresh", { "node_modules/dep/package.json": `{"name":"dep","type":"module","main":"index.js"}`, From de5a1a3f3be730a8cbd13e121d537047f7585713 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:03:41 +0000 Subject: [PATCH 8/9] ci: retrigger From 80b451848a5897228f83116ca64ec9fcdd521898 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 3 Aug 2026 09:17:55 +0000 Subject: [PATCH 9/9] fingerprint: exception check after reifyAllStaticProperties; mixed-sep node_modules on Windows hashObject now clears any exception left by reifyAllStaticProperties before continuing, matching the six other in-tree callers (the PropertyCallback chain on globalObject can open nested ThrowScopes that trip BUN_JSC_validateExceptionChecks otherwise). isKeptAcrossTestIsolation now matches /node_modules/ with either separator on each side on Windows, so a boundary-flip key like C:\proj\node_modules/zod/index.js is kept. --- src/jsc/bindings/bindings.cpp | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/bindings.cpp b/src/jsc/bindings/bindings.cpp index f56cf6ef1a8f..852274e1cd3e 100644 --- a/src/jsc/bindings/bindings.cpp +++ b/src/jsc/bindings/bindings.cpp @@ -2795,17 +2795,28 @@ void JSC__JSGlobalObject__deleteModuleRegistryEntry(JSC::JSGlobalObject* global, moduleLoader->removeEntry(identifier); } +static bool containsNodeModulesSegment(const WTF::String& key) +{ +#if OS(WINDOWS) + auto isSep = [](UChar c) { return c == '/' || c == '\\'; }; + size_t pos = 0; + while ((pos = key.findIgnoringASCIICase("node_modules"_s, pos)) != WTF::notFound) { + if (pos > 0 && isSep(key[pos - 1]) && pos + 12 < key.length() && isSep(key[pos + 12])) + return true; + pos += 12; + } + return false; +#else + return key.contains("/node_modules/"_s); +#endif +} + static ALWAYS_INLINE bool isKeptAcrossTestIsolation(const WTF::String& key, const BunString* skip, size_t skipLen) { if (key.isEmpty()) return false; -#if OS(WINDOWS) - if (key.containsIgnoringASCIICase("/node_modules/"_s) || key.containsIgnoringASCIICase("\\node_modules\\"_s)) - return true; -#else - if (key.contains("/node_modules/"_s)) + if (containsNodeModulesSegment(key)) return true; -#endif if (key.startsWith("node:"_s) || key.startsWith("bun:"_s) || key.startsWith("internal:"_s)) return true; for (size_t i = 0; i < skipLen; ++i) { @@ -2908,8 +2919,12 @@ static void hashObject(Digest& d, JSC::VM& vm, JSC::JSGlobalObject* g, JSC::JSOb } // Reify before walking so forEachProperty sees a stable table regardless // of which lazy static methods the test happened to touch. - if (!obj->staticPropertiesReified()) + if (!obj->staticPropertiesReified()) { + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); obj->reifyAllStaticProperties(g); + if (scope.exception()) [[unlikely]] + scope.clearExceptionExceptTermination(); + } d.mix(obj->structureID().bits()); obj->structure()->forEachProperty(vm, [&](const JSC::PropertyTableEntry& entry) -> bool { auto* key = entry.key();