diff --git a/browse/test/windows-spawn-hide.test.ts b/browse/test/windows-spawn-hide.test.ts index 512279446..10d538aae 100644 --- a/browse/test/windows-spawn-hide.test.ts +++ b/browse/test/windows-spawn-hide.test.ts @@ -70,6 +70,7 @@ describe('windowsHide on Windows-reachable spawns (#1835)', () => { const EXEMPT: Array<{ file: string; needle: string; reason: string }> = [ { file: 'domain-skill-commands.ts', + // tripwire-exempt: grep NEEDLE string for this census, not a call needle: 'spawnSync(editor', reason: "interactive $EDITOR with stdio:'inherit' — windowsHide would detach a console editor into an invisible console", }, diff --git a/test/gbrain-exec-invariant.test.ts b/test/gbrain-exec-invariant.test.ts index a0d962b4a..ae747b65f 100644 --- a/test/gbrain-exec-invariant.test.ts +++ b/test/gbrain-exec-invariant.test.ts @@ -35,9 +35,11 @@ const GUARDED_FILES = [ // Patterns that would bypass lib/gbrain-exec.ts. Match the literal `"gbrain"` // as the first argument since these helpers are the failure mode. const BANNED_PATTERNS: Array<{ name: string; regex: RegExp }> = [ + // tripwire-exempt: grep-needle STRING for this invariant, not a process spawn { name: 'spawnSync("gbrain", ...)', regex: /spawnSync\s*\(\s*["']gbrain["']/g }, { name: 'spawn("gbrain", ...)', regex: /\bspawn\s*\(\s*["']gbrain["']/g }, { name: 'execFileSync("gbrain", ...)', regex: /execFileSync\s*\(\s*["']gbrain["']/g }, + // tripwire-exempt: grep-needle STRING for this invariant, not a process spawn { name: 'execSync("...gbrain...")', regex: /execSync\s*\(\s*["'`][^"'`]*\bgbrain\b/g }, ]; diff --git a/test/skill-e2e-triage.test.ts b/test/skill-e2e-triage.test.ts index e971c5e23..89f75db18 100644 --- a/test/skill-e2e-triage.test.ts +++ b/test/skill-e2e-triage.test.ts @@ -101,6 +101,7 @@ const path = require('path'); let failures = 0; for (const f of ['math.test.js', 'string.test.js']) { try { + // tripwire-exempt: line below is CONTENT of the generated run.js fixture (child-executed), not a test-process call execSync('node ' + path.join(__dirname, f), { stdio: 'inherit' }); } catch (e) { failures++; diff --git a/test/spawnsync-timeout-tripwire.test.ts b/test/spawnsync-timeout-tripwire.test.ts new file mode 100644 index 000000000..acde0099d --- /dev/null +++ b/test/spawnsync-timeout-tripwire.test.ts @@ -0,0 +1,132 @@ +/** + * 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: + * + * 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|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 = 6; + +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: ` 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); + }); +}); diff --git a/test/test-free-shards.test.ts b/test/test-free-shards.test.ts index 0e7daa157..c807c9b0e 100644 --- a/test/test-free-shards.test.ts +++ b/test/test-free-shards.test.ts @@ -83,6 +83,7 @@ describe('test-free-shards: Windows curation', () => { }); test('detects spawn("sh", ...)', () => { + // tripwire-exempt: string fixture fed to detectWindowsFragility, not a call withTempFile(`spawnSync('sh', ['-c', 'command -v claude']);`, (f) => { expect(detectWindowsFragility(f)?.reason).toBe('spawn("sh", ...)'); }); @@ -95,6 +96,7 @@ describe('test-free-shards: Windows curation', () => { }); test('detects which claude shell command', () => { + // tripwire-exempt: string fixture fed to detectWindowsFragility, not a call withTempFile(`execSync('which claude').trim();`, (f) => { expect(detectWindowsFragility(f)?.reason).toBe('which claude (use Bun.which)'); });