From 249bb5e73193a5d190bbf17a410a949cff37247b Mon Sep 17 00:00:00 2001 From: FilipKalcic1 Date: Tue, 29 Sep 2026 20:42:43 +0200 Subject: [PATCH] Fix mutants runner ignoring SIGTERM until the next trial CI's first run failed on ubuntu in one test: SIGTERM sent to `npm run mutants` mid-trial was only handled after the NEXT mutant had run. The runner ran each trial's tests with spawnSync, which blocks the event loop, so the signal handler could not fire until later. Windows skips that test (no catchable signals), which is why it never failed locally. Trials now run their tests with an async spawn. A SIGINT or SIGTERM is handled at once: the runner kills the trial's tests, restores the mutated file and exits with 128 + the signal number. The SIGTERM test now expects exactly that (no half-finished row, lib/x.ts restored, the stub tests stopped). Co-Authored-By: Claude Opus 5.5 (1M context) --- __tests__/meta/mutants.test.ts | 12 +++--- scripts/mutants.mjs | 76 ++++++++++++++++++++++------------ 2 files changed, 56 insertions(+), 32 deletions(-) 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); }