|
1 | | -// Case execution with hang detection for the parser fuzz lane (#1414). |
| 1 | +// Case execution with hang and crash detection for the parser fuzz lane (#1414). |
2 | 2 | // |
3 | | -// Cases run one at a time in a worker thread: a synchronous parser that never returns cannot be |
4 | | -// timed out from inside its own tick, so the budget is enforced from *another* thread, which can |
5 | | -// terminate the wedged one and attribute the stall to the exact input. Cases go over the wire |
6 | | -// individually (rather than as one batch) because fast-check drives the loop — it decides the next |
7 | | -// input, including the shrink candidates it derives from a failing one. |
| 3 | +// Cases run one at a time in a child process, and the budget is enforced from the parent. Two |
| 4 | +// faults a case can produce cannot be observed from inside the context running it: a synchronous |
| 5 | +// parser that never returns cannot be timed out from its own tick, and a parser that faults the |
| 6 | +// process — a native crash, an out-of-memory abort — leaves no stack to catch. Both are |
| 7 | +// attributed here, to the exact input, by watching a process the parent can outlive. |
| 8 | +// |
| 9 | +// It is a process rather than a worker thread because a thread cannot contain the second fault: |
| 10 | +// a fault in a worker thread takes its whole process down, and that process is the caller's — |
| 11 | +// for the unit-lane replay, the Vitest worker running the test file, which then dies with no |
| 12 | +// test, file, or case named (#2053). Cases go over the wire individually (rather than as one |
| 13 | +// batch) because fast-check drives the loop — it decides the next input, including the shrink |
| 14 | +// candidates it derives from a failing one. |
8 | 15 |
|
| 16 | +import { fork, type ChildProcess } from 'node:child_process'; |
9 | 17 | import path from 'node:path'; |
10 | 18 | import { fileURLToPath } from 'node:url'; |
11 | | -import { Worker } from 'node:worker_threads'; |
12 | 19 | import type { FuzzFailure } from './invariant.ts'; |
13 | 20 | import type { FuzzTarget } from './target-types.ts'; |
14 | | -import type { FuzzWorkerData, FuzzWorkerMessage } from './worker.ts'; |
| 21 | +import type { FuzzWorkerMessage } from './worker.ts'; |
15 | 22 |
|
16 | 23 | const WORKER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.ts'); |
17 | 24 |
|
18 | 25 | /** Bound on worker startup only; a per-case budget must never be charged for it. */ |
19 | 26 | const STARTUP_BUDGET_MS = 60_000; |
20 | 27 |
|
| 28 | +/** How much of a dead worker's output a crash failure quotes. Enough for a V8 fatal banner. */ |
| 29 | +const DEATH_OUTPUT_LIMIT = 2_000; |
| 30 | + |
21 | 31 | export type CaseRunner = { |
22 | 32 | /** The failure this input violates the invariant with, or `null` when it holds. */ |
23 | 33 | run: (input: string) => Promise<FuzzFailure | null>; |
24 | 34 | close: () => Promise<void>; |
25 | 35 | }; |
26 | 36 |
|
27 | | -type Session = { worker: Worker; ready: Promise<void> }; |
| 37 | +/** A started worker: it has answered the handshake, so a budget may be charged to it. */ |
| 38 | +type Session = { |
| 39 | + child: ChildProcess; |
| 40 | + /** |
| 41 | + * How the process died, or `null` while it is alive — the death certificate a crash failure |
| 42 | + * quotes. A reader rather than a field because the session's own listeners write it. |
| 43 | + */ |
| 44 | + death: () => string | null; |
| 45 | +}; |
| 46 | + |
| 47 | +/** |
| 48 | + * Everything a worker can do next, and all either wait below has to classify: answer, die, or |
| 49 | + * stay silent for the whole budget. The handshake and a case are the same wait against different |
| 50 | + * budgets, so they share one primitive and differ only in what they make of its three outcomes. |
| 51 | + */ |
| 52 | +type WorkerOutcome = |
| 53 | + | { kind: 'message'; message: FuzzWorkerMessage } |
| 54 | + | { kind: 'death' } |
| 55 | + | { kind: 'silence' }; |
| 56 | + |
| 57 | +function awaitWorker(child: ChildProcess, budgetMs: number): Promise<WorkerOutcome> { |
| 58 | + return new Promise((resolve) => { |
| 59 | + const finish = (outcome: WorkerOutcome) => { |
| 60 | + clearTimeout(timer); |
| 61 | + child.off('message', onMessage); |
| 62 | + child.off('close', onDeath); |
| 63 | + child.off('error', onDeath); |
| 64 | + resolve(outcome); |
| 65 | + }; |
| 66 | + const onMessage = (message: FuzzWorkerMessage) => finish({ kind: 'message', message }); |
| 67 | + const onDeath = () => finish({ kind: 'death' }); |
| 68 | + const timer = setTimeout(() => finish({ kind: 'silence' }), budgetMs); |
| 69 | + child.once('message', onMessage); |
| 70 | + child.once('close', onDeath); |
| 71 | + child.once('error', onDeath); |
| 72 | + }); |
| 73 | +} |
| 74 | + |
| 75 | +function describeDeath(code: number | null, signal: NodeJS.Signals | null, output: string): string { |
| 76 | + const how = signal === null ? `exit code ${code}` : `signal ${signal}`; |
| 77 | + const tail = output.trim(); |
| 78 | + return tail === '' ? how : `${how}: ${tail}`; |
| 79 | +} |
| 80 | + |
| 81 | +/** Why a started worker is unusable, or `null` when it handed back the handshake. */ |
| 82 | +function startupRefusal(outcome: WorkerOutcome, death: string | null): string | null { |
| 83 | + if (outcome.kind === 'silence') return `fuzz worker did not start within ${STARTUP_BUDGET_MS}ms`; |
| 84 | + if (outcome.kind === 'death') return `fuzz worker died before it was ready (${death})`; |
| 85 | + if (outcome.message.kind !== 'ready') { |
| 86 | + return `unexpected first worker message: ${outcome.message.kind}`; |
| 87 | + } |
| 88 | + return null; |
| 89 | +} |
28 | 90 |
|
29 | | -/** A worker plus the promise that settles when it has finished importing the parsers. */ |
30 | | -function startSession(targetName: string): Session { |
31 | | - const workerData: FuzzWorkerData = { targetName }; |
32 | | - // Type stripping is requested explicitly rather than inherited: under Vitest the parent's |
33 | | - // execArgv carries no such flag, and the worker is a plain `.ts` file Node must strip itself. |
34 | | - // The warning is silenced because a restarted worker re-emits it — one per hang buries the report. |
35 | | - const worker = new Worker(WORKER_PATH, { |
36 | | - workerData, |
| 91 | +/** Starts a worker and resolves once it has finished importing the parsers. */ |
| 92 | +async function startSession(targetName: string): Promise<Session> { |
| 93 | + const child = fork(WORKER_PATH, [targetName], { |
| 94 | + // Type stripping is requested explicitly rather than inherited: under Vitest the parent's |
| 95 | + // execArgv carries no such flag, and the worker is a plain `.ts` file Node must strip itself. |
| 96 | + // The warning is silenced because a restarted worker re-emits it — one per hang buries the report. |
37 | 97 | execArgv: ['--experimental-strip-types', '--disable-warning=ExperimentalWarning'], |
| 98 | + // stderr is piped rather than inherited so a fatal fault — which writes to the descriptor and |
| 99 | + // so escapes the worker's own silencing — is quoted in the failure instead of the lane's log. |
| 100 | + stdio: ['ignore', 'inherit', 'pipe', 'ipc'], |
38 | 101 | }); |
39 | | - const ready = new Promise<void>((resolve, reject) => { |
40 | | - const timer = setTimeout( |
41 | | - () => reject(new Error(`fuzz worker did not start within ${STARTUP_BUDGET_MS}ms`)), |
42 | | - STARTUP_BUDGET_MS, |
43 | | - ); |
44 | | - worker.once('message', (message: FuzzWorkerMessage) => { |
45 | | - clearTimeout(timer); |
46 | | - if (message.kind === 'ready') resolve(); |
47 | | - else reject(new Error(`unexpected first worker message: ${message.kind}`)); |
48 | | - }); |
49 | | - worker.once('error', (error) => { |
50 | | - clearTimeout(timer); |
51 | | - reject(error); |
52 | | - }); |
| 102 | + let death: string | null = null; |
| 103 | + let output = ''; |
| 104 | + child.stderr?.setEncoding('utf8'); |
| 105 | + child.stderr?.on('data', (chunk: string) => { |
| 106 | + output = (output + chunk).slice(-DEATH_OUTPUT_LIMIT); |
| 107 | + }); |
| 108 | + // A ChildProcess with no `error` listener raises the error at the process, which is the fault |
| 109 | + // this module exists to keep off the caller. The message is evidence rather than the |
| 110 | + // certificate — `close` is the certificate whenever the process ran at all, and quotes this |
| 111 | + // tail — so the fallback below only stands for a worker that could not be spawned, the one |
| 112 | + // case where `close` never comes. |
| 113 | + child.on('error', (error) => { |
| 114 | + output = `${output}${error.message}\n`.slice(-DEATH_OUTPUT_LIMIT); |
| 115 | + death ??= `spawn failed: ${error.message}`; |
53 | 116 | }); |
54 | | - return { worker, ready }; |
| 117 | + // `close` rather than `exit`: it fires once the piped stderr has drained too, so the death is |
| 118 | + // recorded with the output that explains it. |
| 119 | + child.once('close', (code: number | null, signal: NodeJS.Signals | null) => { |
| 120 | + death = describeDeath(code, signal, output); |
| 121 | + }); |
| 122 | + const session: Session = { child, death: () => death }; |
| 123 | + const outcome = await awaitWorker(child, STARTUP_BUDGET_MS); |
| 124 | + const refusal = startupRefusal(outcome, session.death()); |
| 125 | + if (refusal === null) return session; |
| 126 | + // Every refusing path ends the worker, including the silent one — a worker still importing |
| 127 | + // holds the channel open, and nothing else would ever come back for it. |
| 128 | + await endSession(session); |
| 129 | + throw new Error(refusal); |
| 130 | +} |
| 131 | + |
| 132 | +/** Ends a worker process, and resolves once it is gone. Already-dead sessions are a no-op. */ |
| 133 | +async function endSession(session: Session): Promise<void> { |
| 134 | + if (session.death() !== null) return; |
| 135 | + const gone = new Promise<void>((resolve) => session.child.once('close', () => resolve())); |
| 136 | + // SIGKILL, not SIGTERM: a session is usually ended because a case wedged the worker, and a |
| 137 | + // process spinning inside a synchronous parser is exactly the one a catchable signal cannot reach. |
| 138 | + session.child.kill('SIGKILL'); |
| 139 | + await gone; |
55 | 140 | } |
56 | 141 |
|
| 142 | +/** A case's verdict, plus whether the worker can take another one. */ |
| 143 | +type CaseOutcome = { failure: FuzzFailure | null; usable: boolean }; |
| 144 | + |
57 | 145 | /** |
58 | 146 | * Runs one case on a started worker. Startup is already awaited by the caller, so the budget below |
59 | 147 | * covers parser time only — the reason a slow import can never be misreported as a parser hang. |
| 148 | + * |
| 149 | + * A worker that hung is wedged in this case and one that died is gone, so neither is `usable`. |
60 | 150 | */ |
61 | | -function runOnSession( |
| 151 | +async function runCase( |
62 | 152 | session: Session, |
63 | 153 | target: FuzzTarget, |
64 | 154 | input: string, |
65 | 155 | caseTimeoutMs: number, |
66 | | -): Promise<{ failure: FuzzFailure | null; hung: boolean }> { |
67 | | - return new Promise((resolve, reject) => { |
68 | | - const settle = (failure: FuzzFailure | null, hung: boolean) => { |
69 | | - clearTimeout(timer); |
70 | | - session.worker.off('message', onMessage); |
71 | | - session.worker.off('error', onError); |
72 | | - resolve({ failure, hung }); |
73 | | - }; |
74 | | - const onMessage = (message: FuzzWorkerMessage) => { |
75 | | - if (message.kind === 'result') settle(message.failure, false); |
76 | | - }; |
77 | | - const onError = (error: Error) => { |
78 | | - clearTimeout(timer); |
79 | | - session.worker.off('message', onMessage); |
80 | | - reject(error); |
81 | | - }; |
82 | | - const timer = setTimeout(() => { |
83 | | - settle( |
84 | | - { |
85 | | - target: target.name, |
86 | | - input, |
87 | | - kind: 'hang', |
88 | | - detail: `case did not finish within ${caseTimeoutMs}ms`, |
89 | | - }, |
90 | | - true, |
91 | | - ); |
92 | | - }, caseTimeoutMs); |
93 | | - session.worker.on('message', onMessage); |
94 | | - session.worker.once('error', onError); |
95 | | - session.worker.postMessage({ kind: 'case', input }); |
| 156 | +): Promise<CaseOutcome> { |
| 157 | + const failed = (kind: 'hang' | 'crash', detail: string): CaseOutcome => ({ |
| 158 | + failure: { target: target.name, input, kind, detail }, |
| 159 | + usable: false, |
96 | 160 | }); |
| 161 | + const crashed = () => failed('crash', `case killed the worker process (${session.death()})`); |
| 162 | + // A worker that died between cases is reported against the case that meets it, rather than |
| 163 | + // through the ERR_IPC_CHANNEL_CLOSED that sending to it would raise: the harness classifies |
| 164 | + // every death of a worker it was using, and an opaque transport error is not a classification. |
| 165 | + if (session.death() !== null) return crashed(); |
| 166 | + // Armed before the case is sent, so an answer cannot arrive unobserved. |
| 167 | + const answer = awaitWorker(session.child, caseTimeoutMs); |
| 168 | + session.child.send({ kind: 'case', input }); |
| 169 | + const outcome = await answer; |
| 170 | + if (outcome.kind === 'death') return crashed(); |
| 171 | + if (outcome.kind === 'silence') { |
| 172 | + return failed('hang', `case did not finish within ${caseTimeoutMs}ms`); |
| 173 | + } |
| 174 | + if (outcome.message.kind !== 'result') { |
| 175 | + throw new Error(`unexpected worker message: ${outcome.message.kind}`); |
| 176 | + } |
| 177 | + return { failure: outcome.message.failure, usable: true }; |
97 | 178 | } |
98 | 179 |
|
99 | 180 | /** |
100 | | - * A worker-backed runner for one target. A hang leaves the thread wedged in its own loop, so the |
101 | | - * runner terminates it and starts a fresh one for the next case — otherwise a single hang would |
102 | | - * silently turn every later case into a hang too. |
| 181 | + * A worker-backed runner for one target. A hang leaves the worker wedged in its own loop and a |
| 182 | + * crash leaves no worker at all, so the runner ends the session and starts a fresh one for the |
| 183 | + * next case — otherwise a single such case would silently turn every later case into one too. |
103 | 184 | */ |
104 | 185 | export async function createCaseRunner( |
105 | 186 | target: FuzzTarget, |
106 | 187 | caseTimeoutMs: number, |
107 | 188 | ): Promise<CaseRunner> { |
108 | | - let session = startSession(target.name); |
109 | | - await session.ready; |
| 189 | + let session = await startSession(target.name); |
110 | 190 | let closed = false; |
111 | 191 |
|
112 | 192 | return { |
113 | 193 | run: async (input) => { |
114 | 194 | if (closed) throw new Error('fuzz case runner is closed'); |
115 | | - await session.ready; |
116 | | - const { failure, hung } = await runOnSession(session, target, input, caseTimeoutMs); |
117 | | - if (hung) { |
118 | | - await session.worker.terminate(); |
119 | | - session = startSession(target.name); |
120 | | - await session.ready; |
| 195 | + const { failure, usable } = await runCase(session, target, input, caseTimeoutMs); |
| 196 | + if (!usable) { |
| 197 | + await endSession(session); |
| 198 | + session = await startSession(target.name); |
121 | 199 | } |
122 | 200 | return failure; |
123 | 201 | }, |
124 | 202 | close: async () => { |
125 | 203 | closed = true; |
126 | | - await session.worker.terminate(); |
| 204 | + await endSession(session); |
127 | 205 | }, |
128 | 206 | }; |
129 | 207 | } |
|
0 commit comments