Skip to content

bun test --isolate: experimental global-reuse path to keep dependency JIT code alive - #36810

Closed
robobun wants to merge 9 commits into
mainfrom
farm/19d6e50d/isolate-reuse-global
Closed

robobun wants to merge 9 commits into
mainfrom
farm/19d6e50d/isolate-reuse-global

Conversation

@robobun

@robobun robobun commented Aug 3, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Under --isolate, every test file gets a fresh JSGlobalObject, which means a fresh JSModuleLoader: every file re-links every dependency JSModuleRecord and re-creates every ModuleProgramExecutable / FunctionExecutable / CodeBlock chain. JSC then re-tiers every hot dependency function through LLInt/Baseline/DFG/FTL per file. On a 128-file suite that exercises zod/date-fns/lodash this is ~12s of DFG/FTL worklist CPU per run; the same suite on a shared global spends ~0.3s in JIT.

Change

Adds an opt-in BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL path. Default behaviour is unchanged.

Per-file boundary (prepare_global_for_next_test_file):

  1. Shared cleanup (cwd restore, microtask drain, socket-group close, close_isolation_handles, auto_killer.kill(), cancel_all_timers, generation bump, entry-point reset), factored into cleanup_between_isolated_test_files. node_quic_callbacks.deinit() stays on the fresh-global path only since its once-only initializer lives in a kept built-in module.
  2. Hard dirty checks: mock.module was called (per-global flag set in JSMock__jsModuleMock), or the loader has a Status::Fetching entry.
  3. Intrinsic fingerprint via Bun__computeIsolationFingerprint: FNV-1a over globalThis plus the constructor + prototype of Object/Function/Array/String/Number/Boolean/Symbol/BigInt/Date/RegExp/Error/Promise/Map/Set/ArrayBuffer, and Math/JSON/Reflect/console/fetch/WeakMap/WeakSet/the Error subclasses by slot value, plus crypto/Request/Response/Headers/Buffer/process identity, plus process listener presence. Each hashed object's static-table properties are reified first so a test merely reading Object.keys / JSON.stringify / Number.isInteger does not look like mutation; depth is the property table only, and VMInquiry is used so no getters run.
  4. Fingerprint match → evict project-source module records (anything not under node_modules/, not a node:/bun:/internal: builtin, and not in the baseline preload-key snapshot) from the ESM registry (removeEntry under cellLock) and require.cache (MarkedArgumentBuffer collect, non-string keys evicted outright). Mismatch or hard-dirty → fall back to the existing fresh-global swap, clear the baseline, and let the next load re-run preloads + re-baseline.

Preloads run once per global. reload_entry_point_for_test_runner skips load_preloads while a baseline is captured; the module keys present in the registry immediately after preloads run are snapshotted via Bun__forEachModuleKeyForTestIsolation and exempted from eviction. FirstLast.first tracks the per-global lifecycle so preload-level beforeAll fires once per global, and reset_hook_scope_for_test_isolation only runs on the fresh-global path (preload beforeEach/afterEach registered in hook_scope persist across reused files).

Escape hatch: --isolate now accepts an optional value; --isolate=fresh-global forces the fresh-global path regardless of the flag (forwarded to --parallel workers).

Diagnostics: BUN_DEBUG_test_isolate=1 emits a reused/fresh (fingerprint/mock.module/pending-fetch) summary line on stderr in release builds too, which the tests assert against.

Measurement

bench/test/app/setup.ts generates the 128-file zod/date-fns/lodash suite. Release build, 16 cores:

serial --isolate wall user CPU sys maxRSS
fresh global (today) 7.28s 15.50s 0.44s 155MB
reuse global (this PR) 4.42s 5.12s 0.21s 95MB
no --isolate (floor) 2.70s 2.98s 0.19s 80MB
--parallel (16 workers)
fresh global 1.52s 16.94s 1.24s 126MB
reuse global 0.88s 9.57s 0.92s 81MB

Serial reuse reports 127 reused, 0 fresh. User-CPU 15.5s → 5.1s: ~10.4s of JIT-worklist CPU eliminated. Wall 1.65x faster; RSS down ~40%. The gap to the shared-global floor is (a) the project-side hot.ts re-evaluating per file by design, and (b) the baseline reify making the core prototypes dictionary objects, which costs a little on inline caches; that second part is a known tuning target.

