Skip to content

Worker "error" listener only registered for the first task on a runner — with isolate: false, a worker dying mid-task strands the run (silent hang / false exit 0 under Bun) #10590

Description

@cneller

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:

  1. Config: pool: 'forks', isolate: false, N test files (N ≥ 2) so a runner is reused.
  2. Let the first file complete on a worker, then kill -KILL that worker while a later file is mid-task.
  3. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions