Files
callstack__agent-device/scripts/fuzz/execute.ts
devin-ai-integration[bot] 006c4cadc9 test: nightly parser fuzz lane — parser input fails as typed AppErrors, never hangs (#1414) (#1438)
* test: nightly parser fuzz lane with typed-AppError invariant (#1414)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fuzz): run envelope, artifact promotion, and harness self-check tests (#1414)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fuzz): shared scheduled-lane envelope on every terminal path, watchdog after ready (#1414)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fuzz): envelope for malformed options; add scheduled-lane health consumer (#1414, #1430)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lanes): actions:read scope, terminal error envelope, first-due grace (#1414, #1430)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lanes): anchor first-run grace to schedule registration, use exec helper in tests (#1414, #1430)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(lanes): portable POSIX pickaxe pattern for schedule registration (#1414, #1430)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(fuzz): fast-check generators over the shared hazard list, drop the bundled lane-health work (#1414)

- Strip scripts/scheduled-lane/* and scheduled-lane-health.yml: that watcher is #1430's own
  deliverable and collides with PR #1439's implementation of the same lane. What this lane owes
  (a per-run envelope) moves into scripts/fuzz/envelope.ts.
- Rebase onto #1437 and rebuild the generator layer on fast-check: cases come from arbitraries
  sharing SELECTOR_VALUE_HAZARDS with the property suite, and counterexamples are shrunk, so a
  failure names a minimal input plus fast-check's seed/path instead of a 20k-char random string.
- Route harness.test.ts into the serialized subprocess-stub project.
- Drop the AGENT_DEVICE_FUZZ_STARTUP_DELAY_MS test seam: the ready handshake is now proven by a
  case budget far below real worker startup.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fuzz): replay the regression corpus through the worker watchdog (#1414)

A promoted hang case used to wedge the unit job until the CI timeout, because corpus replay called
checkCase in-process. It now goes through the same worker-backed watchdog the nightly lane uses, so
such a case fails against a 5s per-case budget; the file moves to the serialized subprocess-stub
project with the rest of the worker-driven fuzz tests.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fuzz): let the watchdog outlive vitest's default case timeout (#1414)

A wedged parser was surfacing as a bare 'Test timed out in 5000ms' instead of the named hang:
failure that says which input wedged, because the file's vitest timeout was shorter than the
watchdog budget times the number of replayed cases.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(fuzz): complete drift provenance in the lane envelope (#1414)

configHash now covers every input that decides what a seed generates (generate.ts and the shared property arbitraries, not just the arbitraries/targets/invariant), and tool records fast-check's installed version. A generation-loop edit or a fast-check upgrade previously changed the case set while the envelope looked unchanged. A test recomputes the hash with each input omitted so a future omission fails.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-28 11:29:25 +02:00

149 lines
5.2 KiB
TypeScript

// Case execution with hang detection for the parser fuzz lane (#1414).
//
// Cases run one at a time in a worker thread: a synchronous parser that never returns cannot be
// timed out from inside its own tick, so the budget is enforced from *another* thread, which can
// terminate the wedged one and attribute the stall to the exact input. Cases go over the wire
// individually (rather than as one batch) because fast-check drives the loop — it decides the next
// input, including the shrink candidates it derives from a failing one.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Worker } from 'node:worker_threads';
import type { FuzzFailure } from './invariant.ts';
import type { FuzzTarget } from './target-types.ts';
import type { FuzzWorkerData, FuzzWorkerMessage } from './worker.ts';
const WORKER_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), 'worker.ts');
/** Bound on worker startup only; a per-case budget must never be charged for it. */
const STARTUP_BUDGET_MS = 60_000;
export type CaseRunner = {
/** The failure this input violates the invariant with, or `null` when it holds. */
run: (input: string) => Promise<FuzzFailure | null>;
close: () => Promise<void>;
};
type Session = { worker: Worker; ready: Promise<void> };
/** A worker plus the promise that settles when it has finished importing the parsers. */
function startSession(targetName: string): Session {
const workerData: FuzzWorkerData = { targetName };
// Type stripping is requested explicitly rather than inherited: under Vitest the parent's
// execArgv carries no such flag, and the worker is a plain `.ts` file Node must strip itself.
// The warning is silenced because a restarted worker re-emits it — one per hang buries the report.
const worker = new Worker(WORKER_PATH, {
workerData,
execArgv: ['--experimental-strip-types', '--disable-warning=ExperimentalWarning'],
});
const ready = new Promise<void>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`fuzz worker did not start within ${STARTUP_BUDGET_MS}ms`)),
STARTUP_BUDGET_MS,
);
worker.once('message', (message: FuzzWorkerMessage) => {
clearTimeout(timer);
if (message.kind === 'ready') resolve();
else reject(new Error(`unexpected first worker message: ${message.kind}`));
});
worker.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
return { worker, ready };
}
/**
* Runs one case on a started worker. Startup is already awaited by the caller, so the budget below
* covers parser time only — the reason a slow import can never be misreported as a parser hang.
*/
function runOnSession(
session: Session,
target: FuzzTarget,
input: string,
caseTimeoutMs: number,
): Promise<{ failure: FuzzFailure | null; hung: boolean }> {
return new Promise((resolve, reject) => {
const settle = (failure: FuzzFailure | null, hung: boolean) => {
clearTimeout(timer);
session.worker.off('message', onMessage);
session.worker.off('error', onError);
resolve({ failure, hung });
};
const onMessage = (message: FuzzWorkerMessage) => {
if (message.kind === 'result') settle(message.failure, false);
};
const onError = (error: Error) => {
clearTimeout(timer);
session.worker.off('message', onMessage);
reject(error);
};
const timer = setTimeout(() => {
settle(
{
target: target.name,
input,
kind: 'hang',
detail: `case did not finish within ${caseTimeoutMs}ms`,
},
true,
);
}, caseTimeoutMs);
session.worker.on('message', onMessage);
session.worker.once('error', onError);
session.worker.postMessage({ kind: 'case', input });
});
}
/**
* A worker-backed runner for one target. A hang leaves the thread wedged in its own loop, so the
* runner terminates it and starts a fresh one for the next case — otherwise a single hang would
* silently turn every later case into a hang too.
*/
export async function createCaseRunner(
target: FuzzTarget,
caseTimeoutMs: number,
): Promise<CaseRunner> {
let session = startSession(target.name);
await session.ready;
let closed = false;
return {
run: async (input) => {
if (closed) throw new Error('fuzz case runner is closed');
await session.ready;
const { failure, hung } = await runOnSession(session, target, input, caseTimeoutMs);
if (hung) {
await session.worker.terminate();
session = startSession(target.name);
await session.ready;
}
return failure;
},
close: async () => {
closed = true;
await session.worker.terminate();
},
};
}
/** Runs a fixed list of cases (corpus replay, artifact replay, self-check) on one runner. */
export async function runCases(
target: FuzzTarget,
cases: readonly string[],
caseTimeoutMs: number,
): Promise<FuzzFailure[]> {
const runner = await createCaseRunner(target, caseTimeoutMs);
try {
const failures: FuzzFailure[] = [];
for (const input of cases) {
const failure = await runner.run(input);
if (failure) failures.push(failure);
}
return failures;
} finally {
await runner.close();
}
}