Scope

Opt-in behind a feature flag; default behaviour unchanged. Still to come:

  • Taint graph: a node_modules package that transitively imports a project module is currently kept.
  • spyOn restore.
  • Preload afterAll on a mid-run fresh-global fallback: the outgoing global's preload afterAll is not run (serial runs fire it once at the end; workers never fire it).
  • Default flip and docs.

Open questions for before any default flip: whether dep module-level state shared across files is the right default --isolate semantics, and whether to also cap numberOfDFGCompilerThreads/numberOfFTLCompilerThreads in --test-worker processes.

Tests

test/cli/test/isolation.test.ts gains a 15-case BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL describe:

  • node_modules record identity survives across files; project module re-evaluates fresh (serial and --parallel=2 worker)
  • flag off: dep module is a fresh instance per file (both arms pin the env var explicitly)
  • CJS: node_modules kept in require.cache, project .cjs evicted
  • leaked server/subprocess/timer torn down on the reuse path (asserts connection-refused, ESRCH, and that the interval stops ticking)
  • pollution in file A is not visible in file B, one case each for globalThis.x, Array.prototype.foo, Math.random, console.log, process.on('exit', ...), each asserting exactly one fingerprint fallback
  • a clean file triple reuses with zero fallbacks
  • mock.module in A forces a fresh global; B sees the real module
  • --preload runs once per global; runs again after a forced fallback
  • --isolate=fresh-global disables reuse; --isolate=<unknown> is rejected

[review] gate passed · iteration 1 · 15 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/test/isolation.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/161] gen JS modules (bundle-modules)
Preprocess modules (8521ms)
Bundle modules (33ms)
Postprocesss modules (27ms)
Bundle Functions (718ms)
Generate Code (11ms)

[9.33s] Bundled "src/js" for development
  2749 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[1/160] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

[82/160] cxx obj/src/jsc/bindings/napi.cpp.o
FAILED: obj/src/jsc/bindings/napi.cpp.o 
/usr/bin/ccache /usr/lib/llvm-21/bin/clang++ -march=nehalem -O0 -g3 -gz=zstd -glldb -fsanitize=address -fno-exceptions -fno-c++-static-destructors -fno-rtti -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -fvisibility=hidden -fvisibility-inlines-hidden -fno-unwind-tables -fno-asynchronous-unwind-tables -Wno-c23-extensions -ffunction-sections -fdata-sections -faddrsig -fno-semantic-interposit
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (47caca00b)

test/cli/test/isolation.test.ts:
(pass) bun test --isolate > without --isolate, leaked global is visible to next file [40.52ms]
(pass) bun test --isolate > with --isolate, each file gets a fresh global [39.62ms]
(pass) bun test --isolate > with --isolate, --preload re-runs in each file's fresh global [39.99ms]
(pass) bun test --isolate > without --isolate, --preload still runs once (regression) [39.66ms]
(pass) bun test --isolate > with --isolate, module state is not shared between files [39.76ms]
(pass) --isolate: cached module_info handles `import * as ns; export { ns }` as a Namespace export [25.08ms]
(pass) --isolate: SourceProvider cache covers CommonJS modules [32.97ms]
(pass) --isolate: delete require.cache evicts the SourceProvider cache [34.97ms]
(pass) --isolate: SourceProvider cache covers node_modules .mjs and type:commonjs packages [32.49ms]
(pass) --isolate: cached SourceProvider's module_info rebuilds correct exports [32.76ms]
(pass) BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL > node_modules records survive across files; project modules re-evaluate [15.53ms]
(pass) BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_IS
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/test/isolation.test.ts
bun test v1.4.0 (de5a1a3f3)

