mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 06:28:59 +02:00
test: sync-spawn timeout tripwire — the wedge class stays extinct
Free scanner over all test trees (test/, browse/test/, design/test/, make-pdf/test/, ios-qa, browser-skills): every spawnSync/execSync/ Bun.spawnSync call site must carry a timeout within a 30-line options window, or an explicit '// tripwire-exempt: <reason>' marker. Comment lines are skipped; exemptions are counted and ratcheted shrink-only (ceiling 6 = the 6 string-fixture/grep-needle sites where the pattern is CONTENT, not a call — marked in this commit). A scan-sanity test pins that the scanner still sees >100 real call sites so it can never rot to a vacuous green. Companion to the 436-site sweep in the previous commit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3e674e4c01
commit
cf2990b9fa
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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: <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|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: <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);
|
||||
});
|
||||
});
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user