mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
006c4cadc9
* 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>
149 lines
5.2 KiB
TypeScript
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();
|
|
}
|
|
}
|