diff --git a/packages/vitest/src/node/pools/poolRunner.ts b/packages/vitest/src/node/pools/poolRunner.ts index 90b938cbe..8055e6406 100644 --- a/packages/vitest/src/node/pools/poolRunner.ts +++ b/packages/vitest/src/node/pools/poolRunner.ts @@ -277,8 +277,10 @@ export class PoolRunner { try { this._state = RunnerState.STOPPING - // Remove exit listener early to avoid "unexpected exit" errors during shutdown + // Remove exit and error listeners early to avoid "unexpected exit" and + // channel teardown errors during shutdown this.worker.off('exit', this.emitUnexpectedExit) + this.worker.off('error', this.emitWorkerError) const stopSpan = this.startTracesSpan('vitest.worker.stop') await this.withTimeout( diff --git a/packages/vitest/src/node/pools/workers/forksWorker.ts b/packages/vitest/src/node/pools/workers/forksWorker.ts index a7cda6a83..0f8161a9c 100644 --- a/packages/vitest/src/node/pools/workers/forksWorker.ts +++ b/packages/vitest/src/node/pools/workers/forksWorker.ts @@ -2,10 +2,14 @@ import type { ChildProcess } from 'node:child_process' import type { Writable } from 'node:stream' import type { PoolOptions, PoolWorker, WorkerRequest } from '../types' import { fork } from 'node:child_process' +import { EventEmitter } from 'node:events' import { resolve } from 'node:path' import { streamFlushed } from './utils' const SIGKILL_TIMEOUT = 500 /** jest does 500ms by default, let's follow it */ +// how long a failed pipe write may wait for the process's 'exit' event +// before it is reported as the worker error itself +const PIPE_ERROR_EXIT_GRACE = 1_000 /** @experimental */ export class ForksPoolWorker implements PoolWorker { @@ -20,6 +24,9 @@ export class ForksPoolWorker implements PoolWorker { private stdout: NodeJS.WriteStream | Writable private stderr: NodeJS.WriteStream | Writable + private _errorEmitter = new EventEmitter<{ error: [Error] }>() + private _pipeErrorTimer: ReturnType | undefined + constructor(options: PoolOptions) { this.execArgv = options.execArgv this.env = options.env @@ -31,11 +38,21 @@ export class ForksPoolWorker implements PoolWorker { } on(event: string, callback: (...args: any[]) => void): void { - this.fork.on(event, callback) + if (event === 'error') { + this._errorEmitter.on('error', callback) + } + else { + this.fork.on(event, callback) + } } off(event: string, callback: (...args: any[]) => void): void { - this.fork.off(event, callback) + if (event === 'error') { + this._errorEmitter.off('error', callback) + } + else { + this.fork.off(event, callback) + } } send(message: WorkerRequest): void { @@ -50,6 +67,8 @@ export class ForksPoolWorker implements PoolWorker { serialization: 'advanced', }) + this._fork.on('error', this.emitError) + // `end: false`: the logger streams are shared by every worker, so one // ending worker stream must not end them for everyone else if (this._fork.stdout) { @@ -108,6 +127,40 @@ export class ForksPoolWorker implements PoolWorker { return data } + private emitError = (error: Error): void => { + // A write into a dying child process fails with EPIPE (or a closed IPC + // channel) and can be observed before the process's 'exit' event, + // especially on macOS. The exit event knows the exit code, the signal and + // the affected test files, so hold the write error and let the 'exit' + // listeners report instead. The timer covers a broken channel whose + // process never exits; a process that exited while the error was held was + // already reported through the exit event, and a process whose listeners + // were detached is being shut down deliberately — drop the error in both + // cases. + const code = (error as NodeJS.ErrnoException).code + if (code === 'EPIPE' || code === 'ERR_IPC_CHANNEL_CLOSED') { + if (this._pipeErrorTimer) { + return + } + this._pipeErrorTimer = setTimeout(() => { + this._pipeErrorTimer = undefined + const fork = this._fork + if ( + fork + && fork.exitCode == null + && fork.signalCode == null + && this._errorEmitter.listenerCount('error') + ) { + this._errorEmitter.emit('error', error) + } + }, PIPE_ERROR_EXIT_GRACE) + this._pipeErrorTimer.unref() + return + } + + this._errorEmitter.emit('error', error) + } + private get fork() { if (!this._fork) { throw new Error(`The child process was torn down or never initialized. This is a bug in Vitest.`)