From 5e69aba6540019bce86201ceb34311d45d909e94 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sun, 16 Aug 2026 17:32:16 +0200 Subject: [PATCH] fix: own the post-restart rerun, keep `process.exit` disabled in workers (#10963) --- packages/vitest/src/node/cache/files.ts | 11 ++-- packages/vitest/src/node/cli/cli-api.ts | 64 +++++++++++-------- packages/vitest/src/node/core.ts | 5 +- packages/vitest/src/runtime/workers/base.ts | 6 +- .../vitest/src/runtime/workers/init-forks.ts | 17 ++--- packages/vitest/src/runtime/workers/vm.ts | 3 +- test/e2e/test/cli-config.test.ts | 3 +- test/e2e/test/config/browser-configs.test.ts | 8 ++- 8 files changed, 69 insertions(+), 48 deletions(-) diff --git a/packages/vitest/src/node/cache/files.ts b/packages/vitest/src/node/cache/files.ts index d408081d5..760c5576d 100644 --- a/packages/vitest/src/node/cache/files.ts +++ b/packages/vitest/src/node/cache/files.ts @@ -21,11 +21,14 @@ export class FilesStatsCache { } public async updateStats(fsPath: string, key: string): Promise { - if (!fs.existsSync(fsPath)) { - return + try { + const stats = await fs.promises.stat(fsPath) + this.cache.set(key, { size: stats.size }) + } + catch { + // the file can be deleted while the stat is in flight; a file + // without stats only loses sorting heuristics } - const stats = await fs.promises.stat(fsPath) - this.cache.set(key, { size: stats.size }) } public removeStats(fsPath: string): void { diff --git a/packages/vitest/src/node/cli/cli-api.ts b/packages/vitest/src/node/cli/cli-api.ts index e583a656c..026cf99a5 100644 --- a/packages/vitest/src/node/cli/cli-api.ts +++ b/packages/vitest/src/node/cli/cli-api.ts @@ -129,12 +129,20 @@ export async function startVitest( stdinCleanup = registerConsoleShortcuts(ctx, stdin, stdout) } - ctx.onAfterSetServer(() => { - if (ctx.config.standalone) { - ctx.standalone() + ctx.onAfterSetServer(async () => { + if (ctx.closingPromise) { + return } - else { - ctx.start(cliFilters) + try { + if (ctx.config.standalone) { + await ctx.standalone() + } + else { + await ctx.start(cliFilters) + } + } + catch (error) { + reportStartError(ctx, error) } }) @@ -157,27 +165,7 @@ export async function startVitest( return ctx } catch (e) { - if (e instanceof FilesNotFoundError) { - return ctx - } - - if (e instanceof GitNotFoundError) { - ctx.logger.error(e.message) - return ctx - } - - if ( - e instanceof IncludeTaskLocationDisabledError - || e instanceof RangeLocationFilterProvidedError - || e instanceof LocationFilterFileNotFoundError - ) { - ctx.logger.printError(e, { verbose: false }) - return ctx - } - - process.exitCode = 1 - ctx.logger.printError(e, { fullStack: true, type: 'Unhandled Error' }) - ctx.logger.error('\n\n') + reportStartError(ctx, e) return ctx } finally { @@ -188,6 +176,30 @@ export async function startVitest( } } +function reportStartError(ctx: Vitest, error: unknown): void { + if (error instanceof FilesNotFoundError) { + return + } + + if (error instanceof GitNotFoundError) { + ctx.logger.error(error.message) + return + } + + if ( + error instanceof IncludeTaskLocationDisabledError + || error instanceof RangeLocationFilterProvidedError + || error instanceof LocationFilterFileNotFoundError + ) { + ctx.logger.printError(error, { verbose: false }) + return + } + + process.exitCode = 1 + ctx.logger.printError(error, { fullStack: true, type: 'Unhandled Error' }) + ctx.logger.error('\n\n') +} + export async function prepareVitest( options?: CliOptions, viteOverrides?: ViteUserConfig, diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 80864151f..331435a56 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -347,7 +347,10 @@ export class Vitest { || this.projects.some(p => p.vite.config.configFile === file) || this.config._containerConfigFiles?.includes(file) if (isConfig) { - await this._restart('config') + // a floating rejection in an event handler would crash the process + await this._restart('config').catch((error) => { + this.logger.printError(error, { fullStack: true, type: 'Restart Error' }) + }) } }) diff --git a/packages/vitest/src/runtime/workers/base.ts b/packages/vitest/src/runtime/workers/base.ts index 7adf3d78b..623b926e9 100644 --- a/packages/vitest/src/runtime/workers/base.ts +++ b/packages/vitest/src/runtime/workers/base.ts @@ -27,10 +27,12 @@ async function startModuleRunner(options: ContextModuleRunnerOptions): Promise getSafeWorkerState() || options.state + process.exit = (code = process.exitCode || 0): never => { - throw new Error(`process.exit unexpectedly called with "${code}"`) + const filepath = state().filepath + throw new Error(`process.exit unexpectedly called with "${code}"${filepath ? ` (test file: ${filepath})` : ''}`) } - const state = () => getSafeWorkerState() || options.state listenForErrors(state) diff --git a/packages/vitest/src/runtime/workers/init-forks.ts b/packages/vitest/src/runtime/workers/init-forks.ts index ffb8bf9b4..86de7b90e 100644 --- a/packages/vitest/src/runtime/workers/init-forks.ts +++ b/packages/vitest/src/runtime/workers/init-forks.ts @@ -41,20 +41,15 @@ export default function workerInit(options: { teardown: () => { processRemoveAllListeners('message') processOff('error', onError) + // the guard installed by the test runner stays active between test + // files: with `isolate: false` a late process.exit would kill the + // other files sharing this process + process.exit = processExit }, - runTests: (state, traces) => executeTests('run', state, traces), - collectTests: (state, traces) => executeTests('collect', state, traces), + runTests: (state, traces) => runTests('run', state, traces), + collectTests: (state, traces) => runTests('collect', state, traces), setup: options.setup, }) - - async function executeTests(method: 'run' | 'collect', state: WorkerGlobalState, traces: Traces) { - try { - await runTests(method, state, traces) - } - finally { - process.exit = processExit - } - } } // Prevent leaving worker in loops where it tries to send message to closed main diff --git a/packages/vitest/src/runtime/workers/vm.ts b/packages/vitest/src/runtime/workers/vm.ts index d1510d086..7849497ca 100644 --- a/packages/vitest/src/runtime/workers/vm.ts +++ b/packages/vitest/src/runtime/workers/vm.ts @@ -119,7 +119,8 @@ export async function runVmTests(method: 'run' | 'collect', state: WorkerGlobalS }) process.exit = (code = process.exitCode || 0): never => { - throw new Error(`process.exit unexpectedly called with "${code}"`) + const filepath = state.filepath + throw new Error(`process.exit unexpectedly called with "${code}"${filepath ? ` (test file: ${filepath})` : ''}`) } listenForErrors(() => state) diff --git a/test/e2e/test/cli-config.test.ts b/test/e2e/test/cli-config.test.ts index 0311ea79c..d06386d96 100644 --- a/test/e2e/test/cli-config.test.ts +++ b/test/e2e/test/cli-config.test.ts @@ -1,5 +1,5 @@ import { resolve } from 'pathe' -import { expect, it, test } from 'vitest' +import { expect, it, onTestFinished, test } from 'vitest' import { createVitest } from 'vitest/node' import { runVitest, useFS } from '../../test-utils' @@ -7,6 +7,7 @@ test('can pass down the config as a module', async () => { const vitest = await createVitest('test', { config: '@test/test-dep-config', }) + onTestFinished(() => vitest.close()) expect(vitest.vite.config.configFile).toBe( resolve(import.meta.dirname, '../deps/test-dep-config/index.js'), diff --git a/test/e2e/test/config/browser-configs.test.ts b/test/e2e/test/config/browser-configs.test.ts index 54752f02a..d17ba2c2e 100644 --- a/test/e2e/test/config/browser-configs.test.ts +++ b/test/e2e/test/config/browser-configs.test.ts @@ -42,8 +42,12 @@ const vitest = vi.defineHelper(async (options: TestUserConfig & { $viteConfig?: vitestOptions, ) onTestFinished(async () => { - await vitest.vite.waitForRequestsIdle() - await vitest.close() + try { + await vitest.vite.waitForRequestsIdle() + } + finally { + await vitest.close() + } }) return vitest }) -- 2.51.2