test/cli/test/isolation.test.ts:
(pass) bun test --isolate > without --isolate, leaked global is visible to next file [340.09ms]
(pass) bun test --isolate > with --isolate, each file gets a fresh global [343.43ms]
(pass) bun test --isolate > without --isolate, --preload still runs once (regression) [434.03ms]
(pass) bun test --isolate > with --isolate, --preload re-runs in each file's fresh global [477.88ms]
(pass) bun test --isolate > with --isolate, module state is not shared between files [328.50ms]
(pass) bun test --isolate > with --isolate, leaked outbound socket is closed before next file [2090.06ms]
(pass) bun test --isolate > with --isolate, a file's process.chdir() is undone before the next file [2636.84ms]
(pass) bun test --isolate > with --isolate, leaked vi.useFakeTimers() is deactivated before next file [2367.67ms]
(pass) bun test --isolate > with --isolate, leaked fs.watch is closed before next file [2563.29ms]
(pass) bun test --isolate > leaked subprocesses are
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 681ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/122] gen cpp.rs (cppbind)
[2/122] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[3/122] gen JS modules (bundle-modules)
Preprocess modules (8778ms)
Bundle modules (33ms)
Postprocesss modules (270ms)
Bundle Functions (765ms)
Generate Code (32ms)

[9.89s] Bundled "src/js" for production
  2558 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[3/122] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m 
... (truncated)
diff hotspot
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                 |   1 +
 src/jsc/VirtualMachine.rs               | 191 +++++++++++++++-
 src/jsc/bindings/BunPlugin.cpp          |   1 +
 src/jsc/bindings/ZigGlobalObject.h      |   2 +
 src/jsc/bindings/bindings.cpp           | 282 +++++++++++++++++++++++-
 src/options_types/context.rs            |   4 +
 src/runtime/cli/Arguments.rs            |  15 +-
 src/runtime/cli/test/parallel/runner.rs |  49 ++++-
 src/runtime/cli/test_command.rs         |  91 +++++++-
 src/runtime/test_runner/jest.rs         |   5 +
 test/cli/test/isolation.test.ts         | 373 ++++++++++++++++++++++++++++++++
 15 files changed, 1101 insertions(+), 35 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                     reads  edits  tests
bench/test/README.md                         1      2      0
bench/test/app/setup.ts                      0      1      0
bench/test/bun.lock                          0      0      0
bench/test/package.json                      1      1      0
src/bun_core/env_var.rs                      2      4      0
src/jsc/VirtualMachine.rs                   17     28      0
src/jsc/bindings/BunPlugin.cpp               2      1      0
src/jsc/bindings/ZigGlobalObject.h           1      1      0
src/jsc/bindings/bindings.cpp                9     34      0
src/options_types/context.rs                 2      2      0
src/runtime/cli/Arguments.rs                 1      1      0
src/runtime/cli/test/parallel/runner.rs      5      9      0
src/runtime/cli/test_command.rs             10     14      0
src/runtime/test_runner/jest.rs              1      3      0
test/cli/test/isolation.test.ts              6     12      0

… 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.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. bun test --isolate: module records retained across per-file global swaps → linear RSS growth (OOMs large suites); still present in 1.3.14 #31771 - The PR's global-reuse path eliminates linear RSS growth by reusing a single JSGlobalObject and selectively evicting only project-source modules, so dependency module records are loaded once rather than duplicated per-file and never reclaimed.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #31771

🤖 Generated with Claude Code

@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Not adding Fixes #31771. That issue's repro is a 1000-module project graph (not node_modules/), so even under this PR's reuse path every one of those records is evicted per file and the fix is incidental at best; and this path is opt-in behind a feature flag so default --isolate RSS is unchanged. #31771 needs the fresh-global swap path to actually release the outgoing global's module graph, which is separate work.

Comment thread src/bun_core/env_var.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread test/cli/test/isolation.test.ts Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
@coderabbitai

coderabbitai Bot commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2d457010-fff4-49f8-bab5-9c5b92b81478

📥 Commits

Reviewing files that changed from the base of the PR and between 0099397 and 80b4518.

📒 Files selected for processing (6)
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/bindings.cpp
  • src/runtime/cli/test/parallel/runner.rs
  • src/runtime/cli/test_command.rs
  • src/runtime/test_runner/jest.rs
  • test/cli/test/isolation.test.ts

Walkthrough

Changes

The test runner adds experimental VM-global reuse for isolated test files. It evicts project modules, preserves dependencies, detects pollution, supports forced fresh globals, and adds serial, parallel, diagnostic, and benchmark coverage.

