Skip to content

Commit 76139c5

Browse files
filmajAriPerkkio
andauthored
fix(pool): improve error message when worker exits unexpectedly (#10587)
Co-authored-by: Ari Perkkiö <[email protected]>
1 parent 3f60d08 commit 76139c5

7 files changed

Lines changed: 103 additions & 12 deletions

File tree

packages/vitest/src/node/pools/poolRunner.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export class PoolRunner {
4646
public readonly project: TestProject
4747
public environment: ContextTestEnvironment
4848

49+
private _lastTestFiles: string[]
4950
private _state: RunnerState = RunnerState.IDLE
5051
private _operationLock: DeferPromise<void> | null = null
5152
private _terminatePromise: DeferPromise<void> = createDefer()
@@ -79,6 +80,7 @@ export class PoolRunner {
7980
this.environment = options.environment
8081

8182
const vitest = this.project.vitest
83+
this._lastTestFiles = []
8284
this._traces = vitest._traces
8385
if (this._traces.isEnabled()) {
8486
const { span: workerSpan, context } = this._traces.startContextSpan('vitest.worker')
@@ -147,7 +149,8 @@ export class PoolRunner {
147149
}
148150

149151
request(method: 'run' | 'collect', context: WorkerExecuteContext): void {
150-
this._otel?.files.push(...context.files.map(f => f.filepath))
152+
this._lastTestFiles = context.files.map(f => f.filepath)
153+
this._otel?.files.push(...this._lastTestFiles)
151154
return this.postMessage({
152155
__vitest_worker_request__: true,
153156
type: method,
@@ -325,6 +328,7 @@ export class PoolRunner {
325328
throw error
326329
}
327330
finally {
331+
this._lastTestFiles = []
328332
this._operationLock.resolve()
329333
this._operationLock = null
330334
this._otel?.span.end()
@@ -366,8 +370,13 @@ export class PoolRunner {
366370
}
367371
}
368372

369-
private emitUnexpectedExit = (): void => {
370-
const error = new Error(`Worker exited unexpectedly during ${this._state} state`)
373+
private emitUnexpectedExit = (code?: number, signal?: string): void => {
374+
const hasCode = typeof code === 'number'
375+
const errorDetails = hasCode || signal
376+
? `with ${hasCode ? `exit code ${code} ` : ''}${signal ? `signal ${signal} ` : ''}`
377+
: ''
378+
const testFileDetails = this._lastTestFiles.length ? ` while running test file${this._lastTestFiles.length === 1 ? '' : 's'} ${this._lastTestFiles.join(', ')}` : ''
379+
const error = new Error(`Worker exited unexpectedly ${errorDetails}during ${this._state} state${testFileDetails}`)
371380
this._state = RunnerState.STOPPED
372381

373382
this._eventEmitter.emit('error', error)

packages/vitest/src/node/pools/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ export interface PoolWorker {
2424
readonly reportMemory?: boolean
2525
readonly cacheFs?: boolean
2626

27-
on: (event: string, callback: (arg: any) => void) => void
28-
off: (event: string, callback: (arg: any) => void) => void
27+
on: (event: string, callback: (...args: any[]) => void) => void
28+
off: (event: string, callback: (...args: any[]) => void) => void
2929
send: (message: WorkerRequest) => void
3030
deserialize: (data: unknown) => unknown
3131

packages/vitest/src/node/pools/workers/forksWorker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ export class ForksPoolWorker implements PoolWorker {
2929
this.entrypoint = resolve(options.distPath, 'workers/forks.js')
3030
}
3131

32-
on(event: string, callback: (arg: any) => void): void {
32+
on(event: string, callback: (...args: any[]) => void): void {
3333
this.fork.on(event, callback)
3434
}
3535

36-
off(event: string, callback: (arg: any) => void): void {
36+
off(event: string, callback: (...args: any[]) => void): void {
3737
this.fork.off(event, callback)
3838
}
3939

packages/vitest/src/node/pools/workers/threadsWorker.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@ export class ThreadsPoolWorker implements PoolWorker {
2525
this.entrypoint = resolve(options.distPath, 'workers/threads.js')
2626
}
2727

28-
on(event: string, callback: (arg: any) => void): void {
28+
on(event: string, callback: (...args: any[]) => void): void {
2929
this.thread.on(event, callback)
3030
}
3131

32-
off(event: string, callback: (arg: any) => void): void {
32+
off(event: string, callback: (...args: any[]) => void): void {
3333
this.thread.off(event, callback)
3434
}
3535

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { test } from 'vitest'
22

33
test('the worker dies before sending testfileFinished', async () => {
4-
// SIGKILL the worker process so it can't send testfileFinished back to main.
4+
// SIGINT the worker process so it can't send testfileFinished back to main.
55
// Pre-fix this caused pool.run() to hang forever instead of rejecting.
6-
queueMicrotask(() => process.kill(process.pid, 'SIGKILL'))
6+
queueMicrotask(() => process.kill(process.pid, 'SIGINT'))
77
await new Promise(() => {})
88
})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { test } from 'vitest'
2+
3+
test('the worker exits before sending testfileFinished', async () => {
4+
// @ts-expect-error -- use reallyExit as Vitest patches process.exit
5+
queueMicrotask(() => process.reallyExit(42))
6+
await new Promise(() => {})
7+
})

test/e2e/test/pool-worker-exit.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { sep } from 'node:path'
12
import { runVitest, StableTestFileOrderSorter } from '#test-utils'
23
import { resolve } from 'pathe'
34
import { expect, test } from 'vitest'
@@ -6,7 +7,7 @@ import { readCoverageMap } from '../../coverage-test/utils'
67
test('worker death on a shared runner does not skip coverage finalization', async () => {
78
const root = './fixtures/pool-worker-exit'
89

9-
const { buildTree } = await runVitest({
10+
const { buildTree, stderr } = await runVitest({
1011
root,
1112
pool: 'forks',
1213

@@ -68,4 +69,78 @@ test('worker death on a shared runner does not skip coverage finalization', asyn
6869
"total": 2,
6970
}
7071
`)
72+
// should report two errors in stderr
73+
expect(stderr).toContain('caught 2 unhandled errors')
74+
})
75+
76+
test('worker process exit and kill raises exit code and signal to stderr along with which files were in process', async () => {
77+
const root = './fixtures/pool-worker-exit'
78+
79+
const { buildTree, stderr } = await runVitest({
80+
root,
81+
pool: 'forks',
82+
83+
// Disable isolation to make sure crashed worker doesn't hang whole test run
84+
isolate: false,
85+
maxWorkers: 2,
86+
87+
sequence: { sequencer: StableTestFileOrderSorter },
88+
include: [
89+
'1-first.test.ts',
90+
'3-crash.test.ts',
91+
'4-third.test.ts',
92+
'5-exit.test.ts',
93+
],
94+
95+
reporters: 'default',
96+
})
97+
98+
let errors = stderr
99+
.replaceAll(process.cwd().replaceAll(sep, '/'), '<process-cwd>')
100+
.split('\n')
101+
.filter(line => !line.startsWith(' ❯') && line.trim().length > 0)
102+
.join('\n')
103+
104+
// Windows has no signals
105+
if (process.platform === 'win32') {
106+
errors = errors.replaceAll('with exit code 1', 'with signal SIGINT')
107+
}
108+
109+
expect(errors).toMatchInlineSnapshot(`
110+
"⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯
111+
Vitest caught 2 unhandled errors during the test run.
112+
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.
113+
⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯
114+
Error: [vitest-pool]: Worker forks emitted error.
115+
Caused by: Error: Worker exited unexpectedly with signal SIGINT during started state while running test file <process-cwd>/fixtures/pool-worker-exit/3-crash.test.ts
116+
⎯⎯⎯⎯⎯⎯ Unhandled Error ⎯⎯⎯⎯⎯⎯⎯
117+
Error: [vitest-pool]: Worker forks emitted error.
118+
Caused by: Error: Worker exited unexpectedly with exit code 42 during started state while running test file <process-cwd>/fixtures/pool-worker-exit/5-exit.test.ts
119+
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯"
120+
`)
121+
122+
expect(buildTree(t => ({ state: t.result().state }))).toMatchInlineSnapshot(`
123+
{
124+
"1-first.test.ts": {
125+
"first test exercises src so it should appear in coverage": {
126+
"state": "passed",
127+
},
128+
},
129+
"3-crash.test.ts": {
130+
"the worker dies before sending testfileFinished": {
131+
"state": "pending",
132+
},
133+
},
134+
"4-third.test.ts": {
135+
"third test": {
136+
"state": "passed",
137+
},
138+
},
139+
"5-exit.test.ts": {
140+
"the worker exits before sending testfileFinished": {
141+
"state": "pending",
142+
},
143+
},
144+
}
145+
`)
71146
})

0 commit comments

Comments
 (0)