Skip to content

Commit a904ef0

Browse files
authored
fix(fuzz): run parser cases in a worker process, not the runner's thread (#2053) (#2055)
The unit-lane corpus replay executed adversarial parser cases on worker threads of the Vitest worker running the test file. A fault in a worker thread ends its whole process, so a case that faulted killed the test runner: `[vitest-pool]: Worker forks emitted error / Worker exited unexpectedly`, with no test, file, or case named. Six of six Coverage deaths before #1994's split were this one file out of ~1100, and the uninstrumented second leg it created then lost the same file six more times in three days. Cases now run in a worker *process*. The two faults a case cannot report about itself are both classified from outside it: a case that never returns is a `hang` (unchanged), and one that ends the process it runs in is a new `crash` failure carrying the exit code or signal and the tail of the worker's stderr — the death certificate the lane used to lose. A sixth self-check target seeds that kind, so a regression in reporting it fails the harness self-check like every other kind.
1 parent e205638 commit a904ef0

9 files changed

Lines changed: 235 additions & 102 deletions

File tree

.github/workflows/replays-nightly.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ jobs:
3838
# 38,000 cases/target holds the job's wall-clock at the pre-B2 five-target/50k budget now that
3939
# seven targets share it: measured on a quiet host, 7x38k = 16.5s against 5x50k = 16.6-16.9s.
4040
# Chosen by measurement rather than rounding — 33k would have been 2s cheaper but cost the
41-
# five untouched targets a third of their depth for no wall-clock reason.
41+
# five untouched targets a third of their depth for no wall-clock reason. The count is
42+
# unchanged by #2053; moving case execution out of process cost that run about 3s (17.8s ->
43+
# 20.9s, best of three on one host), which buys a case that faults its process a named
44+
# `crash` failure instead of a dead caller.
4245
nightly-parser-fuzz:
4346
name: Parser Fuzz Lane
4447
runs-on: ubuntu-latest

docs/agents/testing.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ another example. Keep examples for a real past bug or a named decision. Reuse `P
115115
budgets so property files stay inside the unit slow-test gate.
116116

117117
Parser fuzz targets live in `scripts/fuzz/targets.ts`. Validation generators carry the invalid
118-
outcome they planted, so silent acceptance and wrong error codes are failures. Promote a discovered
119-
case with the command the harness prints — never hand-copy an unshrunk input.
118+
outcome they planted, so silent acceptance and wrong error codes are failures. Cases run in a
119+
worker process, so the two faults a case cannot report about itself — never returning, and killing
120+
the process it runs in — are reported as `hang` and `crash` against the exact input rather than
121+
taking the caller down with them. Promote a discovered case with the command the harness prints —
122+
never hand-copy an unshrunk input.
120123

121124
Mutation is report-only and limited to the registry in `scripts/mutation/modules.ts`. It measures
122125
whether tests distinguish changed decision logic. Do not infer redundancy from line coverage alone.

scripts/fuzz/corpus-replay.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
//
33
// The nightly lane finds cases; this replays every case it ever found so a regression fails in
44
// seconds on a PR instead of a night later. Cases go through the same worker-backed watchdog the
5-
// nightly lane uses: a promoted hang case must fail this test against its per-case budget, not
6-
// wedge the unit job until the CI timeout.
5+
// nightly lane uses, and for the same reason they run in a worker *process* (#2053): a promoted
6+
// hang case must fail this test against its per-case budget rather than wedge the unit job, and
7+
// a case that faults the process it runs in must fail this test rather than kill the Vitest
8+
// worker running it — which reports no test, no file, and no case.
79

810
import fc from 'fast-check';
911
import { describe, expect, it } from 'vitest';

scripts/fuzz/execute.ts

Lines changed: 153 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,207 @@
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).
22
//
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.
815

16+
import { fork, type ChildProcess } from 'node:child_process';
917
import path from 'node:path';
1018
import { fileURLToPath } from 'node:url';
11-
import { Worker } from 'node:worker_threads';
1219
import type { FuzzFailure } from './invariant.ts';
1320
import type { FuzzTarget } from './target-types.ts';
14-
import type { FuzzWorkerData, FuzzWorkerMessage } from './worker.ts';
21+
import type { FuzzWorkerMessage } from './worker.ts';
1522

1623
const WORKER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.ts');
1724

1825
/** Bound on worker startup only; a per-case budget must never be charged for it. */
1926
const STARTUP_BUDGET_MS = 60_000;
2027

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+
2131
export type CaseRunner = {
2232
/** The failure this input violates the invariant with, or `null` when it holds. */
2333
run: (input: string) => Promise<FuzzFailure | null>;
2434
close: () => Promise<void>;
2535
};
2636

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+
}
2890

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.
3797
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'],
38101
});
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}`;
53116
});
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;
55140
}
56141

