From 54f3cee27827561b38dd011cfcc132ab8bdd2ee2 Mon Sep 17 00:00:00 2001 From: christopher-buss Date: Tue, 25 Aug 2026 20:01:55 +0100 Subject: [PATCH 1/3] fix(typecheck): stop hanging project checkers Co-authored-by: Codex --- .../vitest/src/node/config/resolveConfig.ts | 1 + packages/vitest/src/typecheck/typechecker.ts | 43 +++++++++++- test/e2e/test/config/root.test.ts | 25 +++++++ test/typescript/test/typechecker.test.ts | 67 ++++++++++++++++++- 4 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index 8e826edd88ce..cd20f1359932 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -246,6 +246,7 @@ export function resolveTestConfig( resolved.coverage = globalConfig.coverage resolved.attachmentsDir = globalConfig.attachmentsDir resolved.mergeReportsLabel = globalConfig.mergeReportsLabel + resolved.watch = globalConfig.watch } const rootStats = statSync(resolved.root, { throwIfNoEntry: false }) diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index 9355db5b1fa7..e5c13e9043d5 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -273,8 +273,46 @@ export class Typechecker { } public async stop(): Promise { - this.process?.kill() - this.process = undefined + const child = this.process + if (!child) { + return + } + + child.stdout?.destroy() + child.stderr?.destroy() + + let treeKill: Result | undefined + try { + if (process.platform === 'win32' && child.pid != null) { + treeKill = x('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + nodeOptions: { stdio: 'ignore' }, + throwOnError: false, + }) + } + else if (child.pid != null) { + try { + process.kill(-child.pid) + } + catch { + child.kill() + } + } + else { + child.kill() + } + } + finally { + this.process = undefined + } + + if (treeKill) { + try { + await treeKill + } + catch { + child.kill() + } + } } protected async ensurePackageInstalled(ctx: Vitest, checker: string): Promise { @@ -343,6 +381,7 @@ export class Typechecker { const child = x(typecheck.checker, args, { nodeOptions: { cwd: root, + detached: process.platform !== 'win32', stdio: 'pipe', }, throwOnError: false, diff --git a/test/e2e/test/config/root.test.ts b/test/e2e/test/config/root.test.ts index 93d4b53442aa..41c845c4222a 100644 --- a/test/e2e/test/config/root.test.ts +++ b/test/e2e/test/config/root.test.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto' import { relative, resolve } from 'pathe' import { expect, test } from 'vitest' +import { configDefaults } from 'vitest/config' import { resolveConfig } from 'vitest/node' import { runVitest, ts, useFS, useTmpFS } from '#test-utils' @@ -60,6 +61,30 @@ test('watch mode re-resolves `test.root` when the config changes', async () => { await expect.poll(() => ctx?.config.root, { timeout: 5000 }).toBe(resolve(fs.root, 'nested2')) }) +test('projects inherit the root watch mode', async () => { + const fs = useTmpFS({ + './vitest.config.ts': ts` + import { defineConfig } from 'vitest/config' + + export default defineConfig({ + test: { + projects: [{ test: { name: 'project' } }], + }, + }) + `, + }) + + const config = await resolveConfig({ + config: fs.resolveFile('./vitest.config.ts'), + watch: !configDefaults.watch, + }) + + expect(config.test.watch).toBe(!configDefaults.watch) + expect(config.test.resolvedProjects.map(project => project.projectConfig.watch)).toEqual([ + config.test.watch, + ]) +}) + test('`--root` overrides `test.root` from the config file', async () => { const fs = useTmpFS({ './vitest.config.ts': testRootConfig, diff --git a/test/typescript/test/typechecker.test.ts b/test/typescript/test/typechecker.test.ts index 9e53a191a3bb..955ac9790ebd 100644 --- a/test/typescript/test/typechecker.test.ts +++ b/test/typescript/test/typechecker.test.ts @@ -1,6 +1,17 @@ +import fs from 'node:fs' import { resolve } from 'pathe' import { describe, expect, it } from 'vitest' -import { runVitest } from '../../test-utils' +import { runInlineTests, runVitest, ts } from '../../test-utils' + +function isProcessRunning(pid: number) { + try { + process.kill(pid, 0) + return true + } + catch { + return false + } +} describe('Typechecker', () => { it('handles non-existing typechecker command gracefully', async () => { @@ -40,4 +51,58 @@ describe('Typechecker', () => { expect(stderr).toContain('before type checking finished') expect(stderr).toContain('ran out of memory') }) + + it('stops the typechecker process tree', async () => { + const { ctx, root } = await runInlineTests({ + 'vitest.config.mjs': ts` + import { resolve } from 'node:path' + + export default { + test: { + typecheck: { + enabled: true, + only: true, + checker: resolve( + import.meta.dirname, + process.platform === 'win32' ? 'fake-checker.cmd' : 'fake-checker.mjs', + ), + }, + }, + } + `, + 'fake-checker.cmd': '@node "%~dp0fake-checker.mjs" %*', + 'fake-checker.mjs': ts`#!/usr/bin/env node + import { spawn } from 'node:child_process' + import { writeFileSync } from 'node:fs' + import { resolve } from 'node:path' + + const child = spawn( + process.execPath, + ['-e', 'setInterval(() => {}, 1_000)'], + { stdio: 'inherit' }, + ) + writeFileSync(resolve(process.cwd(), 'checker-child.pid'), String(child.pid)) + process.stdout.write('Found 0 errors. Watching for file changes.\n') + setInterval(() => {}, 1_000) + `, + 'test/foo.test-d.ts': '', + }, { + watch: true, + }) + + const pidFile = resolve(root, 'checker-child.pid') + await expect.poll(() => fs.existsSync(pidFile), { timeout: 5000 }).toBe(true) + const childPid = Number(fs.readFileSync(pidFile, 'utf8')) + + try { + expect(isProcessRunning(childPid)).toBe(true) + await ctx!.close() + await expect.poll(() => isProcessRunning(childPid), { timeout: 5000 }).toBe(false) + } + finally { + if (isProcessRunning(childPid)) { + process.kill(childPid) + } + } + }) }) From 39cfff599bafb6282ca152786e8f5521f469c461 Mon Sep 17 00:00:00 2001 From: christopher-buss Date: Wed, 26 Aug 2026 00:15:57 +0100 Subject: [PATCH 2/3] fix(typecheck): harden checker shutdown Co-authored-by: Codex --- packages/vitest/src/typecheck/typechecker.ts | 5 +- test/typescript/test/typechecker.test.ts | 60 +++++++++++++++----- 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index e5c13e9043d5..a0a50b787908 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -287,6 +287,7 @@ export class Typechecker { treeKill = x('taskkill', ['/pid', String(child.pid), '/T', '/F'], { nodeOptions: { stdio: 'ignore' }, throwOnError: false, + timeout: 5000, }) } else if (child.pid != null) { @@ -307,7 +308,9 @@ export class Typechecker { if (treeKill) { try { - await treeKill + if ((await treeKill).exitCode !== 0) { + child.kill() + } } catch { child.kill() diff --git a/test/typescript/test/typechecker.test.ts b/test/typescript/test/typechecker.test.ts index 955ac9790ebd..18d8a98d6d22 100644 --- a/test/typescript/test/typechecker.test.ts +++ b/test/typescript/test/typechecker.test.ts @@ -8,9 +8,29 @@ function isProcessRunning(pid: number) { process.kill(pid, 0) return true } + catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ESRCH') { + return false + } + throw error + } +} + +function readPids(pidFile: string) { + try { + const pids: unknown = JSON.parse(fs.readFileSync(pidFile, 'utf8')) + if ( + Array.isArray(pids) + && pids.length === 2 + && pids.every((pid): pid is number => Number.isInteger(pid) && pid > 0) + ) { + return pids + } + } catch { - return false + // The checker may still be writing the file. } + return [] } describe('Typechecker', () => { @@ -55,17 +75,23 @@ describe('Typechecker', () => { it('stops the typechecker process tree', async () => { const { ctx, root } = await runInlineTests({ 'vitest.config.mjs': ts` + import { chmodSync } from 'node:fs' import { resolve } from 'node:path' + const checker = resolve( + import.meta.dirname, + process.platform === 'win32' ? 'fake-checker.cmd' : 'fake-checker.mjs', + ) + if (process.platform !== 'win32') { + chmodSync(checker, 0o755) + } + export default { test: { typecheck: { enabled: true, only: true, - checker: resolve( - import.meta.dirname, - process.platform === 'win32' ? 'fake-checker.cmd' : 'fake-checker.mjs', - ), + checker, }, }, } @@ -81,7 +107,10 @@ describe('Typechecker', () => { ['-e', 'setInterval(() => {}, 1_000)'], { stdio: 'inherit' }, ) - writeFileSync(resolve(process.cwd(), 'checker-child.pid'), String(child.pid)) + writeFileSync( + resolve(process.cwd(), 'checker-pids.json'), + JSON.stringify([process.pid, child.pid]), + ) process.stdout.write('Found 0 errors. Watching for file changes.\n') setInterval(() => {}, 1_000) `, @@ -90,18 +119,23 @@ describe('Typechecker', () => { watch: true, }) - const pidFile = resolve(root, 'checker-child.pid') - await expect.poll(() => fs.existsSync(pidFile), { timeout: 5000 }).toBe(true) - const childPid = Number(fs.readFileSync(pidFile, 'utf8')) + const pidFile = resolve(root, 'checker-pids.json') + await expect.poll(() => readPids(pidFile), { timeout: 5000 }).toHaveLength(2) + const pids = readPids(pidFile) try { - expect(isProcessRunning(childPid)).toBe(true) + expect(pids.map(isProcessRunning)).toEqual([true, true]) await ctx!.close() - await expect.poll(() => isProcessRunning(childPid), { timeout: 5000 }).toBe(false) + await expect.poll( + () => pids.map(isProcessRunning), + { timeout: 5000 }, + ).toEqual([false, false]) } finally { - if (isProcessRunning(childPid)) { - process.kill(childPid) + for (const pid of pids.reverse()) { + if (isProcessRunning(pid)) { + process.kill(pid) + } } } }) From f599d6900340946786c341d54c466aa448e587be Mon Sep 17 00:00:00 2001 From: christopher-buss Date: Thu, 27 Aug 2026 17:16:42 +0100 Subject: [PATCH 3/3] refactor: extract process tree and move test utils --- packages/vitest/src/typecheck/typechecker.ts | 67 +++++++------- test/typescript/test/typechecker.test.ts | 93 ++++++++++---------- 2 files changed, 80 insertions(+), 80 deletions(-) diff --git a/packages/vitest/src/typecheck/typechecker.ts b/packages/vitest/src/typecheck/typechecker.ts index a0a50b787908..23a510a9d519 100644 --- a/packages/vitest/src/typecheck/typechecker.ts +++ b/packages/vitest/src/typecheck/typechecker.ts @@ -274,48 +274,16 @@ export class Typechecker { public async stop(): Promise { const child = this.process + this.process = undefined if (!child) { return } + // the open pipes keep the main process alive even after the checker is gone child.stdout?.destroy() child.stderr?.destroy() - let treeKill: Result | undefined - try { - if (process.platform === 'win32' && child.pid != null) { - treeKill = x('taskkill', ['/pid', String(child.pid), '/T', '/F'], { - nodeOptions: { stdio: 'ignore' }, - throwOnError: false, - timeout: 5000, - }) - } - else if (child.pid != null) { - try { - process.kill(-child.pid) - } - catch { - child.kill() - } - } - else { - child.kill() - } - } - finally { - this.process = undefined - } - - if (treeKill) { - try { - if ((await treeKill).exitCode !== 0) { - child.kill() - } - } - catch { - child.kill() - } - } + await killProcessTree(child) } protected async ensurePackageInstalled(ctx: Vitest, checker: string): Promise { @@ -565,3 +533,32 @@ function findGeneratedPosition(traceMap: TraceMap, { line, column, source }: { l } return { line: null, column: null } } + +async function killProcessTree(child: ChildProcess): Promise { + if (child.pid == null || child.exitCode !== null || child.signalCode !== null) { + child.kill() + return + } + + // Windows has no process groups, so `taskkill` walks the tree instead + if (process.platform === 'win32') { + const killed = await x('taskkill', ['/pid', String(child.pid), '/T', '/F'], { + nodeOptions: { stdio: 'ignore' }, + throwOnError: false, + timeout: 5000, + }).then(result => result.exitCode === 0, () => false) + + if (!killed) { + child.kill() + } + return + } + + // `prepare` spawns detached, which makes the child the leader of its own group + try { + process.kill(-child.pid) + } + catch { + child.kill() + } +} diff --git a/test/typescript/test/typechecker.test.ts b/test/typescript/test/typechecker.test.ts index 18d8a98d6d22..805be1d61241 100644 --- a/test/typescript/test/typechecker.test.ts +++ b/test/typescript/test/typechecker.test.ts @@ -1,38 +1,8 @@ import fs from 'node:fs' import { resolve } from 'pathe' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { runInlineTests, runVitest, ts } from '../../test-utils' -function isProcessRunning(pid: number) { - try { - process.kill(pid, 0) - return true - } - catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ESRCH') { - return false - } - throw error - } -} - -function readPids(pidFile: string) { - try { - const pids: unknown = JSON.parse(fs.readFileSync(pidFile, 'utf8')) - if ( - Array.isArray(pids) - && pids.length === 2 - && pids.every((pid): pid is number => Number.isInteger(pid) && pid > 0) - ) { - return pids - } - } - catch { - // The checker may still be writing the file. - } - return [] -} - describe('Typechecker', () => { it('handles non-existing typechecker command gracefully', async () => { const { stderr } = await runVitest({ @@ -120,23 +90,56 @@ describe('Typechecker', () => { }) const pidFile = resolve(root, 'checker-pids.json') - await expect.poll(() => readPids(pidFile), { timeout: 5000 }).toHaveLength(2) - const pids = readPids(pidFile) - - try { - expect(pids.map(isProcessRunning)).toEqual([true, true]) - await ctx!.close() - await expect.poll( - () => pids.map(isProcessRunning), - { timeout: 5000 }, - ).toEqual([false, false]) - } - finally { + let pids: number[] = [] + onTestFinished(() => { for (const pid of pids.reverse()) { - if (isProcessRunning(pid)) { + if (isProcessAlive(pid)) { process.kill(pid) } } - } + }) + + await expect.poll(() => { + pids = readPids(pidFile) + return pids + }, { timeout: 5000 }).toHaveLength(2) + + expect(pids.map(isProcessAlive)).toEqual([true, true]) + await ctx!.close() + await expect.poll( + () => pids.map(isProcessAlive), + { timeout: 5000 }, + ).toEqual([false, false]) }) }) + +function isProcessAlive(pid: number): boolean { + try { + // Sending signal 0 checks if the process exists without actually killing it: + // https://nodejs.org/api/process.html#processkillpid-signal + process.kill(pid, 0) + return true + } + catch (error) { + // ESRCH means the process is gone. Treat anything else (e.g. EPERM) as alive + // so we never reclaim a lock from a process that is still running. + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +function readPids(pidFile: string) { + try { + const pids = JSON.parse(fs.readFileSync(pidFile, 'utf8')) + if ( + Array.isArray(pids) + && pids.length === 2 + && pids.every((pid): pid is number => Number.isInteger(pid) && pid > 0) + ) { + return pids + } + } + catch { + // The checker may still be writing the file. + } + return [] +}