@@ -4,33 +4,85 @@ import util from 'node:util';
44import { afterAll , afterEach , expect } from 'vitest' ;
55
66// Unit tests must be hermetic with respect to the host's process table: a
7- // worker may signal only itself and the processes it spawned . Anything else is
7+ // worker may signal only itself and its own direct children . Anything else is
88// a pid the test made up (`child: { pid: 4242 }`), and on a real host that
99// number can belong to anyone — on CI it periodically belonged to a sibling
1010// vitest fork, which died mid-file with no test attributed
11- // ("Worker exited unexpectedly", #1824). Refusing the signal here, in every
11+ // ("Worker exited unexpectedly", #1824). Refusing the write here, in every
1212// worker, turns that silent fork death into a named failure of the test that
1313// sent it, on any host, deterministically.
1414//
15+ // Both ways out of the process are covered, because the runner-disposal family
16+ // uses both: `process.kill` (including the negative pid that addresses a
17+ // child's process group) and a spawned `kill`/`pkill`/`killall`, whose `-P`
18+ // and `-f` forms reach processes this worker never spawned at all.
19+ //
1520// Signal 0 is a liveness probe (`isProcessAlive`), not a signal; it stays free.
16- // A negative pid addresses a process group; it is allowed exactly when the
17- // group leader is a process this worker spawned (`runCmd`'s tree kill).
18- // Tests that drive a real kill path against a fabricated pid mock the signal
19- // seam (`signalPidsBestEffort` / `signalProcessGroupBestEffort` in
20- // `src/utils/host-process.ts`, or `vi.spyOn(process, 'kill')`) the same way
21- // they already mock the liveness reads.
21+ // Tests that drive a real kill path against a fabricated pid mock the seam —
22+ // `signalPidsBestEffort` / `signalProcessGroupBestEffort` in
23+ // `src/utils/host-process.ts` for the direct writes, the exec/tool-provider
24+ // seam for a spawned `pkill` — the same way they already mock the liveness
25+ // reads.
2226
27+ /**
28+ * Direct children only. A grandchild (a server started through a shell or
29+ * `npx` wrapper) is not tracked, so signalling its pid is refused even though
30+ * the test legitimately owns it; signal the direct child, or its process group
31+ * via the negative pid, or mock the seam.
32+ *
33+ * Synchronous spawns (`spawnSync`, `execFileSync`) are deliberately not
34+ * remembered: they have already exited by the time the call returns, so
35+ * remembering them would license a signal to whatever inherits that pid next —
36+ * the very hazard this file closes. Asynchronous children are remembered for
37+ * the worker's lifetime rather than evicted on exit, because tests legitimately
38+ * signal a child they have already reaped (idempotent cleanup); with
39+ * `isolate: true` a worker runs one file, so the set stays small and
40+ * short-lived.
41+ */
2342const ownPids = new Set < number > ( [ process . pid ] ) ;
2443
44+ const KILL_BINARIES = new Set ( [ 'kill' , 'pkill' , 'killall' ] ) ;
45+
46+ const refused : string [ ] = [ ] ;
47+
48+ function record ( what : string ) : void {
49+ const state = expect . getState ( ) ;
50+ const where = new Error ( ) . stack ?. split ( '\n' ) . slice ( 3 , 7 ) . join ( '\n' ) ?? '' ;
51+ refused . push ( `${ what } (${ state . currentTestName ?? 'outside a test' } )\n${ where } ` ) ;
52+ }
53+
2554function rememberChild ( child : unknown ) : void {
2655 const pid = ( child as { pid ?: unknown } | null ) ?. pid ;
2756 if ( typeof pid === 'number' ) ownPids . add ( pid ) ;
2857}
2958
30- function wrapSpawner < T extends ( ...args : never [ ] ) => unknown > ( real : T ) : T {
59+ function killBinaryName ( command : unknown ) : string | undefined {
60+ // `exec` takes a shell command line; the rest take an executable path.
61+ const first = String ( command ) . trim ( ) . split ( / \s + / ) [ 0 ] ?? '' ;
62+ const name = first . split ( '/' ) . pop ( ) ?? '' ;
63+ return KILL_BINARIES . has ( name ) ? name : undefined ;
64+ }
65+
66+ function refuseSpawnedKill ( command : unknown , args : unknown ) : Error | undefined {
67+ const name = killBinaryName ( command ) ;
68+ if ( ! name ) return undefined ;
69+ const rendered = [ String ( command ) , ...( Array . isArray ( args ) ? args . map ( String ) : [ ] ) ] . join ( ' ' ) ;
70+ record ( `spawn ${ rendered } ` ) ;
71+ // Shaped like the binary being unavailable, which every caller of these
72+ // best-effort kills already tolerates.
73+ const error = new Error (
74+ `Refusing to spawn \`${ rendered } \`: a unit test may not signal processes through ${ name } (see afterEach failure).` ,
75+ ) as NodeJS . ErrnoException ;
76+ error . code = 'ENOENT' ;
77+ return error ;
78+ }
79+
80+ function wrapSpawner < T extends ( ...args : never [ ] ) => unknown > ( real : T , remember : boolean ) : T {
3181 const wrapped = ( ( ...args : Parameters < T > ) => {
82+ const refusal = refuseSpawnedKill ( args [ 0 ] , args [ 1 ] ) ;
83+ if ( refusal ) throw refusal ;
3284 const child = real ( ...args ) ;
33- rememberChild ( child ) ;
85+ if ( remember ) rememberChild ( child ) ;
3486 return child ;
3587 } ) as unknown as T ;
3688 // `util.promisify(execFile)` resolves through the custom-promisify symbol.
@@ -39,23 +91,26 @@ function wrapSpawner<T extends (...args: never[]) => unknown>(real: T): T {
3991 return wrapped ;
4092}
4193
42- for ( const name of [ 'spawn' , 'spawnSync' , 'fork' , 'exec' , 'execFile' ] as const ) {
43- ( childProcess as unknown as Record < string , unknown > ) [ name ] = wrapSpawner ( childProcess [ name ] ) ;
94+ for ( const name of [ 'spawn' , 'fork' , 'exec' , 'execFile' ] as const ) {
95+ ( childProcess as unknown as Record < string , unknown > ) [ name ] = wrapSpawner (
96+ childProcess [ name ] ,
97+ true ,
98+ ) ;
99+ }
100+ for ( const name of [ 'spawnSync' , 'execFileSync' , 'execSync' ] as const ) {
101+ ( childProcess as unknown as Record < string , unknown > ) [ name ] = wrapSpawner (
102+ childProcess [ name ] ,
103+ false ,
104+ ) ;
44105}
45106// Named ESM imports of the builtin (`import { spawn } from 'node:child_process'`)
46107// bind to the CJS exports only after they are re-synced.
47108module . syncBuiltinESMExports ( ) ;
48109
49- const refused : string [ ] = [ ] ;
50-
51110const realKill = process . kill . bind ( process ) ;
52111process . kill = ( ( pid : number , signal : string | number = 'SIGTERM' ) => {
53112 if ( signal === 0 || ownPids . has ( Math . abs ( pid ) ) ) return realKill ( pid , signal as NodeJS . Signals ) ;
54- const state = expect . getState ( ) ;
55- const where = new Error ( ) . stack ?. split ( '\n' ) . slice ( 2 , 6 ) . join ( '\n' ) ?? '' ;
56- refused . push (
57- `${ String ( signal ) } -> pid ${ pid } (${ state . currentTestName ?? 'outside a test' } )\n${ where } ` ,
58- ) ;
113+ record ( `${ String ( signal ) } -> pid ${ pid } ` ) ;
59114 // Behave like a dead pid: the caller's ESRCH handling runs, no signal leaves
60115 // this worker, and the record above fails the test in afterEach below.
61116 const error = new Error (
@@ -65,17 +120,19 @@ process.kill = ((pid: number, signal: string | number = 'SIGTERM') => {
65120 throw error ;
66121} ) as typeof process . kill ;
67122
68- // Best-effort kill sites swallow the ESRCH above, so the refusal is reported
123+ // Best-effort kill sites swallow the errors above, so the refusal is reported
69124// here, attributed to the test (or file) that caused it, and never lost.
70125function failOnRefusedSignals ( scope : string ) : void {
71126 if ( refused . length === 0 ) return ;
72127 const count = refused . length ;
73128 const report = refused . splice ( 0 ) . join ( '\n' ) ;
74129 throw new Error (
75- `${ scope } tried to send ${ count } real signal(s) to a pid this vitest worker did not spawn. ` +
76- 'A unit test may signal only its own children (and their process groups); mock the signal ' +
77- 'seam in src/utils/host-process.ts (signalPidsBestEffort, signalProcessGroupBestEffort) or ' +
78- `vi.spyOn(process, 'kill') instead.\n${ report } ` ,
130+ `${ scope } tried to signal ${ count } process(es) this vitest worker did not spawn. ` +
131+ 'A unit test may signal only itself and its own direct children (and their process ' +
132+ 'groups); mock the seam instead — signalPidsBestEffort / signalProcessGroupBestEffort in ' +
133+ 'src/utils/host-process.ts, the exec or tool-provider seam for a spawned pkill, or ' +
134+ `vi.spyOn(process, 'kill'). If the pid is a descendant this test really owns, signal the ` +
135+ `direct child (or its group via the negative pid) rather than the grandchild.\n${ report } ` ,
79136 ) ;
80137}
81138
0 commit comments