Files
gstack/test/spawnsync-timeout-tripwire.test.ts
T
Garry TanandClaude Fable 5 5b04f05ba4 test: review-army + adversarial test hardening
- Tripwire scans execFileSync too (ceiling 8: two more grep-needle string
  exemptions); merge-introduced timeout-less spawnSync in
  question-preference-hook fixed — the tripwire caught a site that landed
  on main AFTER the sweep, on its first day.
- gstack-detach gains TWO watchdog kill regression tests: TERM-immune
  grandchild (the killpg-after-grace escalation) and the leader-dies
  variant (the pgid-at-spawn fix — the case the first test cannot see).
- eval-flake-rank gets its unit suite (final-attempt accounting, artifact
  exclusion, shard recursion, recency bound).
- Groupkill/startup-grace shim markers are per-run unique (pid-suffixed
  sleep durations): sibling Conductor worktrees run free suites with no
  machine lock, and fixed markers let one run pgrep/pkill the other's
  shims — a cross-run flake inside the anti-flake tests.
- flake-ledger test pins the project-scoped local default; stale empty
  section headers in touchfiles-data deleted (they invited entries under
  deliberately retired categories).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 05:38:26 +00:00

133 lines
5.4 KiB
TypeScript

/**
* Synchronous-spawn timeout tripwire — the enforcement for the shard-wedge
* class that reached main.
*
* Mechanism: spawnSync / execSync / Bun.spawnSync BLOCK the main thread, so
* bun's in-process per-test timeout can never fire while one waits. A child
* that hangs (stdin read, network probe, dead daemon) wedges the whole shard
* until the runner's external wall-clock SIGKILL — observed live on main:
* test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the
* 360s wall on free-tests run 33262077256 while its five siblings finished
* in ~65s. The 2026-08 sweep added a `timeout` to ~400 call sites across
* ~130 test files; this tripwire keeps the class extinct.
*
* Rule: every sync-spawn call site in the test trees must carry a `timeout`
* within its options window (WINDOW_LINES below), or the line above / the
* call line must carry an explicit exemption marker with a reason:
*
* // tripwire-exempt: <why this call may legitimately block unbounded>
*
* Exemptions are counted and ratcheted (EXEMPT_CEILING can only go down).
*/
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
// Mirrors the free runner's TEST_ROOTS plus test/helpers (helpers spawn on
// behalf of tests and wedge shards just the same).
const SCAN_ROOTS = [
'test',
'browse/test',
'design/test',
'make-pdf/test',
'ios-qa/daemon/test',
'ios-qa/scripts',
'browser-skills',
];
const SYNC_SPAWN = /\b(?:spawnSync|execSync|execFileSync|Bun\.spawnSync)\s*\(/;
/** Generous on purpose: multi-line arg arrays push the options object far
* below the call line (observed: +13 lines in codex-model-probe). A wide
* window trades a sliver of false-negative risk for zero rename churn. */
const WINDOW_LINES = 30;
const EXEMPT_MARKER = /tripwire-exempt:/;
/** Comment lines legitimately NAME the calls (doc headers, grep-needle
* tables in sibling tripwires) without being call sites. */
const COMMENT_LINE = /^\s*(?:\/\/|\*|\/\*)/;
/** Shrink-only: lower it when exemptions burn down; never raise it. */
const EXEMPT_CEILING = 8;
const SELF = path.join('test', 'spawnsync-timeout-tripwire.test.ts');
function walk(dir: string, out: string[] = []): string[] {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (/\.(?:[cm]?[jt]s|tsx)$/.test(entry.name)) out.push(full);
}
return out;
}
interface Violation { file: string; line: number; text: string }
function scan(): { violations: Violation[]; exempt: Violation[] } {
const violations: Violation[] = [];
const exempt: Violation[] = [];
for (const root of SCAN_ROOTS) {
const abs = path.join(ROOT, root);
if (!fs.existsSync(abs)) continue;
for (const file of walk(abs)) {
const rel = path.relative(ROOT, file);
if (rel === SELF) continue;
const lines = fs.readFileSync(file, 'utf-8').split('\n');
for (let i = 0; i < lines.length; i += 1) {
if (!SYNC_SPAWN.test(lines[i])) continue;
if (COMMENT_LINE.test(lines[i])) continue;
const record = { file: rel, line: i + 1, text: lines[i].trim().slice(0, 120) };
if (EXEMPT_MARKER.test(lines[i]) || (i > 0 && EXEMPT_MARKER.test(lines[i - 1]))) {
exempt.push(record);
continue;
}
const window = lines.slice(i, i + WINDOW_LINES).join('\n');
if (!/\btimeout\b/.test(window)) violations.push(record);
}
}
}
return { violations, exempt };
}
describe('sync-spawn timeout tripwire', () => {
const { violations, exempt } = scan();
test('every sync spawn in the test trees carries a timeout (or a reasoned exemption)', () => {
const detail = violations
.map((v) => ` ${v.file}:${v.line} ${v.text}`)
.join('\n');
expect(
violations,
`sync-spawn call site(s) without a timeout — each one can wedge a whole shard to its wall-clock kill (bun's per-test timeout cannot interrupt a blocking spawn):\n${detail}\n`
+ 'Fix: add `timeout: 30_000` (120_000 for genuinely slow ops) to the options, or fix the wrapping helper once. '
+ 'A call that must legitimately block unbounded gets `// tripwire-exempt: <reason>` on or above the line.',
).toEqual([]);
});
test('exemptions only shrink', () => {
expect(
exempt.length,
`tripwire-exempt count ${exempt.length} exceeds the ceiling ${EXEMPT_CEILING}. `
+ 'New unbounded sync spawns are not allowed — add a timeout instead. If an exemption was '
+ `removed, lower EXEMPT_CEILING in ${SELF} in the same commit:\n`
+ exempt.map((v) => ` ${v.file}:${v.line}`).join('\n'),
).toBeLessThanOrEqual(EXEMPT_CEILING);
});
test('scan sanity: the pattern still matches real code (must not rot to vacuous green)', () => {
// The test trees legitimately contain hundreds of sync spawns WITH
// timeouts; if the scanner suddenly sees none at all, the regex or the
// roots rotted and the tripwire is scanning nothing.
let total = 0;
for (const root of SCAN_ROOTS) {
const abs = path.join(ROOT, root);
if (!fs.existsSync(abs)) continue;
for (const file of walk(abs)) {
const src = fs.readFileSync(file, 'utf-8');
for (const line of src.split('\n')) if (SYNC_SPAWN.test(line)) total += 1;
}
}
expect(total).toBeGreaterThan(100);
});
});