fix(test-runner): cancellation terminates the run; win32 kills the whole tree

Installing SIGINT/SIGTERM forwarders suppresses Node's default
terminate-on-signal, so a cancelled run killed the current child and
kept LAUNCHING shards — observed as paid runs continuing to burn API
spend after Ctrl-C (codex adversarial, repro'd ALIVE_AFTER_SIGTERM).
The first signal now also schedules the parent's own exit after the
children's SIGKILL grace, and both shard pools consult
isTerminationRequested() before taking new work. On win32,
killProcessGroup uses taskkill /T /F — detached:true creates no
killable group there, and a bare child.kill orphaned every grandchild
(ports, locks, and the inherited pipes that kept close from firing).
Also: the tree-mutating serial shard prints dirty generated artifacts
when it dies mid-regeneration, and --shard CI-matrix mode gets the
same size-scaled wall deadline as full-suite mode.
This commit is contained in:
Garry Tan
2026-08-15 17:01:40 -07:00
parent 84a93766f2
commit 9e72b4ac7f
4 changed files with 155 additions and 3 deletions
+25 -2
View File
@@ -77,13 +77,14 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawn } from 'child_process';
import { spawn, spawnSync } from 'child_process';
import { StringDecoder } from 'node:string_decoder';
import { isPaidTestFile } from '../test/helpers/paid-test-set';
import {
BunTestOutputClassifier,
exactTestFileSelectors,
installChildSignalForwarding,
isTerminationRequested,
killProcessGroup,
strictTestExitCode,
stripAnsiLine,
@@ -1081,16 +1082,38 @@ async function main(): Promise<number> {
})),
);
let worst = Math.max(...outcomes.map((o) => exitCodeFor(o.status)));
if (mutators.length > 0) {
// Cancellation stops the run: don't launch the serial tree-mutating shard
// after a SIGINT/SIGTERM already killed the parallel phase.
if (mutators.length > 0 && !isTerminationRequested()) {
const mutatorOutcome = await runFreeShard(mutators, totalShards, totalShards, {
wallTimeoutMs: shardTimeout(mutators.length),
verbose: options.verbose,
});
worst = Math.max(worst, exitCodeFor(mutatorOutcome.status));
if (mutatorOutcome.status !== 'passed') {
// Mutator safety rests on each test restoring default state itself; a
// SIGKILL at the wall deadline (or a mid-regeneration crash) defeats
// that by construction. Say so, loudly, before someone commits
// regenerated SKILL.md / .agents artifacts by accident.
const dirty = spawnSyncGitStatusGenerated();
if (dirty.length > 0) {
console.error('[test:free] ⚠ tree-mutating shard did not finish cleanly — generated artifacts may be mid-regeneration:');
for (const line of dirty.slice(0, 20)) console.error(`[test:free] ${line}`);
console.error('[test:free] restore with: bun run gen:skill-docs (or git checkout -- <paths>)');
}
}
}
return worst;
}
/** Dirty generated artifacts (SKILL.md / host outputs) after a failed mutator shard. */
function spawnSyncGitStatusGenerated(): string[] {
const result = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
if (result.status !== 0 || !result.stdout) return [];
return result.stdout.split('\n').filter((line) =>
/SKILL\.md$/.test(line) || line.includes('.agents/') || line.includes('.factory/'));
}
if (import.meta.main) {
try {
process.exitCode = await main();