diff --git a/__tests__/meta/mutants.test.ts b/__tests__/meta/mutants.test.ts index b7d57ae..d1edf59 100644 --- a/__tests__/meta/mutants.test.ts +++ b/__tests__/meta/mutants.test.ts @@ -422,16 +422,16 @@ describe("npm run mutants, in a scratch repo with a stub Jest", () => { : [ { route: "SIGTERM reaches the runner mid-trial", + // The stub holds the trial open until released, and is never + // released: the runner must stop it, not wait for it. mode: "gated", - // The signal is pending in the runner before its trial may end, so - // the order never depends on how fast the machine is. send: (child: ChildProcess) => { child.kill("SIGTERM"); - fs.writeFileSync(inSandbox("release"), ""); }, code: 143, - firstRow: row(FIRST, "✅ killed: 2 test(s) failed"), - message: "SIGTERM: stopped; no mutant is left in the tree.", + firstRow: null, + message: + "SIGTERM: stopped, lib/x.ts restored; no mutant is left in the tree.", }, { route: "its tests die of Ctrl+C (SIGINT reaches the whole group)", @@ -456,6 +456,8 @@ describe("npm run mutants, in a scratch repo with a stub Jest", () => { expect(stdout).not.toContain("| RY |"); expect(readSandbox(TARGET)).toBe(ORIGINAL); expect(readSandbox("lib/y.ts")).toBe(ORIGINAL); + // The trial's tests were stopped, not left running on mutated code. + expect(alive(stubRuns()[0].pid)).toBe(false); }); } }); diff --git a/scripts/mutants.mjs b/scripts/mutants.mjs index 9bf1a67..2ec7bf3 100644 --- a/scripts/mutants.mjs +++ b/scripts/mutants.mjs @@ -14,7 +14,7 @@ * npm run mutants # all * npm run mutants -- R04 R05 # some */ -import { spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import os from "node:os"; @@ -92,8 +92,12 @@ function restore() { return file; } +/** The tests of the trial in progress, so a signal can stop them too. */ +let running = null; + /** Stop the run at once, with every file as it was. */ function interrupt(signal) { + running?.kill("SIGKILL"); const file = restore(); console.error( `\n${signal}: stopped${file ? `, ${file} restored` : ""}; no mutant is left in the tree.`, @@ -129,28 +133,51 @@ export function verdict(mutant, { status, output }) { }; } +/** + * Run the mutant's tests. Asynchronously, so the event loop keeps running: a + * SIGINT or SIGTERM is handled the moment it arrives, not after the trial. + */ function run(mutant) { - const options = { - encoding: "utf8", - timeout: TRIAL_TIMEOUT_MS, - killSignal: "SIGKILL", - maxBuffer: 64 * 1024 * 1024, - }; - if (mutant.jest) { - return spawnSync( - process.execPath, - [...JEST, ...mutant.jest, "-t", `${mutant.id}\\b`], - { ...options, cwd: ROOT }, - ); - } - return spawnSync(PYTHON, ["-m", "pytest", "-q", "-k", mutant.pytest], { - ...options, - cwd: path.join(ROOT, "ml-platform"), + const [command, args, cwd] = mutant.jest + ? [ + process.execPath, + [...JEST, ...mutant.jest, "-t", `${mutant.id}\\b`], + ROOT, + ] + : [ + PYTHON, + ["-m", "pytest", "-q", "-k", mutant.pytest], + path.join(ROOT, "ml-platform"), + ]; + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + running = child; + let output = ""; + let timedOut = false; + let settled = false; + const settle = (result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + running = null; + resolve({ ...result, output, timedOut }); + }; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, TRIAL_TIMEOUT_MS); + child.stdout.on("data", (chunk) => (output += chunk)); + child.stderr.on("data", (chunk) => (output += chunk)); + child.on("error", (error) => settle({ error })); + child.on("close", (status, signal) => settle({ status, signal })); }); } /** Apply the mutant, run its tests, always restore. */ -function trial(mutant) { +async function trial(mutant) { const file = path.join(ROOT, mutant.file); const original = readIfExists(file); let mutated; @@ -168,8 +195,8 @@ function trial(mutant) { try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, mutated); - const result = run(mutant); - if (result.error?.code === "ETIMEDOUT") + const result = await run(mutant); + if (result.timedOut) return { status: "error", detail: `timed out after ${TRIAL_TIMEOUT_MS / 1000} s`, @@ -178,10 +205,7 @@ function trial(mutant) { // Ctrl+C reaches the whole process group: the tests died of it, so stop. if (result.signal === "SIGINT" || result.signal === "SIGTERM") interrupt(result.signal); - return verdict(mutant, { - status: result.status, - output: `${result.stdout}\n${result.stderr}`, - }); + return verdict(mutant, { status: result.status, output: result.output }); } finally { restore(); } @@ -207,13 +231,11 @@ async function main() { let ok = true; console.log("| R | Reintroduced bug | Result |\n| --- | --- | --- |"); for (const mutant of selected) { - const { status, detail } = trial(mutant); + const { status, detail } = await trial(mutant); ok &&= status === "killed"; console.log( `| ${mutant.id} | ${mutant.bug} | ${status === "killed" ? "✅" : "❌"} ${status}: ${detail} |`, ); - // A signal that arrived during the (synchronous) trial is handled here. - await new Promise((resolve) => setImmediate(resolve)); } process.exit(ok ? 0 : 1); }