Describe the bug
In the pool scheduler (packages/vitest/src/node/pools/pool.ts, ~L110-114 at the v4.1.8 tag; identical in the shipped 4.1.3 dist), the worker "error" listener is registered inside if (!runner.isStarted) — only for the first task on a fresh runner, and its rejection closes over that first task's resolver:
runner.on("message", onFinished);
if (!runner.isStarted) {
runner.on("error", (error) => {
resolver.reject(new Error(`[vitest-pool]: Worker ${task.worker} emitted error.`, { cause: error }));
});
...
}
With isolate: false, runners are reused across tasks (sharedRunners), and reused runners have isStarted === true — so every task after the first has NO error listener bound to its own resolver. A worker that dies mid-task (OOM kill, crash, SIGKILL) leaves that task's finish promise pending forever. Pending promises don't ref the event loop, so:
There is also no completion guard: nothing asserts onTestRunEnd ran before a zero exit.
Reproduction
Observed twice in CI-like conditions on a loaded macOS box (load ≥ 20), pool: 'forks', isolate: false, ~2,200 test files across two projects, Bun 1.3.14: the run stopped partway through the first project, printed no summary block, and exited 0 in 66–79 s (well under its timeout cap).
Deterministic repro shape:
- Config:
pool: 'forks', isolate: false, N test files (N ≥ 2) so a runner is reused.
- Let the first file complete on a worker, then
kill -KILL that worker while a later file is mid-task.
- Observed: under Bun 1.3.14 the orchestrator exits 0 with no
Test Files summary; under Node it hangs.
Empirical confirmation that the missing listener is the mechanism: after patching the dist so the "error" listener registers per-task (see fix below), the same loaded-box conditions reproduced the worker death — but loudly. The per-task listener fired with:
Error: [vitest-pool]: Worker forks emitted error.
❯ onRunnerError dist/chunks/cli-api.js:…
Caused by: Error: Worker exited unexpectedly
❯ emitUnexpectedExit dist/chunks/cli-api.js:…
❯ #handleOnExit node:child_process:520:14
followed by a cascade of Subprocess.send() cannot be used after the process has exited rejections for every later file scheduled onto the dead shared runner, and an honest exit 1. Deliberate kill -KILL <worker> mid-run with the patch applied: 3/3 runs failed loudly with the [vitest-pool] diagnostic; 0/3 silent.
Suggested fix
Register a per-task error listener for every task and remove it when the task settles (in schedule()):
const onRunnerError = (error) => {
resolver.reject(new Error(`[vitest-pool]: Worker ${task.worker} emitted error.`, { cause: error }));
};
runner.on("error", onRunnerError);
// ...
await resolver.promise
.catch((error) => span?.recordException(error))
.finally(() => {
span?.end();
runner.off("error", onRunnerError);
});
This is what we are running as a vendored patch; it converts the silent death into the loud [vitest-pool] failure in every scenario we tested, with no regression across a ~6,900-test suite. (A separate, smaller observation: upstream's first-task listener is never removed, so it stays bound to a settled resolver for the runner's lifetime — harmless, but it is exactly why the first task appears protected while later tasks are not.)
Environment
- vitest 4.1.3 (the listener-gap code is unchanged at the
v4.1.8 tag)
- pool:
forks, isolate: false, two projects, maxWorkers capped
- Bun 1.3.14 (false exit-0) / also affects Node ≥ 20 (as a hang)
- macOS 25.2 (darwin arm64)
Describe the bug
In the pool scheduler (
packages/vitest/src/node/pools/pool.ts, ~L110-114 at thev4.1.8tag; identical in the shipped 4.1.3 dist), the worker"error"listener is registered insideif (!runner.isStarted)— only for the first task on a fresh runner, and its rejection closes over that first task's resolver:With
isolate: false, runners are reused across tasks (sharedRunners), and reused runners haveisStarted === true— so every task after the first has NO error listener bound to its own resolver. A worker that dies mid-task (OOM kill, crash, SIGKILL) leaves that task's finish promise pending forever. Pending promises don't ref the event loop, so:Test Filessummary, remaining projects never run, reporters never reachonTestRunEnd, and red tests inside the dropped output are silently swallowed.There is also no completion guard: nothing asserts
onTestRunEndran before a zero exit.Reproduction
Observed twice in CI-like conditions on a loaded macOS box (load ≥ 20),
pool: 'forks',isolate: false, ~2,200 test files across two projects, Bun 1.3.14: the run stopped partway through the first project, printed no summary block, and exited 0 in 66–79 s (well under its timeout cap).Deterministic repro shape:
pool: 'forks',isolate: false, N test files (N ≥ 2) so a runner is reused.kill -KILLthat worker while a later file is mid-task.Test Filessummary; under Node it hangs.Empirical confirmation that the missing listener is the mechanism: after patching the dist so the
"error"listener registers per-task (see fix below), the same loaded-box conditions reproduced the worker death — but loudly. The per-task listener fired with:followed by a cascade of
Subprocess.send() cannot be used after the process has exitedrejections for every later file scheduled onto the dead shared runner, and an honest exit 1. Deliberatekill -KILL <worker>mid-run with the patch applied: 3/3 runs failed loudly with the[vitest-pool]diagnostic; 0/3 silent.Suggested fix
Register a per-task error listener for every task and remove it when the task settles (in
schedule()):This is what we are running as a vendored patch; it converts the silent death into the loud
[vitest-pool]failure in every scenario we tested, with no regression across a ~6,900-test suite. (A separate, smaller observation: upstream's first-task listener is never removed, so it stays bound to a settled resolver for the runner's lifetime — harmless, but it is exactly why the first task appears protected while later tasks are not.)Environment
v4.1.8tag)forks,isolate: false, two projects,maxWorkerscapped