Skip to content

Commit 59a0373

Browse files
authored
fix(pool): report the worker exit instead of a pipe error that raced it (#10964)
1 parent 5622f09 commit 59a0373

2 files changed

Lines changed: 58 additions & 3 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,10 @@ export class PoolRunner {
277277
try {
278278
this._state = RunnerState.STOPPING
279279

280-
// Remove exit listener early to avoid "unexpected exit" errors during shutdown
280+
// Remove exit and error listeners early to avoid "unexpected exit" and
281+
// channel teardown errors during shutdown
281282
this.worker.off('exit', this.emitUnexpectedExit)
283+
this.worker.off('error', this.emitWorkerError)
282284

283285
const stopSpan = this.startTracesSpan('vitest.worker.stop')
284286
await this.withTimeout(

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

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ import type { ChildProcess } from 'node:child_process'
22
import type { Writable } from 'node:stream'
33
import type { PoolOptions, PoolWorker, WorkerRequest } from '../types'
44
import { fork } from 'node:child_process'
5+
import { EventEmitter } from 'node:events'
56
import { resolve } from 'node:path'
67
import { streamFlushed } from './utils'
78

89
const SIGKILL_TIMEOUT = 500 /** jest does 500ms by default, let's follow it */
10+
// how long a failed pipe write may wait for the process's 'exit' event
11+
// before it is reported as the worker error itself
12+
const PIPE_ERROR_EXIT_GRACE = 1_000
913

1014
/** @experimental */
1115
export class ForksPoolWorker implements PoolWorker {
@@ -20,6 +24,9 @@ export class ForksPoolWorker implements PoolWorker {
2024
private stdout: NodeJS.WriteStream | Writable
2125
private stderr: NodeJS.WriteStream | Writable
2226

27+
private _errorEmitter = new EventEmitter<{ error: [Error] }>()
28+
private _pipeErrorTimer: ReturnType<typeof setTimeout> | undefined
29+
2330
constructor(options: PoolOptions) {
2431
this.execArgv = options.execArgv
2532
this.env = options.env
@@ -31,11 +38,21 @@ export class ForksPoolWorker implements PoolWorker {
3138
}
3239

3340
on(event: string, callback: (...args: any[]) => void): void {
34-
this.fork.on(event, callback)
41+
if (event === 'error') {
42+
this._errorEmitter.on('error', callback)
43+
}
44+
else {
45+
this.fork.on(event, callback)
46+
}
3547
}
3648

3749
off(event: string, callback: (...args: any[]) => void): void {
38-
this.fork.off(event, callback)
50+
if (event === 'error') {
51+
this._errorEmitter.off('error', callback)
52+
}
53+
else {
54+
this.fork.off(event, callback)
55+
}
3956
}
4057

4158
send(message: WorkerRequest): void {
@@ -50,6 +67,8 @@ export class ForksPoolWorker implements PoolWorker {
5067
serialization: 'advanced',
5168
})
5269

70+
this._fork.on('error', this.emitError)
71+
5372
// `end: false`: the logger streams are shared by every worker, so one
5473
// ending worker stream must not end them for everyone else
5574
if (this._fork.stdout) {
@@ -108,6 +127,40 @@ export class ForksPoolWorker implements PoolWorker {
108127
return data
109128
}
110129

130+
private emitError = (error: Error): void => {
131+
// A write into a dying child process fails with EPIPE (or a closed IPC
132+
// channel) and can be observed before the process's 'exit' event,
133+
// especially on macOS. The exit event knows the exit code, the signal and
134+
// the affected test files, so hold the write error and let the 'exit'
135+
// listeners report instead. The timer covers a broken channel whose
136+
// process never exits; a process that exited while the error was held was
137+
// already reported through the exit event, and a process whose listeners
138+
// were detached is being shut down deliberately — drop the error in both
139+
// cases.
140+
const code = (error as NodeJS.ErrnoException).code
141+
if (code === 'EPIPE' || code === 'ERR_IPC_CHANNEL_CLOSED') {
142+
if (this._pipeErrorTimer) {
143+
return
144+
}
145+
this._pipeErrorTimer = setTimeout(() => {
146+
this._pipeErrorTimer = undefined
147+
const fork = this._fork
148+
if (
149+
fork
150+
&& fork.exitCode == null
151+
&& fork.signalCode == null
152+
&& this._errorEmitter.listenerCount('error')
153+
) {
154+
this._errorEmitter.emit('error', error)
155+
}
156+
}, PIPE_ERROR_EXIT_GRACE)
157+
this._pipeErrorTimer.unref()
158+
return
159+
}
160+
161+
this._errorEmitter.emit('error', error)
162+
}
163+
111164
private get fork() {
112165
if (!this._fork) {
113166
throw new Error(`The child process was torn down or never initialized. This is a bug in Vitest.`)

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo