Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/runtime/test_runner/Execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,11 @@ impl Execution {

fn on_group_completed(global_this: &JSGlobalObject) {
// SAFETY: bun_vm() returns the live per-thread VM.
global_this.bun_vm().as_mut().auto_killer.disable();
let vm = global_this.bun_vm().as_mut();
// Under --isolate the swap between files kills and clears the tracked set.
if !vm.test_isolation_enabled {
vm.auto_killer.disable();
}
Comment thread
claude[bot] marked this conversation as resolved.
}

fn on_sequence_started(sequence: &mut ExecutionSequence) {
Expand Down
62 changes: 62 additions & 0 deletions test/cli/test/isolation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, setDefaultTimeout, test } from "bun:test";
import { bunEnv, bunExe, isASAN, normalizeBunSnapshot, tempDir } from "harness";
import fs from "node:fs";
import net from "node:net";
import { join } from "node:path";

// Every case spawns at least one full `bun test --isolate` child; the heavy
// ones (8-file leak fixtures, 500-2000-export module_info modules) exceed the
Expand Down Expand Up @@ -411,6 +412,67 @@ describe.concurrent("bun test --isolate", () => {
expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail");
expect(exitCode).toBe(0);
});

// Every file spawns a sleeper at module scope (outside any test or hook),
// appends its pid to a shared log, and its test asserts that the sleepers
// logged by the files that ran before it are gone. The first file's sleeper
// is always killed; the third file is what proves the second file's
// module-scope sleeper was killed at its isolation swap too. Holds in any
// file order, so the same fixture covers a --parallel worker running
// several files.
const moduleScopeSpawnFile = `
import { test, expect } from "bun:test";
import fs from "node:fs";
const log = process.env.PID_LOG!;
const earlier = fs.readFileSync(log, "utf8").split("\\n").filter(Boolean).map(Number);
const child = Bun.spawn({ cmd: [process.execPath, "-e", "setInterval(()=>{}, 1e6)"], stdout: "ignore", stderr: "ignore" });
fs.appendFileSync(log, child.pid + "\\n");
const isAlive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
test("module-scope sleepers of the files before this one were killed", async () => {
// The swap sends SIGTERM; the sleeper may still be getting reaped.
const deadline = Date.now() + 2_000;
while (earlier.some(isAlive) && Date.now() < deadline) await Bun.sleep(10);
expect(earlier.filter(isAlive)).toEqual([]);
});
`;

test.each([
["--isolate", ["--isolate"], {}],
// One worker takes all three files (scale-up gated), so the worker's
// per-file swap is what has to kill the second and third sleepers.
["--parallel worker", ["--parallel=2"], { BUN_TEST_PARALLEL_SCALE_MS: "60000" }],
])("module-scope subprocesses are killed for every isolated file, not just the first (%s)", async (_, args, env) => {
using dir = tempDir("isolate-module-scope-subprocess", {
"a.test.ts": moduleScopeSpawnFile,
"b.test.ts": moduleScopeSpawnFile,
"c.test.ts": moduleScopeSpawnFile,
"pids.txt": "",
});
const log = join(String(dir), "pids.txt");
const loggedPids = () => fs.readFileSync(log, "utf8").split("\n").filter(Boolean);
try {
await using proc = Bun.spawn({
cmd: [bunExe(), "test", ...args, "./a.test.ts", "./b.test.ts", "./c.test.ts"],
env: { ...bunEnv, ...env, PID_LOG: log },
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("3 pass");
expect(normalizeBunSnapshot(stderr, dir)).toContain("0 fail");
expect(loggedPids()).toHaveLength(3);
expect(exitCode).toBe(0);
} finally {
// Nothing swaps after the last file of a serial run, so its sleeper is
// ours to kill, as is anything an unfixed runner leaked.
for (const pid of loggedPids()) {
try {
process.kill(Number(pid));
} catch {}
}
}
});
});

// The eviction test below proves the SourceProvider cache is active (control:
Expand Down
Loading