Test isolation global reuse

Layer / File(s) Summary
Isolation options and state contracts
src/bun_core/env_var.rs, src/options_types/context.rs, src/runtime/cli/Arguments.rs, src/jsc/VirtualMachine.rs
Adds the experimental feature flag, --isolate=fresh-global, and VM state for reuse baselines, preload keys, counters, and fallback reasons.
VM cleanup and reuse preparation
src/jsc/VirtualMachine.rs
Shares per-file cleanup, skips repeated preloads, captures fingerprints, and chooses reuse or fresh-global isolation.
Module cache and fingerprint bindings
src/jsc/bindings/*
Adds project ESM/CommonJS eviction, module-key enumeration, fingerprinting, pending-fetch detection, and module-mock tracking.
Serial and parallel runner integration
src/runtime/cli/test_command.rs, src/runtime/cli/test/parallel/runner.rs
Selects reuse, handles fresh-global fallbacks, resets hook scopes, forwards forced-fresh mode, and emits summaries.
Isolation validation and benchmarks
test/cli/test/isolation.test.ts, bench/test/*
Tests module retention, cleanup, pollution, mocks, preloads, parallel execution, forced-fresh mode, invalid values, and benchmark comparisons.

Possibly related PRs

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the experimental global-reuse path for bun test --isolate.
Description check ✅ Passed The description explains the problem, implementation, verification, benchmarks, scope, tests, and default behavior, although its headings differ from the template.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@bench/test/app/setup.ts`:
- Around line 11-13: Make the fresh benchmark command explicitly unset
BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL in
bench/test/app/setup.ts lines 11-13 and bench/test/README.md lines 45-48 before
invoking hyperfine; leave the reuse command explicitly setting the flag to 1.
- Around line 34-66: Update the workload setup in hot by defining descriptive
constants for the row count, name modulus, date modulus, chunk size, row-index
mask, and millisecond mask, then replace the repeated literals in row generation
and accumulation with those constants. Keep the row count and index-mask
relationship synchronized so rows[k & mask] remains within the generated rows.
- Around line 19-24: Validate the FILES and ITERS environment values before the
rmSync(root, ...) side effect, distinguishing unset, empty, and zero explicitly.
Reject NaN, non-finite, fractional, negative, and unsafe integer values, and
enforce an appropriate finite upper bound for each resource before continuing to
mkdirSync or generate tests; preserve the existing defaults for unset values.
- Around line 9-16: Update the benchmark commands in bench/test/app/setup.ts
lines 9-16 and bench/test/README.md lines 43-49 to build once with bun bd, then
run every hyperfine sample using build/debug/bun-debug without rebuilding inside
the measured commands. Ensure the fresh-global sample explicitly clears
BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL, while the reuse-global
sample sets it.

In `@bench/test/package.json`:
- Around line 6-9: Pin the benchmark dependencies in bench/test/package.json by
removing the caret ranges from date-fns, lodash-es, vitest, and zod, or commit a
frozen lockfile that guarantees these exact resolved versions for reproducible
benchmark runs.

In `@bench/test/README.md`:
- Line 56: Update the wording in the README sentence so the compound modifier
uses a hyphen: change “dependency optimized code” to “dependency-optimized
code.”

In `@src/jsc/bindings/bindings.cpp`:
- Around line 2827-2842: Update the module eviction block to acquire
moduleLoader->cellLock() before iterating over moduleLoader->moduleMap(), and
keep that lock held through collecting identifiers and calling removeEntry.
Remove the narrower lock scope so the entire traversal and removal sequence is
synchronized.
- Around line 2853-2859: Update the requireMap iteration around keyValue and
keyString to detect non-string keys before calling toWTFString, explicitly
remove those entries from requireMap, and continue without conversion. Preserve
normal string-key handling while ensuring Symbol keys do not remain after the
isolation cleanup.

In `@src/jsc/VirtualMachine.rs`:
- Around line 4857-4859: Update cleanup_between_isolated_test_files and both
isolation transition methods, including reuse_global_for_test_isolation, to
propagate set_process_cwd restoration errors instead of continuing after
saved_cwd is consumed. Change the transition results and serial/parallel callers
so a cleanup failure aborts the file transition and is returned to the caller.

In `@test/cli/test/isolation.test.ts`:
- Around line 978-987: Add a deterministic parallel-worker test case near the
local run helper in isolation.test.ts by invoking the existing run flow with
--parallel=1 and multiple files. Assert both dependency retention and
project-module eviction within the reused worker, covering the parallel runner
branch while preserving the existing serial-path coverage.
- Around line 1111-1139: Strengthen the “leaked timer, server and subprocess are
still torn down between files” test by storing observable handles or state for
the interval and spawned process in `a.test.ts`, then asserting in `b.test.ts`
that the interval no longer executes and the child process has terminated,
alongside the existing server check. Ensure each assertion can fail and verifies
actual resource cleanup rather than only global-property persistence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0e0fce00-0786-4e20-a6f7-fd03fdea2071

📥 Commits

Reviewing files that changed from the base of the PR and between 52af832 and 86b8b4b.

⛔ Files ignored due to path filters (1)
  • bench/test/bun.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • bench/test/README.md
  • bench/test/app/setup.ts
  • bench/test/package.json
  • src/bun_core/env_var.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/bindings.cpp
  • src/runtime/cli/test/parallel/runner.rs
  • src/runtime/cli/test_command.rs
  • test/cli/test/isolation.test.ts

Comment thread bench/test/app/setup.ts Resolved
Comment thread bench/test/app/setup.ts Resolved
Comment thread bench/test/app/setup.ts Resolved
Comment thread bench/test/app/setup.ts Resolved
Comment thread bench/test/package.json Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread test/cli/test/isolation.test.ts Resolved
Comment thread test/cli/test/isolation.test.ts Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread test/cli/test/isolation.test.ts Resolved
Comment thread test/cli/test/isolation.test.ts Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
…resh-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).
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/options_types/context.rs Resolved
Comment thread src/runtime/cli/test_command.rs Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/jsc/bindings/bindings.cpp`:
- Around line 2848-2876: Check evict.hasOverflowed() after collecting entries
and before calling evict.size() or iterating with evict.at(i). If it has
overflowed, stop the CommonJS eviction path, clear or handle the pending state
consistently, and set evictedCjs to zero; otherwise preserve the existing
removal loop and count.

In `@src/runtime/cli/Arguments.rs`:
- Line 598: Update the --isolate help text in the CLI argument definitions to
document the optional fresh-global value, including what --isolate=fresh-global
does, while preserving the existing no-value behavior description and matching
the style used by --bail and --changed.

In `@src/runtime/cli/test/parallel/runner.rs`:
- Around line 556-563: Update the worker reuse transition around
prepare_global_for_next_test_file to match the serial path: match both
TestIsolationOutcome::Reused and TestIsolationOutcome::Fresh, and emit the
corresponding bun_output::scoped_log!(test_isolate, ...) diagnostics with
evicted ESM/CJS counts or the fallback reason. Preserve the existing Fresh
handling that updates next_is_first_on_global and resets the hook scope.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7bd8bd47-45da-48f0-bb21-2587c3385f17

📥 Commits

Reviewing files that changed from the base of the PR and between 86b8b4b and 0099397.

📒 Files selected for processing (11)
  • bench/test/README.md
  • src/bun_core/env_var.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunPlugin.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/bindings/bindings.cpp
  • src/options_types/context.rs
  • src/runtime/cli/Arguments.rs
  • src/runtime/cli/test/parallel/runner.rs
  • src/runtime/cli/test_command.rs
  • test/cli/test/isolation.test.ts
💤 Files with no reviewable changes (1)
  • src/bun_core/env_var.rs

Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/runtime/cli/Arguments.rs Resolved
Comment thread src/runtime/cli/test/parallel/runner.rs Resolved
Comment thread test/cli/test/isolation.test.ts Resolved
Comment thread src/runtime/cli/test/parallel/runner.rs Resolved
Comment thread src/runtime/cli/test_command.rs Resolved
…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).
Comment thread src/jsc/VirtualMachine.rs Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
Comment thread src/jsc/bindings/bindings.cpp Resolved
…p 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.
@robobun

robobun commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI at 80b4518: the three red lanes are all unrelated to this diff.

  • test/js/node/worker_threads/worker-transfer-terminate-stress.test.ts SIGABRT on debian x64-asan: ExceptionScope::assertNoException() in a worker-terminate stress test. This PR's only new exception-scope code is inside Bun__computeIsolationFingerprint, which is only reachable under BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL; the stress test doesn't set that flag. The test is already listed in test/no-validate-leaksan.txt.
  • test/cli/install/migration/complex-workspace.test.ts on Windows (flaky, install-failed cascade).
  • test/cli/install/bun-add.test.ts SCP-style Git URL on debian aarch64 (flaky, git@github.com: Permission denied (publickey)).

test/cli/test/isolation.test.ts (the file this PR actually changes) passed on all lanes. Diff is green; ready for review.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new findings this pass; the earlier inline comments (subprocess-poll flake, preload setDefaultTimeout reset, reifyAllStaticProperties exception check, Windows mixed-separator node_modules) were all addressed in 8b4094f and 80b4518. Deferring to a human for the design/semantics questions the PR itself flags — this is ~1100 lines across the JSC module loader, global-object fingerprinting, and test-runner lifecycle, and the "dep module-level state shared across files" semantic is an explicit open question.

What was reviewed:

  • Bun__evictProjectModulesForTestIsolation / Bun__computeIsolationFingerprint: exception-scope handling, MarkedArgumentBuffer overflow, cellLock around removeEntry, VMInquiry so no user getters run.
  • prepare_global_for_next_test_file / cleanup_between_isolated_test_files refactor: node_quic_callbacks.deinit() correctly kept fresh-global-only; preload_module_keys are .deref()'d on the fresh path.
  • Serial vs --parallel worker parity for FirstLast, default_timeout_override restore, and reset_hook_scope gating.
Extended reasoning...

Overview

Adds an opt-in BUN_FEATURE_FLAG_EXPERIMENTAL_TEST_ISOLATE_REUSE_GLOBAL path to bun test --isolate that keeps the JSGlobalObject alive across test files (evicting only project-source module records) so dependency JIT code survives. ~1100 lines across 15 files: ~280 lines of new C++ in bindings.cpp (module-registry eviction, intrinsic fingerprinting via structure/property-table walk, module-key iteration), ~190 lines in VirtualMachine.rs (state struct, prepare_global_for_next_test_file, refactored cleanup split), test-runner lifecycle threading in test_command.rs / runner.rs, a new --isolate=<value> CLI arg, and 373 lines of tests plus a benchmark fixture.

Security risks

None identified. The new code runs only under bun test --isolate with an experimental env flag set, operates on the runner's own module registry and global object, and uses VMInquiry/getPureResult so the fingerprint pass doesn't invoke user getters.

Level of scrutiny

High. This touches JSC module-loader internals (removeEntry under cellLock, JSMapIterator over requireMap), walks JSC Structure property tables, calls reifyAllStaticProperties on the global object and ~30 prototypes, and reshapes the per-file lifecycle (FirstLast, preload-once, hook-scope reset gating) across two entry points. The default path is unchanged and everything new is behind an experimental flag, which lowers blast radius — but the refactor of swap_global_for_test_isolation into cleanup_between_isolated_test_files + swap_global_for_test_isolation_inner affects the non-flag path too and warrants human eyes.

Other factors

Two prior automated review rounds surfaced five issues (test flake, setDefaultTimeout snapshot/restore, worker afterAll gap, reifyAllStaticProperties exception check, Windows node_modules separator handling), all of which the author addressed or explicitly scoped out in the PR description. The PR itself lists open design questions ("whether dep module-level state shared across files is the right default --isolate semantics") and known follow-ups (taint graph, spyOn restore, worker preload-afterAll) that a maintainer should weigh in on before this lands. Test coverage is thorough (15 cases including the negative flag-off arm and --parallel worker), and the ASAN/release gate output in the PR body shows both passing.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #36871, which implements the same opt-in global reuse for bun test --isolate. One implementation of this path is enough. #36871 is smaller, has maintainer feedback applied, and is rebased on current main. The mock.module() hard fallback from this PR is folded into it (a8c6d37). The benchmark app is already on main via #36814.

@robobun robobun closed this Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants