diff --git a/packages/vitest/src/node/pools/poolRunner.ts b/packages/vitest/src/node/pools/poolRunner.ts index 4e4c4a5b5..1e0eb94b9 100644 --- a/packages/vitest/src/node/pools/poolRunner.ts +++ b/packages/vitest/src/node/pools/poolRunner.ts @@ -46,6 +46,7 @@ export class PoolRunner { public readonly project: TestProject public environment: ContextTestEnvironment + private _lastTestFiles: string[] private _state: RunnerState = RunnerState.IDLE private _operationLock: DeferPromise | null = null private _terminatePromise: DeferPromise = createDefer() @@ -79,6 +80,7 @@ export class PoolRunner { this.environment = options.environment const vitest = this.project.vitest + this._lastTestFiles = [] this._traces = vitest._traces if (this._traces.isEnabled()) { const { span: workerSpan, context } = this._traces.startContextSpan('vitest.worker') @@ -147,7 +149,8 @@ export class PoolRunner { } request(method: 'run' | 'collect', context: WorkerExecuteContext): void { - this._otel?.files.push(...context.files.map(f => f.filepath)) + this._lastTestFiles = context.files.map(f => f.filepath) + this._otel?.files.push(...this._lastTestFiles) return this.postMessage({ __vitest_worker_request__: true, type: method, @@ -325,6 +328,7 @@ export class PoolRunner { throw error } finally { + this._lastTestFiles = [] this._operationLock.resolve() this._operationLock = null this._otel?.span.end() @@ -366,8 +370,13 @@ export class PoolRunner { } } - private emitUnexpectedExit = (): void => { - const error = new Error(`Worker exited unexpectedly during ${this._state} state`) + private emitUnexpectedExit = (code?: number, signal?: string): void => { + const hasCode = typeof code === 'number' + const errorDetails = hasCode || signal + ? `with ${hasCode ? `exit code ${code} ` : ''}${signal ? `signal ${signal} ` : ''}` + : '' + const testFileDetails = this._lastTestFiles.length ? ` while running test file${this._lastTestFiles.length === 1 ? '' : 's'} ${this._lastTestFiles.join(', ')}` : '' + const error = new Error(`Worker exited unexpectedly ${errorDetails}during ${this._state} state${testFileDetails}`) this._state = RunnerState.STOPPED this._eventEmitter.emit('error', error) diff --git a/packages/vitest/src/node/pools/types.ts b/packages/vitest/src/node/pools/types.ts index 9f6f6f217..18de91f08 100644 --- a/packages/vitest/src/node/pools/types.ts +++ b/packages/vitest/src/node/pools/types.ts @@ -24,8 +24,8 @@ export interface PoolWorker { readonly reportMemory?: boolean readonly cacheFs?: boolean - on: (event: string, callback: (arg: any) => void) => void - off: (event: string, callback: (arg: any) => void) => void + on: (event: string, callback: (...args: any[]) => void) => void + off: (event: string, callback: (...args: any[]) => void) => void send: (message: WorkerRequest) => void deserialize: (data: unknown) => unknown diff --git a/packages/vitest/src/node/pools/workers/forksWorker.ts b/packages/vitest/src/node/pools/workers/forksWorker.ts index a1a88cc86..a95c1b041 100644 --- a/packages/vitest/src/node/pools/workers/forksWorker.ts +++ b/packages/vitest/src/node/pools/workers/forksWorker.ts @@ -29,11 +29,11 @@ export class ForksPoolWorker implements PoolWorker { this.entrypoint = resolve(options.distPath, 'workers/forks.js') } - on(event: string, callback: (arg: any) => void): void { + on(event: string, callback: (...args: any[]) => void): void { this.fork.on(event, callback) } - off(event: string, callback: (arg: any) => void): void { + off(event: string, callback: (...args: any[]) => void): void { this.fork.off(event, callback) } diff --git a/packages/vitest/src/node/pools/workers/threadsWorker.ts b/packages/vitest/src/node/pools/workers/threadsWorker.ts index 7178c2bcb..05609a0b2 100644 --- a/packages/vitest/src/node/pools/workers/threadsWorker.ts +++ b/packages/vitest/src/node/pools/workers/threadsWorker.ts @@ -25,11 +25,11 @@ export class ThreadsPoolWorker implements PoolWorker { this.entrypoint = resolve(options.distPath, 'workers/threads.js') } - on(event: string, callback: (arg: any) => void): void { + on(event: string, callback: (...args: any[]) => void): void { this.thread.on(event, callback) } - off(event: string, callback: (arg: any) => void): void { + off(event: string, callback: (...args: any[]) => void): void { this.thread.off(event, callback) } diff --git a/test/e2e/fixtures/pool-worker-exit/3-crash.test.ts b/test/e2e/fixtures/pool-worker-exit/3-crash.test.ts index 2c20ecf59..d2c00be22 100644 --- a/test/e2e/fixtures/pool-worker-exit/3-crash.test.ts +++ b/test/e2e/fixtures/pool-worker-exit/3-crash.test.ts @@ -1,8 +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. + // SIGINT 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')) + queueMicrotask(() => process.kill(process.pid, 'SIGINT')) await new Promise(() => {}) }) diff --git a/test/e2e/fixtures/pool-worker-exit/5-exit.test.ts b/test/e2e/fixtures/pool-worker-exit/5-exit.test.ts new file mode 100644 index 000000000..1e61b981d --- /dev/null +++ b/test/e2e/fixtures/pool-worker-exit/5-exit.test.ts @@ -0,0 +1,7 @@ +import { test } from 'vitest' + +test('the worker exits before sending testfileFinished', async () => { + // @ts-expect-error -- use reallyExit as Vitest patches process.exit + queueMicrotask(() => process.reallyExit(42)) + await new Promise(() => {}) +}) diff --git a/test/e2e/test/pool-worker-exit.test.ts b/test/e2e/test/pool-worker-exit.test.ts index 3813478af..0ac05cb6b 100644 --- a/test/e2e/test/pool-worker-exit.test.ts +++ b/test/e2e/test/pool-worker-exit.test.ts @@ -1,3 +1,4 @@ +import { sep } from 'node:path' import { runVitest, StableTestFileOrderSorter } from '#test-utils' import { resolve } from 'pathe' import { expect, test } from 'vitest' @@ -6,7 +7,7 @@ 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({ + const { buildTree, stderr } = await runVitest({ root, pool: 'forks', @@ -68,4 +69,78 @@ test('worker death on a shared runner does not skip coverage finalization', asyn "total": 2, } `) + // should report two errors in stderr + expect(stderr).toContain('caught 2 unhandled errors') +}) + +test('worker process exit and kill raises exit code and signal to stderr along with which files were in process', async () => { + const root = './fixtures/pool-worker-exit' + + const { buildTree, stderr } = 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', + '3-crash.test.ts', + '4-third.test.ts', + '5-exit.test.ts', + ], + + reporters: 'default', + }) + + let errors = stderr + .replaceAll(process.cwd().replaceAll(sep, '/'), '') + .split('\n') + .filter(line => !line.startsWith(' ❯') && line.trim().length > 0) + .join('\n') + + // Windows has no signals + if (process.platform === 'win32') { + errors = errors.replaceAll('with exit code 1', 'with signal SIGINT') + } + + expect(errors).toMatchInlineSnapshot(` + "⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯ + Vitest caught 2 unhandled errors during the test run. + This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected. + ⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯ + Error: [vitest-pool]: Worker forks emitted error. + Caused by: Error: Worker exited unexpectedly with signal SIGINT during started state while running test file /fixtures/pool-worker-exit/3-crash.test.ts + ⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯ + Error: [vitest-pool]: Worker forks emitted error. + Caused by: Error: Worker exited unexpectedly with exit code 42 during started state while running test file /fixtures/pool-worker-exit/5-exit.test.ts + ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯" + `) + + expect(buildTree(t => ({ state: t.result().state }))).toMatchInlineSnapshot(` + { + "1-first.test.ts": { + "first test exercises src so it should appear in coverage": { + "state": "passed", + }, + }, + "3-crash.test.ts": { + "the worker dies before sending testfileFinished": { + "state": "pending", + }, + }, + "4-third.test.ts": { + "third test": { + "state": "passed", + }, + }, + "5-exit.test.ts": { + "the worker exits before sending testfileFinished": { + "state": "pending", + }, + }, + } + `) })