bun test --isolate: kill module-scope subprocesses of every file at the isolation swap - #38750
Conversation
|
Warning Review limit reached
Next review available in: 39 seconds Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: reproduced and fixed; narrowed after self-review. Ready for review. Reproduced on the released build (serial The diff is the CI: the first build (96883) passed. The second build's only red job was the android |
There was a problem hiding this comment.
LGTM — small, targeted fix that makes files 2+ match the already-correct behavior of file 1 under --isolate.
What was reviewed:
on_group_completednow skipsdisable()under isolation; verifiedon_group_startedis idempotent and the swap's kill+clear (VirtualMachine.rs:5057-5058) drains the set between files, so it can't grow unbounded.enable()vsenabled = trueat startup: confirmed the difference isever_enabled, which gateson_subprocess_exitremoval — so first-file module-scope spawns that exit on their own now get pruned immediately instead of lingering until the first group runs.- spawnSync fast-path gate at
js_bun_spawn_bindings.rs:1049: module-scopespawnSyncin files 2+ now takes the event-loop path, matching file 1 — no behavior change, just consistency. - Test follows the established scale-up-gated
--parallelpattern already used twice in this file;finallycleanup covers both the last file's sleeper and anything an unfixed build leaks.
Extended reasoning...
Overview
The PR fixes a gap in bun test --isolate's subprocess cleanup: subprocesses spawned at module scope (top-level, outside any test/hook) were only tracked for the first file of the run, because Execution::on_group_completed unconditionally called auto_killer.disable() after every test group, and nothing re-enabled it before the next file's module scope evaluated. The fix is a 3-line guard on vm.test_isolation_enabled in on_group_completed, plus swapping two enabled = true field writes for enable() calls (which also sets ever_enabled). A new order-independent test in isolation.test.ts covers both the serial --isolate and single-worker --parallel paths.
Security risks
None. This is test-runner lifecycle bookkeeping — no auth, crypto, parsing of untrusted input, or user-facing API surface. The only side effect is that more leaked subprocesses get SIGTERM'd at file boundaries, which is the documented contract.
Level of scrutiny
Low-to-moderate. The Rust change is ~10 lines and mechanical: gate an existing call on an existing flag, and use the existing enable() helper instead of a raw field write. I traced the state machine (startup → module scope → group start/end → swap) and verified the fix produces the intended enabled-for-the-whole-run behavior under isolation while leaving the non-isolate per-group toggling unchanged. The per-file swap (VirtualMachine.rs:5057-5058) still kills and clears the set, so there's no unbounded growth. I also checked the one other reader of auto_killer.enabled (the spawnSync blocking fast-path gate) — under isolation it now consistently routes module-scope spawnSync through the event-loop path for every file instead of just the first, which the PR description calls out as a no-behavior-change alignment.
Other factors
- The
ever_enabledchange is a subtle correctness improvement:on_subprocess_exitis gated onever_enabled, so before this PR a module-scope process that exited on its own during file 1 (before any group had calledenable()) would sit in the set with a held ref until the swap. Harmless (kill() checkshas_exited()), but now it's removed promptly. - The new test reuses the scale-up-gated
--parallelfixture pattern already established in two other tests in this file. It uses bounded polling (2s deadline) rather than fixed sleeps, asserts on the specific surviving PIDs, and has afinallyblock that kills any leaked sleepers so a regression doesn't poison later tests on persistent runners. - The existing sibling test "leaked subprocesses are killed for every isolated file, not just the first" (spawns from inside tests) is unchanged and still covered; the new test specifically targets the module-scope window.
- No prior human or bot review comments to address; CI is building.
|
Heads up from a neighbouring change: #38774 makes the timeout kill in |
c3cac3b to
c0cd324
Compare
…ule-scope spawns of every file are killed at the swap --isolate enables the auto killer once at startup, but Execution::on_group_completed disabled it after every group, so it was off while the module scope of the second and later files ran. Spawns made there were never registered and survived the isolation swap; only the first file's module-scope spawns were killed. Skip the per-group disable() when test isolation is enabled. The swap already kills and clears the tracked set between files. A test timeout under --isolate now also kills the current file's module-scope spawns in every file, which is what already happened in the first file.
c0cd324 to
97a895c
Compare
There was a problem hiding this comment.
LGTM. The PR description now folds the dropped enable() hunks into an "Earlier revision" note, so my prior nit about the description/diff mismatch is resolved.
What was reviewed:
on_group_completedgatingdisable()on!vm.test_isolation_enabled— verified the flag is set at both startup sites (test_command.rs:2317, parallel/runner.rs:637) and the swap at VirtualMachine.rs:5057 does the kill+clear the comment claims.- Non-isolate path unchanged: per-group
disable()still runs, and the per-fileclear()+disable()at test_command.rs:3396 is already gated on!test_isolation_enabled. - New test follows the file's existing
--parallel=2+BUN_TEST_PARALLEL_SCALE_MSone-worker pattern, polls with a deadline, and cleans up leaked pids (including the last file's un-swapped sleeper) infinally.
Extended reasoning...
Overview
Four-line change in src/runtime/test_runner/Execution.rs: on_group_completed now skips auto_killer.disable() when vm.test_isolation_enabled is set, so subprocess tracking stays on between execution groups under --isolate and module-scope spawns of files 2+ are registered for the isolation swap's kill. A new test.each case in test/cli/test/isolation.test.ts covers serial --isolate and a single --parallel worker.
Security risks
None. This is test-runner subprocess lifecycle bookkeeping — it changes when an existing SIGTERM sweep applies, not what it targets or how.
Level of scrutiny
Low-to-medium. The Rust change is a one-condition policy adjustment in a per-group callback; I traced test_isolation_enabled to confirm it is set exactly at the two startup sites the description names and read by swap_global_for_test_isolation, which does auto_killer.kill(); auto_killer.clear() — matching the new one-line comment. The non---isolate path is byte-identical to before. The stated side effects (timeout kill scope in files 2+, spawnSync fast path at js_bun_spawn_bindings.rs:1049) are real but make later files match file 1's existing behaviour, and #38774 is separately narrowing the timeout kill.
Other factors
- My earlier inline comment (description mentioned
enable()calls that weren't in the diff) was addressed: the description was rewritten with an "Earlier revision" details block explaining those hunks were dropped after self-review. The comment-cop note about the paragraph-long comment was also addressed (97a895c shortened it to one line). - The new test reuses the file's established one-worker-takes-all-files trick (
--parallel=2+BUN_TEST_PARALLEL_SCALE_MS: 60000), polls with a 2s deadline instead of sleeping, asserts three pids were logged so a partial run can't vacuously pass, and itsfinallyreaps every logged pid so an unfixed runner (or the last file's un-swapped sleeper) doesn't leak into CI. - robobun confirmed both test variants fail on the released build with exactly the second file's pid surviving and pass on this branch, and that
test-timeout-behavior.test.tsand the rest ofisolation.test.tsstill pass.
There was a problem hiding this comment.
LGTM — one-condition gate matching the already-documented intent in test_command.rs ("need tracking to remain enabled and populated until then").
What was reviewed:
- Confirmed
vm.test_isolation_enabledis set at both startup sites (serial--isolateand parallel worker) and read by the swap atVirtualMachine.rs:5057for kill+clear. - Non-isolate path unchanged:
disable()still runs per group, and per-fileclear()/disable()attest_command.rs:3396is already gated on!test_isolation_enabled. on_group_started'senable()is idempotent, so leaving the killer enabled between groups introduces no double-ref or state mismatch.- New test follows the file's existing
--parallel=2+BUN_TEST_PARALLEL_SCALE_MSpattern, polls with a deadline, andfinally-kills the last file's sleeper.
Extended reasoning...
Overview
The runtime change is four lines in Execution::on_group_completed: skip auto_killer.disable() when vm.test_isolation_enabled is set. The rest is a new test.each case in test/cli/test/isolation.test.ts covering serial --isolate and a single --parallel worker.
I verified against the surrounding code that the fix implements exactly what the existing comment at test_command.rs:3393-3395 says the code should do: under --isolate, the swap (VirtualMachine.rs:5057-5058) is the sole consumer of the tracked set between files, and it calls kill() + clear(). The per-group disable() was defeating that by turning tracking off before the next file's module scope evaluated. ProcessAutoKiller::disable() only clears the enabled flag (no side effects), and enable() in on_group_started is idempotent, so the change has no effect on refcounts or the set contents beyond keeping tracking on.
Security risks
None. This is test-runner subprocess bookkeeping; no untrusted input, no auth/crypto/permissions.
Level of scrutiny
Low-to-medium. The Rust change is a single conditional on a boolean field that already gates the same behavior at the per-file cleanup site in test_command.rs. The non---isolate path is byte-identical to before. The stated side effects (timeout kills module-scope spawns in files 2+ to match file 1; spawnSync fast path now bypassed in every file's module scope) are both convergences toward the first file's existing behavior, not new behavior.
Other factors
The prior comment-cop feedback (paragraph-long comment) was addressed in 97a895c — the comment is now one line. My earlier note about the description mentioning dropped .enable() hunks was answered: the description now carries an "Earlier revision" note and the paragraph is gone. Both threads are resolved.
The test is well-constructed: three identical fixture files each spawn a sleeper at module scope and log its pid; each file's test polls (2s deadline) for earlier pids to be dead, so the third file catches the second file's leak regardless of file order. It reuses the file's existing pattern for forcing one worker to take all files (BUN_TEST_PARALLEL_SCALE_MS: "60000"), drains stdout/stderr concurrently, asserts before exitCode, and cleans up leaked pids in finally. The author confirmed both variants fail on the unfixed build with exactly the second file's pid surviving.
Problem
bun test --isolate(and every--parallelworker) kills the subprocesses a file left running when it swaps globals between files, but a subprocess spawned at module scope (top level of the file, outside any test or hook) is only killed for the first file of the run. The same spawn in the second and later files outlives the swap and leaks out of the run.auto_killer.enabledis set.--isolatesets it once at startup (src/runtime/cli/test_command.rs:2316;--parallelworkers do the same insrc/runtime/cli/test/parallel/runner.rs:637), which is why the first file's module scope is tracked.Execution::on_group_completed(src/runtime/test_runner/Execution.rs:571) then callsdisable()after every group of tests, and nothing turns tracking back on until the next group starts, so it is off while the next file's module scope runs.swap_global_for_test_isolation(src/jsc/VirtualMachine.rs:5057) kills and clears the tracked set, but those spawns were never in it.test_command.rs("under --isolate ... we need tracking to remain enabled and populated until then") describes the intended behavior; the per-groupdisable()has defeated it since--isolatewas added in bun test: add --isolate and --parallel #29354.Fix
Execution::on_group_completedleaves the killer enabled whenvm.test_isolation_enabledis set. Under--isolatetracking is then on for the whole run and the swap's kill + clear covers every spawn the file made, at module scope or inside a test. Without--isolatenothing changes: tracking is still per group and the set is still cleared per file.--isolate(docs/test/parallel.mdx: between files Bun closes "subprocesses the file left open") and it is already how the first file of every run behaves; the change makes the later files match it. The swap is the only place that kills the set between files, so keeping the set populated until then has no other consumer.--isolatekills everything tracked (Execution::handle_timeout), so in files 2+ it now also kills that file's module-scope spawns, as it already did in file 1. bun test: only kill the timed-out test's own subprocesses on timeout #38774 makes the timeout kill per group; once both land a timeout no longer kills module-scope spawns in any file and the swap still kills everything. The two diffs touch adjacent functions and do not conflict.spawnSynccalls under--isolatenow wait on the event loop in every file instead of only the first (the blocking fast path injs_bun_spawn_bindings.rs:1049is gated on the killer being off); that is the path everyspawnSyncinside a test already takes.ActiveHandlesweep that runs before the swap (src/runtime/jsc_hooks.rs), which would let the killer go back to being timeout-only. The swap has used the killer for subprocesses since bun test: add --isolate and --parallel #29354 and bun test: only kill the timed-out test's own subprocesses on timeout #38774 is reworking the killer's internals, so this PR keeps the one-line policy change and leaves that reshape as a follow-up.test/cli/test/isolation.test.ts, "module-scope subprocesses are killed for every isolated file, not just the first", run once for serial--isolateand once for a single--parallelworker running all three files. Each fixture file spawns a sleeper at module scope and logs its pid; its test asserts the sleepers of the files that ran before it are dead, so the third file to run observes the second file's leak. Both variants fail on the unfixed build (one surviving pid, the second file's) and pass with the fix; the existing in-test spawn case andtest-timeout-behavior.test.tsstill pass.Background
ProcessAutoKiller(src/jsc/ProcessAutoKiller.rs) is a per-VM set of liveBun.spawnprocesses.Bun.spawnadds a process to it only whileenabledis true, exits remove it, andkill()SIGTERMs and empties it.bun testuses it in two places: a test timeout kills whatever is in the set, and the--isolateswap kills the set to clean up after a file.bun:testruns at a time (a test with its hooks, or a batch of concurrent tests). Without--isolate, tracking is turned on and off around each group so a timeout only kills processes spawned while tests were running, and the set is cleared at the end of each file.swap_global_for_test_isolation) runs after every file under--isolate, and after every file inside a--parallelworker (workers always isolate). A file's module scope is evaluated before any of its groups start, which is the window in which tracking was off.Earlier revision
The first push also switched the two startup sites from
auto_killer.enabled = truetoauto_killer.enable()so thatever_enabledwas set before the first group. The only effect was pruning a first-file module-scope process that exits before any test runs slightly earlier (the swap releases it anyway), so those hunks were dropped after self-review and the diff is now theon_group_completedchange plus the test.