142+
/** A case's verdict, plus whether the worker can take another one. */
143+
type CaseOutcome = { failure: FuzzFailure | null; usable: boolean };
144+
57145
/**
58146
* Runs one case on a started worker. Startup is already awaited by the caller, so the budget below
59147
* 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`.
60150
*/
61-
function runOnSession(
151+
async function runCase(
62152
session: Session,
63153
target: FuzzTarget,
64154
input: string,
65155
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,
96160
});
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 };
97178
}
98179

99180
/**
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.
103184
*/
104185
export async function createCaseRunner(
105186
target: FuzzTarget,
106187
caseTimeoutMs: number,
107188
): Promise<CaseRunner> {
108-
let session = startSession(target.name);
109-
await session.ready;
189+
let session = await startSession(target.name);
110190
let closed = false;
111191

112192
return {
113193
run: async (input) => {
114194
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);
121199
}
122200
return failure;
123201
},
124202
close: async () => {
125203
closed = true;
126-
await session.worker.terminate();
204+
await endSession(session);
127205
},
128206
};
129207
}

scripts/fuzz/harness.test.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { fileURLToPath } from 'node:url';
1414
import { describe, expect, it } from 'vitest';
1515
import { LANE_ENVELOPE_SCHEMA_VERSION } from '../lib/lane-envelope.ts';
1616
import { CASE_GENERATION_INPUTS } from './envelope.ts';
17+
import { runCases } from './execute.ts';
1718
import { checkCase } from './invariant.ts';
1819
import { SELF_CHECK_TARGETS } from './self-check-targets.ts';
1920

@@ -69,7 +70,7 @@ describe('fuzz invariant classifier', () => {
6970
});
7071

7172
describe('fuzz harness self-check', () => {
72-
// One run asserts both the report and its envelope: a second full self-check would cost five
73+
// One run asserts both the report and its envelope: a second full self-check would cost six
7374
// more real worker startups (#1823) for no new signal.
7475
it('catches every seeded violation kind and writes the self-check envelope', () => {
7576
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fuzz-selfcheck-'));
@@ -83,17 +84,36 @@ describe('fuzz harness self-check', () => {
8384
expect(stdout).toContain('ok self-check-untyped-throw: expected untyped-throw');
8485
expect(stdout).toContain('ok self-check-empty-hint: expected empty-hint');
8586
expect(stdout).toContain('ok self-check-hang: expected hang, got hang');
87+
expect(stdout).toContain('ok self-check-crash: expected crash, got crash');
8688
expect(stdout).toContain('ok self-check-silent-accept: expected silent-accept');
8789
expect(stdout).toContain('ok self-check-wrong-code: expected wrong-code');
8890
expect(status).toBe(0);
8991
const envelope = JSON.parse(fs.readFileSync(path.join(dir, 'run-envelope.json'), 'utf8'));
9092
expect(envelope.result).toBe('pass');
9193
expect(envelope.data.mode).toBe('self-check');
92-
expect(envelope.data.targetRuns).toHaveLength(5);
94+
expect(envelope.data.targetRuns).toHaveLength(6);
9395
fs.rmSync(dir, { recursive: true, force: true });
9496
}, 30_000);
9597
});
9698

99+
describe('case crash containment', () => {
100+
// #2053: a case that faults the process it runs in must take only the worker with it. This
101+
// test runs the runner in-process on purpose — it *is* the caller the lane must protect, so a
102+
// runner that executed cases on this thread would kill this Vitest worker instead of failing,
103+
// and the file would vanish from the run with nothing attributed.
104+
it('attributes a worker death to the case and survives it', async () => {
105+
const failures = await runCases(targetNamed('self-check-crash'), ['case'], 5_000);
106+
expect(failures.map((failure) => failure.kind)).toEqual(['crash']);
107+
// The death certificate the lane used to lose: how the worker ended, quoted with the case.
108+
expect(failures[0]?.detail).toContain('exit code 97');
109+
}, 30_000);
110+
111+
it('keeps running cases after one kills the worker', async () => {
112+
const failures = await runCases(targetNamed('self-check-crash'), ['first', 'second'], 5_000);
113+
expect(failures.map((failure) => failure.input)).toEqual(['first', 'second']);
114+
}, 30_000);
115+
});
116+
97117
describe('worker startup budget', () => {
98118
// The watchdog used to start before the worker reported ready, so thread spawn plus type
99119
// stripping plus parser imports — hundreds of milliseconds — was charged to the first case and

0 commit comments

Comments
 (0)