From 934b0f587cb61d8338d83f525295322692a2db40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ari=20Perkki=C3=B6?= Date: Thu, 11 Jun 2026 15:55:38 +0300 Subject: [PATCH] fix(pool): prevent test run hang on worker crash (#10543) [backport to v4] (#10564) Co-authored-by: Jattioui Ismail <44307645+jaxalo@users.noreply.github.com> --- packages/vitest/src/node/pools/pool.ts | 15 ++-- packages/vitest/src/node/pools/poolRunner.ts | 1 + .../fixtures/pool-worker-exit/1-first.test.ts | 6 ++ .../fixtures/pool-worker-exit/2-crash.test.ts | 8 +++ .../fixtures/pool-worker-exit/3-crash.test.ts | 8 +++ .../fixtures/pool-worker-exit/4-third.test.ts | 6 ++ test/cli/fixtures/pool-worker-exit/src.ts | 7 ++ test/cli/test/pool-worker-exit.test.ts | 71 +++++++++++++++++++ 8 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 test/cli/fixtures/pool-worker-exit/1-first.test.ts create mode 100644 test/cli/fixtures/pool-worker-exit/2-crash.test.ts create mode 100644 test/cli/fixtures/pool-worker-exit/3-crash.test.ts create mode 100644 test/cli/fixtures/pool-worker-exit/4-third.test.ts create mode 100644 test/cli/fixtures/pool-worker-exit/src.ts create mode 100644 test/cli/test/pool-worker-exit.test.ts diff --git a/packages/vitest/src/node/pools/pool.ts b/packages/vitest/src/node/pools/pool.ts index eeccdc8aa..4f86e6ffb 100644 --- a/packages/vitest/src/node/pools/pool.ts +++ b/packages/vitest/src/node/pools/pool.ts @@ -101,19 +101,21 @@ export class Pool { } runner.off('message', onFinished) + runner.off('error', onTaskError) resolver.resolve() } } + function onTaskError(error: unknown) { + runner.off('message', onFinished) + runner.off('error', onTaskError) + resolver.reject(new Error(`[vitest-pool]: Worker ${task.worker} emitted error.`, { cause: error })) + } + runner.on('message', onFinished) + runner.on('error', onTaskError) if (!runner.isStarted) { - runner.on('error', (error) => { - resolver.reject( - new Error(`[vitest-pool]: Worker ${task.worker} emitted error.`, { cause: error }), - ) - }) - const id = setTimeout( () => resolver.reject(new Error(`[vitest-pool]: Timeout starting ${task.worker} runner.`)), WORKER_START_TIMEOUT, @@ -148,6 +150,7 @@ export class Pool { if ( !task.isolate + && !runner.isTerminated && !isMemoryLimitReached && this.queue[0]?.task.isolate === false && isEqualRunner(runner, this.queue[0].task) diff --git a/packages/vitest/src/node/pools/poolRunner.ts b/packages/vitest/src/node/pools/poolRunner.ts index 26613932d..59180a292 100644 --- a/packages/vitest/src/node/pools/poolRunner.ts +++ b/packages/vitest/src/node/pools/poolRunner.ts @@ -368,6 +368,7 @@ export class PoolRunner { private emitUnexpectedExit = (): void => { const error = new Error('Worker exited unexpectedly') + this._state = RunnerState.STOPPED this._eventEmitter.emit('error', error) } diff --git a/test/cli/fixtures/pool-worker-exit/1-first.test.ts b/test/cli/fixtures/pool-worker-exit/1-first.test.ts new file mode 100644 index 000000000..ea36b1916 --- /dev/null +++ b/test/cli/fixtures/pool-worker-exit/1-first.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { covered } from './src' + +test('first test exercises src so it should appear in coverage', () => { + expect(covered()).toBe(42) +}) diff --git a/test/cli/fixtures/pool-worker-exit/2-crash.test.ts b/test/cli/fixtures/pool-worker-exit/2-crash.test.ts new file mode 100644 index 000000000..2c20ecf59 --- /dev/null +++ b/test/cli/fixtures/pool-worker-exit/2-crash.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +test('the worker dies before sending testfileFinished', async () => { + // SIGKILL the worker process so it can't send testfileFinished back to main. + // Pre-fix this caused pool.run() to hang forever instead of rejecting. + queueMicrotask(() => process.kill(process.pid, 'SIGKILL')) + await new Promise(() => {}) +}) diff --git a/test/cli/fixtures/pool-worker-exit/3-crash.test.ts b/test/cli/fixtures/pool-worker-exit/3-crash.test.ts new file mode 100644 index 000000000..2c20ecf59 --- /dev/null +++ b/test/cli/fixtures/pool-worker-exit/3-crash.test.ts @@ -0,0 +1,8 @@ +import { test } from 'vitest' + +test('the worker dies before sending testfileFinished', async () => { + // SIGKILL the worker process so it can't send testfileFinished back to main. + // Pre-fix this caused pool.run() to hang forever instead of rejecting. + queueMicrotask(() => process.kill(process.pid, 'SIGKILL')) + await new Promise(() => {}) +}) diff --git a/test/cli/fixtures/pool-worker-exit/4-third.test.ts b/test/cli/fixtures/pool-worker-exit/4-third.test.ts new file mode 100644 index 000000000..1ab7b9358 --- /dev/null +++ b/test/cli/fixtures/pool-worker-exit/4-third.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from 'vitest' +import { covered } from './src.js' + +test('third test', () => { + expect(covered()).toBe(42) +}) diff --git a/test/cli/fixtures/pool-worker-exit/src.ts b/test/cli/fixtures/pool-worker-exit/src.ts new file mode 100644 index 000000000..ff8f12215 --- /dev/null +++ b/test/cli/fixtures/pool-worker-exit/src.ts @@ -0,0 +1,7 @@ +export function covered() { + return 42 +} + +export function uncovered() { + return 42 +} diff --git a/test/cli/test/pool-worker-exit.test.ts b/test/cli/test/pool-worker-exit.test.ts new file mode 100644 index 000000000..3813478af --- /dev/null +++ b/test/cli/test/pool-worker-exit.test.ts @@ -0,0 +1,71 @@ +import { runVitest, StableTestFileOrderSorter } from '#test-utils' +import { resolve } from 'pathe' +import { expect, test } from 'vitest' +import { readCoverageMap } from '../../coverage-test/utils' + +test('worker death on a shared runner does not skip coverage finalization', async () => { + const root = './fixtures/pool-worker-exit' + + const { buildTree } = await runVitest({ + root, + pool: 'forks', + + // Disable isolation to make sure crashed worker doesn't hang whole test run + isolate: false, + maxWorkers: 2, + + sequence: { sequencer: StableTestFileOrderSorter }, + include: [ + '1-first.test.ts', + '2-crash.test.ts', + '3-crash.test.ts', + '4-third.test.ts', + ], + + reporters: 'default', + coverage: { + enabled: true, + provider: 'v8', + reporter: ['json'], + reportOnFailure: true, + }, + }) + + expect(buildTree(t => ({ state: t.result().state }))).toMatchInlineSnapshot(` + { + "1-first.test.ts": { + "first test exercises src so it should appear in coverage": { + "state": "passed", + }, + }, + "2-crash.test.ts": { + "the worker dies before sending testfileFinished": { + "state": "pending", + }, + }, + "3-crash.test.ts": { + "the worker dies before sending testfileFinished": { + "state": "pending", + }, + }, + "4-third.test.ts": { + "third test": { + "state": "passed", + }, + }, + } + `) + + // Crashing worker should not interfere with other test-run, coverage should be reported: + const coverageMap = await readCoverageMap(resolve(root, 'coverage/coverage-final.json')) + const fileCoverage = coverageMap.fileCoverageFor('/fixtures/pool-worker-exit/src.ts') + + expect(fileCoverage.toSummary().functions).toMatchInlineSnapshot(` + { + "covered": 1, + "pct": 50, + "skipped": 0, + "total": 2, + } + `) +}) -- 2.51.2