@firfi/quint-connect
Version:
Model-based testing framework connecting Quint specifications to TypeScript implementations
387 lines • 18.3 kB
JavaScript
import { Effect, Schema } from "effect";
import { execSync, spawn } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { cpus, homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { ItfTrace } from "../itf/schema.js";
export class QuintError extends Schema.TaggedError()("QuintError", {
message: Schema.String,
stderr: Schema.optional(Schema.String),
exitCode: Schema.optional(Schema.Number)
}) {
}
export class QuintNotFoundError extends Schema.TaggedError()("QuintNotFoundError", {
message: Schema.String
}) {
}
const DEFAULT_N_TRACES = 10;
// quint defaults maxSamples to 1 when --seed is provided; use the non-seed default instead
const DEFAULT_MAX_SAMPLES = 10000;
const buildRunArgs = (opts, outDir) => {
const nTraces = opts.nTraces ?? DEFAULT_N_TRACES;
const args = [
"run",
opts.spec,
"--mbt",
"--n-traces",
String(nTraces),
"--out-itf",
`${outDir}/trace_{seq}.itf.json`
];
const envBackend = process.env["QUINT_BACKEND"];
const backend = opts.backend ?? (envBackend === "typescript" || envBackend === "rust" ? envBackend : "typescript");
args.push("--backend", backend);
if (opts.seed !== undefined) {
args.push("--seed", opts.seed);
}
if (opts.maxSteps !== undefined) {
args.push("--max-steps", String(opts.maxSteps));
}
if (opts.maxSamples !== undefined) {
args.push("--max-samples", String(opts.maxSamples));
}
else if (opts.seed !== undefined) {
args.push("--max-samples", String(DEFAULT_MAX_SAMPLES));
}
if (opts.init !== undefined) {
args.push("--init", opts.init);
}
if (opts.step !== undefined) {
args.push("--step", opts.step);
}
if (opts.main !== undefined) {
args.push("--main", opts.main);
}
if (opts.invariants !== undefined) {
for (const inv of opts.invariants) {
args.push("--invariant", inv);
}
}
if (opts.witnesses !== undefined) {
for (const wit of opts.witnesses) {
args.push("--witness", wit);
}
}
return args;
};
const runQuintProcess = (args, verbose) => Effect.async((resume) => {
const env = verbose ? { ...process.env, QUINT_VERBOSE: "true" } : process.env;
// detached: true creates a new process group so we can kill the entire tree
// (quint spawns quint_evaluator as a child — plain proc.kill() leaves it orphaned)
// Try direct `quint` first (avoids ~3s npx overhead), fall back to npx on ENOENT
const startProc = (cmd, cmdArgs) => {
const proc = spawn(cmd, cmdArgs, { env, detached: true });
let stderr = "";
proc.stdout.resume();
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("close", (code) => {
process.removeListener("exit", killGroup);
resume(Effect.succeed({ exitCode: code ?? 1, stderr }));
});
proc.on("error", (e) => {
if (e.code === "ENOENT" && cmd === "quint") {
// quint not on PATH — fall back to npx (~3s slower)
console.warn("[quint-connect] 'quint' not found on PATH, falling back to npx (slower). Install globally: npm i -g @informalsystems/quint");
activeProc = startProc("npx", ["@informalsystems/quint", ...cmdArgs]);
}
else {
process.removeListener("exit", killGroup);
resume(Effect.fail(new QuintNotFoundError({ message: `Failed to start quint: ${e}` })));
}
});
return proc;
};
// Kill the process group on ANY process exit — prevents zombie evaluators
// when the test runner exits abnormally (timeout, SIGKILL, OOM).
// Must be declared before startProc because startProc's close handler references it.
let killGroup = () => { };
let activeProc = startProc("quint", [...args]);
killGroup = () => {
try {
process.kill(-activeProc.pid, "SIGKILL");
}
catch { /* already dead */ }
};
process.on("exit", killGroup);
return Effect.sync(() => {
process.removeListener("exit", killGroup);
killGroup();
});
});
// ---------------------------------------------------------------------------
// Evaluator-direct path: bypass `quint run`, call `quint_evaluator` directly
// ---------------------------------------------------------------------------
const getRustEvaluatorPath = () => {
// Match Quint's binary manager lookup: ~/.quint/rust-evaluator-v{version}/quint_evaluator
const quintDir = join(homedir(), ".quint");
if (!existsSync(quintDir)) {
throw new Error(`Quint home directory not found: ${quintDir}`);
}
// Find a rust-evaluator directory. Prefer the version specified by QUINT_EVALUATOR_VERSION
// env var, otherwise use the latest available.
const preferredVersion = process.env["QUINT_EVALUATOR_VERSION"];
const dirs = readdirSync(quintDir).filter((d) => d.startsWith("rust-evaluator-")).sort();
if (dirs.length === 0) {
throw new Error("No Rust evaluator found in ~/.quint/. Run `quint run` once with --backend rust to download it.");
}
const preferred = preferredVersion
? dirs.find((d) => d.includes(preferredVersion))
: undefined;
const latest = preferred ?? dirs[dirs.length - 1];
const exePath = join(quintDir, latest, "quint_evaluator");
if (!existsSync(exePath)) {
throw new Error(`Rust evaluator binary not found: ${exePath}`);
}
return exePath;
};
const runEvaluatorDirect = (evaluatorPath, inputStr) => Effect.async((resume) => {
let stdout = "";
let stderr = "";
const proc = spawn(evaluatorPath, ["simulate-from-stdin"], {
stdio: ["pipe", "pipe", "pipe"],
detached: true
});
// Kill the evaluator process group on ANY process exit — not just Effect interrupt.
// Without this, zombie evaluators survive when the test runner exits abnormally
// (timeout, SIGKILL, OOM) and accumulate at 100% CPU, causing 40x slowdowns.
const killGroup = () => {
try {
process.kill(-proc.pid, "SIGKILL");
}
catch { /* already dead */ }
};
process.on("exit", killGroup);
// Write directly to stdin (no file round-trip)
proc.stdin.write(inputStr);
proc.stdin.end();
proc.stdout.on("data", (chunk) => {
stdout += chunk.toString();
});
proc.stderr.on("data", (chunk) => {
stderr += chunk.toString();
});
proc.on("close", (code) => {
process.removeListener("exit", killGroup);
resume(Effect.succeed({ stdout, exitCode: code ?? 1, stderr }));
});
proc.on("error", (e) => {
process.removeListener("exit", killGroup);
resume(Effect.fail(new QuintNotFoundError({ message: `Failed to start Rust evaluator: ${e}` })));
});
return Effect.sync(() => {
process.removeListener("exit", killGroup);
killGroup();
});
});
/**
* Run the Rust evaluator directly with a pre-compiled input, patching runtime
* parameters (nruns, nsteps, ntraces, seed) into the cached JSON.
*
* The evaluator writes ITF trace JSON to stdout, progress to stderr.
* We parse the stdout result to extract traces.
*/
const runFromCompiledInput = (compiledInputPath, opts, outDir) => Effect.gen(function* () {
// Read cached compiled input
const rawInput = yield* Effect.tryPromise({
try: () => readFile(compiledInputPath, "utf-8"),
catch: (e) => new QuintError({ message: `Failed to read compiled input: ${e}` })
});
// Patch runtime parameters into the JSON
// The cached input has: { parsed, source, nruns, nsteps, ntraces, nthreads, seed, mbt, verbosity }
// We need to update nruns (maxSamples), nsteps, ntraces, and optionally seed.
const nTraces = opts.nTraces ?? DEFAULT_N_TRACES;
const maxSamples = opts.maxSamples ?? DEFAULT_MAX_SAMPLES;
const maxSteps = opts.maxSteps ?? 10;
// Match quint run's thread calculation: Math.min(maxSamples, cpuCount).
// Must be >= 2 to avoid a deadlock bug in the Rust evaluator (v0.5.0).
const nThreads = Math.max(2, Math.min(maxSamples, cpus().length));
// Use simple string replacement to avoid parsing the entire 7MB JSON
let patchedInput = rawInput
.replace(/"nruns":\s*\d+/, `"nruns":${maxSamples}`)
.replace(/"nsteps":\s*\d+/, `"nsteps":${maxSteps}`)
.replace(/"ntraces":\s*\d+/, `"ntraces":${nTraces}`)
.replace(/"nthreads":\s*\d+/, `"nthreads":${nThreads}`);
// Always patch a seed so it's known before the evaluator starts (for debugging timeouts).
// If the user provided one, use it; otherwise generate a random one.
const seedBigint = opts.seed !== undefined
? (opts.seed.startsWith("0x") ? BigInt(opts.seed) : BigInt(`0x${opts.seed}`))
: BigInt(`0x${Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join("")}`);
const seedHex = `0x${seedBigint.toString(16)}`;
const seedReplaced = patchedInput.replace(/"seed":\s*(?:null|undefined|\d+)/, `"seed":${seedBigint}`);
if (seedReplaced === patchedInput) {
// The compiled input has no "seed" field — inject it before the closing "}"
// This happens when compile-battle-spec.cjs omits the seed field.
patchedInput = patchedInput.replace(/}\s*$/, `,"seed":${seedBigint}}`);
}
else {
patchedInput = seedReplaced;
}
// Always log seed so it's known before the evaluator starts (for debugging timeouts/hangs).
console.error(`[quint-connect] seed: ${seedHex} (compiled-input path)`);
// Write patched input directly to stdin (matching commandWrapper.simulate behavior).
// CRITICAL: Do NOT read from file — the file round-trip through Node.js readFile/createReadStream
// silently corrupts json-bigint output (BigInt integers → float64), causing the evaluator to hang.
// Direct proc.stdin.write() preserves the exact bytes from json-bigint.stringify().
const evaluatorPath = getRustEvaluatorPath();
const result = yield* runEvaluatorDirect(evaluatorPath, patchedInput);
if (result.exitCode !== 0) {
return yield* new QuintError({
message: `Rust evaluator failed with exit code ${result.exitCode}:\n${result.stderr}`,
stderr: result.stderr,
exitCode: result.exitCode
});
}
// Parse the evaluator's JSON output to extract ITF traces
// The output format is: { status, errors, bestTraces: [{ seed, states: ITF, ... }], ... }
// We need to extract states from bestTraces and write them as individual ITF trace files.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluator output is untyped JSON
let parsed;
try {
// The output might contain non-JSON lines (progress, warnings) before the JSON result.
// The JSON result is the last line that starts with '{'.
const lines = result.stdout.split("\n");
const jsonLine = lines.filter(l => l.trimStart().startsWith("{")).pop();
if (!jsonLine) {
return yield* new QuintError({ message: "No JSON output from Rust evaluator" });
}
// Parse with integer-to-bigint reviver to match `quint run --out-itf` behavior.
// Quint's `json-bigint` library serializes all integers as bigints in ITF traces.
parsed = JSON.parse(jsonLine, (_key, value) => typeof value === "number" && Number.isInteger(value) ? BigInt(value) : value);
}
catch (e) {
return yield* new QuintError({ message: `Failed to parse evaluator output: ${e}` });
}
if (parsed.status === "error") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped evaluator output
const errMsgs = (parsed.errors || []).map((e) => e.message || String(e)).join("\n");
return yield* new QuintError({ message: `Quint simulation error:\n${errMsgs}` });
}
// Write ITF trace files to outDir (matching `quint run --out-itf` format).
// The evaluator wraps traces in { #meta, params, vars, loop, states }.
// `quint run --out-itf` strips to { #meta, vars, states }. Match that format.
//
// Critical: The evaluator outputs plain integers (28), but `quint run --out-itf`
// uses the ITF #bigint encoding ({"#bigint":"28"}). Convert integers to #bigint
// format to match what quint-connect/itf-trace-parser expects.
const traces = [];
const bestTraces = parsed.bestTraces || [];
for (let i = 0; i < bestTraces.length; i++) {
const bt = bestTraces[i];
if (!bt.states)
continue;
const statesObj = bt.states;
const itfObj = {
"#meta": statesObj["#meta"],
vars: statesObj["vars"],
states: statesObj["states"]
};
// Convert BigInts (from our integer-to-bigint reviver) to ITF #bigint encoding
const itfContent = JSON.stringify(itfObj, (_key, value) => typeof value === "bigint" ? { "#bigint": String(value) } : value);
const filePath = join(outDir, `trace_${i}.itf.json`);
yield* Effect.tryPromise({
try: () => writeFile(filePath, itfContent),
catch: (e) => new QuintError({ message: `Failed to write trace file: ${e}` })
});
}
// Read the trace files back using the standard ITF parser
const files = yield* Effect.tryPromise({
try: () => readdir(outDir),
catch: (e) => new QuintError({ message: `Failed to read trace directory: ${e}` })
});
const traceFiles = files.filter((f) => f.endsWith(".itf.json")).sort();
for (const file of traceFiles) {
const content = yield* Effect.tryPromise({
try: () => readFile(join(outDir, file), "utf-8"),
catch: (e) => new QuintError({ message: `Failed to read trace file ${file}: ${e}` })
});
const json = yield* Effect.try({
try: () => JSON.parse(content),
catch: (e) => new QuintError({ message: `Invalid JSON in trace file ${file}: ${e}` })
});
const trace = yield* Effect.mapError(Schema.decodeUnknown(ItfTrace)(json), (e) => new QuintError({ message: `Failed to parse ITF trace ${file}: ${e}` }));
traces.push(trace);
}
return traces;
});
// ---------------------------------------------------------------------------
// Standard path: call `quint run` CLI
// ---------------------------------------------------------------------------
const runAndReadTraces = (opts, outDir) => Effect.gen(function* () {
const args = buildRunArgs(opts, outDir);
const { exitCode, stderr } = yield* runQuintProcess(args, opts.verbose === true);
if (exitCode !== 0) {
return yield* new QuintError({
message: stderr
? `quint run failed with exit code ${exitCode}:\n${stderr.trim()}`
: `quint run failed with exit code ${exitCode}`,
stderr,
exitCode
});
}
const files = yield* Effect.tryPromise({
try: () => readdir(outDir),
catch: (e) => new QuintError({ message: `Failed to read trace directory: ${e}` })
});
const traceFiles = files
.filter((f) => f.endsWith(".itf.json"))
.sort();
const traces = [];
for (const file of traceFiles) {
const content = yield* Effect.tryPromise({
try: () => readFile(join(outDir, file), "utf-8"),
catch: (e) => new QuintError({ message: `Failed to read trace file ${file}: ${e}` })
});
const json = yield* Effect.try({
try: () => JSON.parse(content),
catch: (e) => new QuintError({ message: `Invalid JSON in trace file ${file}: ${e}` })
});
const trace = yield* Effect.mapError(Schema.decodeUnknown(ItfTrace)(json), (e) => new QuintError({ message: `Failed to parse ITF trace ${file}: ${e}` }));
traces.push(trace);
}
return traces;
});
// ---------------------------------------------------------------------------
// Dispatch: use compiled input path if available, otherwise standard path
// ---------------------------------------------------------------------------
const generateTracesInDir = (opts, outDir) => {
if (opts.compiledInput && existsSync(opts.compiledInput)) {
return runFromCompiledInput(opts.compiledInput, opts, outDir);
}
return runAndReadTraces(opts, outDir);
};
const generateTracesWithTraceDir = (opts, traceDir) => Effect.gen(function* () {
yield* Effect.tryPromise({
try: () => mkdir(traceDir, { recursive: true }),
catch: (e) => new QuintError({ message: `Failed to create trace directory: ${e}` })
});
return yield* generateTracesInDir(opts, traceDir);
});
const generateTracesWithTempDir = (opts) => Effect.acquireUseRelease(Effect.tryPromise({
try: () => mkdtemp(join(tmpdir(), "quint-")),
catch: (e) => new QuintError({ message: `Failed to create temp directory: ${e}` })
}), (tmpDir) => generateTracesInDir(opts, tmpDir), (tmpDir) => Effect.promise(() => rm(tmpDir, { recursive: true, force: true }).catch(() => { })));
/** Warn if zombie quint_evaluator processes are running — they cause 40x slowdowns. */
const warnZombieEvaluators = () => {
try {
const result = execSync("pgrep -c quint_evaluator", { stdio: ["pipe", "pipe", "pipe"] }).toString().trim();
const count = parseInt(result, 10);
if (count > 0) {
console.warn(`[quint-connect] WARNING: Found ${count} running quint_evaluator process(es). ` +
`These consume 100% CPU each and will slow down this run by ~40x. ` +
`Kill them: killall -9 quint_evaluator`);
}
}
catch {
// pgrep returns exit code 1 when no processes match — expected
}
};
export const generateTraces = (opts) => {
warnZombieEvaluators();
return opts.traceDir !== undefined
? generateTracesWithTraceDir(opts, opts.traceDir)
: generateTracesWithTempDir(opts);
};
//# sourceMappingURL=quint.js.map