From fbbec209672f7c9ae41ed7bec196ca6905890f2c Mon Sep 17 00:00:00 2001 From: garrytan Date: Sun, 1 Nov 2026 15:01:12 +0000 Subject: [PATCH] test: eight flaky-test repairs and five failure diagnostics from the 2026-11-01 flaky pass (GSTA-240) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-codex-probe | 4 +-- bin/gstack-evidence | 2 +- changelog.d/capy-gsta240-flaky.md | 14 ++++++++ scripts/lib/free-home-guard.ts | 9 ++++-- scripts/test-free-shards.ts | 4 +++ test/auq-parallel.test.ts | 3 +- test/autoplan-publication-guard.test.ts | 4 ++- test/bootstrap-retention-shard.test.ts | 2 +- test/codex-probe-cache.test.ts | 25 +++++++++++++++ test/evidence.test.ts | 8 +++++ test/free-home-guard.test.ts | 28 ++++++++++++++++ test/gstack-design-detect.test.ts | 7 +++- test/helpers/free-owned-browser-settlement.ts | 3 +- test/helpers/restamp-fixture.ts | 32 +++++++++++++++---- test/office-hours-attempt.test.ts | 3 +- test/pregate-regate.test.ts | 28 ++++++++++++---- test/session-update-autostash.test.ts | 3 +- test/shard-engine.test.ts | 5 ++- test/ship-budget.test.ts | 2 +- test/ship-queue-train.test.ts | 7 ++-- 20 files changed, 164 insertions(+), 29 deletions(-) create mode 100644 changelog.d/capy-gsta240-flaky.md diff --git a/bin/gstack-codex-probe b/bin/gstack-codex-probe index 0292e5674..981ae1499 100755 --- a/bin/gstack-codex-probe +++ b/bin/gstack-codex-probe @@ -662,8 +662,8 @@ _gstack_codex_lock_acquire() { if [ "$_ohost" = "$_host" ] && ! kill -0 "$_pid" 2>/dev/null; then _dead=1; fi [ $((_now - _ts)) -ge "$_stale" ] && _dead=1 else - _ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo 0) - case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac + _ts=$(stat -c %Y "$_dir" 2>/dev/null || stat -f %m "$_dir" 2>/dev/null || echo "$_now") + case "$_ts" in ''|*[!0-9]*) _ts="$_now" ;; esac [ $((_now - _ts)) -ge 5 ] && _dead=1 fi [ "$_dead" -eq 1 ] && _gstack_codex_lock_break "$_dir" "$_owner" diff --git a/bin/gstack-evidence b/bin/gstack-evidence index 59f8bbe30..35ca01d7b 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise { try { if (shell && !shell.ok) throw new Error(shell.error); const spawnArgv = shell?.ok ? [shell.cmd, ...shell.argv] : cmdArgv; - proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" }); + proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe", env: log ? { ...process.env, GSTACK_EVIDENCE_LOG: log.path } : process.env }); } catch (e: any) { // Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127. exitCode = 127; diff --git a/changelog.d/capy-gsta240-flaky.md b/changelog.d/capy-gsta240-flaky.md new file mode 100644 index 000000000..efacb67eb --- /dev/null +++ b/changelog.d/capy-gsta240-flaky.md @@ -0,0 +1,14 @@ +**Eight flaky tests from the 2026-11-01 flaky pass stop failing correct runs, and five more now name their cause when they fail.** One product lock broke a fresh lock it had misjudged as abandoned, the free-shard runner failed a shard over a browser child that had already exited, and the rest were fixtures that raced their own setup, leaned on wall-clock sleeps or read the caller's state root. + +### Fixed +- **`bin/gstack-codex-probe`**: a waiter that cannot read an owner-less lock's age (the holder removed it in that instant, or `stat -f` printed filesystem text on Linux) now treats the lock as fresh instead of abandoned, so it no longer deletes the next owner's lock and the probe no longer ends `MODEL_PROBE_INCONCLUSIVE` (Free Tests 41216564482, 41235671867, 41263831361). A stat stub for both race windows fails without the fix; 64 stress runs went from 2 failures to 0. +- **`scripts/test-free-shards.ts`**: a recorded browser child the runner never saw, belonging to a known daemon, settles when it has already exited; a live one still fails the shard and now names its pid and daemon (Free Tests 41264845860 shard 7). A live child from another shard still fails. +- **`scripts/lib/free-home-guard.ts`, `bin/gstack-evidence`**: `gstack-evidence run` names its own lane log in `GSTACK_EVIDENCE_LOG`, and the real-home tripwire ignores exactly that file, so a /ship lane no longer trips over its own output. A foreign write under the home's `~/.gstack` still fails. +- **`test/autoplan-publication-guard.test.ts`**: the render-root cases clear the state-root variables they don't name, so a `GSTACK_STATE_ROOT` exported by the gstack launch file no longer picks the root. Three cases failed every time under that variable, and the lookalike controls passed for the wrong reason. +- **`test/gstack-design-detect.test.ts`**: the directory scan proves one batch by counting engine calls instead of sleeping near the 1,500 ms detect timeout (macOS job 124813933194). The product timeout is unchanged. +- **`test/pregate-regate.test.ts`**: the two fixtures are built once and copied per test (`copyFixtureRepo` in `test/helpers/restamp-fixture.ts`), so 8 builds become 2. +- **`test/ship-queue-train.test.ts`**: the planted seam has its own name, so the fixture's own background `git maintenance` lock no longer collides with it (EEXIST, Free Tests 41288253305, 41288417894), and the test now holds a real `maintenance.lock` beside it. +- **`test/ship-budget.test.ts`**: the fake child writes its args to a temporary file and renames it, so the poll never reads an empty file (Free Tests 41242243205). + +### Changed +- `test/bootstrap-retention-shard.test.ts`, `test/office-hours-attempt.test.ts`, `test/session-update-autostash.test.ts`, `test/shard-engine.test.ts` and `test/auq-parallel.test.ts` print the process state, exit status or cleanup events behind their assertion, so the next red names its cause. diff --git a/scripts/lib/free-home-guard.ts b/scripts/lib/free-home-guard.ts index 69b4bfd17..fb07555d3 100644 --- a/scripts/lib/free-home-guard.ts +++ b/scripts/lib/free-home-guard.ts @@ -5,8 +5,10 @@ * the real home before and after each free shard, and names the shard's files * when one of those entries changed. It watches only these surfaces; it is not * a universal write-containment check. Live agent-session logs that a - * concurrently running host keeps writing are excluded, and directory mtimes - * are ignored, so only file, link and directory-set changes count. + * concurrently running host keeps writing are excluded, as is the one lane log + * `gstack-evidence run` tees this run's own output into (its exact path, from + * GSTACK_EVIDENCE_LOG). Directory mtimes are ignored, so only file, link and + * directory-set changes count. * * A shard that runs alone owns its window, so its guard names its files. * Concurrent shards share one HOME and cannot tell whose write a change was, so @@ -104,6 +106,7 @@ export type FreeHomeGuardFactory = (files: string[], env: NodeJS.ProcessEnv, sta /** Take the baseline now; verify() returns null or the failure to report. */ export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process.env, scope: FreeHomeScope = { kind: 'shard' }): FreeHomeGuard { const home = freeHomeDir(env); + const ownLog = env.GSTACK_EVIDENCE_LOG && path.isAbsolute(env.GSTACK_EVIDENCE_LOG) ? path.relative(home, env.GSTACK_EVIDENCE_LOG) : null; const unreadable = (error: unknown) => `real home surfaces could not be snapshotted (${(error as NodeJS.ErrnoException).code ?? 'error'}); shard files: ${files.join(', ')}`; let before: FreeHomeSnapshot | undefined; let baselineError: string | undefined; @@ -112,7 +115,7 @@ export function guardFreeHome(files: string[], env: NodeJS.ProcessEnv = process. verify() { if (!before) return baselineError!; try { - const changed = diffFreeHome(before, snapshotFreeHome(home)); + const changed = diffFreeHome(before, snapshotFreeHome(home)).filter(entry => entry !== ownLog); return changed.length ? formatFreeHomeChange(changed, files, scope) : null; } catch (error) { return unreadable(error); } }, diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index ba82c3a48..423c50c95 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -1325,6 +1325,10 @@ function trackShardBrowser(stateDir: string, env: NodeJS.ProcessEnv) { check(operation); if (!Number.isSafeInteger(pid) || (pid as number) <= 1) throw new BrowserCleanupError('invalid browser child identity'); const identity = identities.get(pid as number); + if (!identity && identities.get(daemon as number)?.root) { + if (!await inspect(operation, pid as number)) return; + throw new BrowserCleanupError(`browser child ownership is unconfirmed: recorded pid ${pid} is alive outside daemon ${daemon}'s process tree`); + } if (!identity || identity.daemon !== daemon || identity.root) throw new BrowserCleanupError('browser child ownership is unconfirmed'); if (await live(operation, identity) && (typeof start !== 'string' || !start || await nativeStart(operation, identity) !== start.replace(/\s+/g, ' '))) throw new BrowserCleanupError('browser child identity was replaced'); diff --git a/test/auq-parallel.test.ts b/test/auq-parallel.test.ts index d7296a5b1..5a6facc94 100644 --- a/test/auq-parallel.test.ts +++ b/test/auq-parallel.test.ts @@ -111,7 +111,8 @@ test.each([ expect(facts.receipts.filter((receipt: any) => receipt.outcome === 'timeout')).toHaveLength(3 - scenario.passed); expect(facts.receipts.every((receipt: any) => receipt.timeoutMs === 240_000 && !receipt.answered)).toBe(true); expect(facts.active).toBe(0); expect(facts.answers).toBe(0); expect(facts.pendingTimers).toBe(0); - expect(facts.leftovers).toEqual([]); expect(facts.ownedStateLeftovers).toEqual([]); + expect(facts.leftovers).toEqual([]); + expect(facts.ownedStateLeftovers, JSON.stringify({ fixtures: facts.fixtures, inputs: facts.inputs.map((input: any) => [input.id, input.config, input.state]), events: facts.events.filter((event: any) => ['query-close', 'capture-settle', 'cleanup'].includes(event.kind)) })).toEqual([]); expect(facts.events.filter((event: any) => event.kind === 'query-close')).toHaveLength(scenario.queries); expect(facts.events.filter((event: any) => event.kind === 'cleanup')).toHaveLength(3); }, 20_000); diff --git a/test/autoplan-publication-guard.test.ts b/test/autoplan-publication-guard.test.ts index fb1199a7a..30f1462d9 100644 --- a/test/autoplan-publication-guard.test.ts +++ b/test/autoplan-publication-guard.test.ts @@ -1316,7 +1316,9 @@ describe('Autoplan phase entry through the gbrain :user render (#2569)', () => { edit(rewriteSectionBase(fs.readFileSync(path.join(ROOT, 'autoplan/sections', name), 'utf8'), linkRoot))); return root; } - function withEnv(env: Record, work: () => T): T { + /** The state-root chain the case does not name is cleared, so an ambient GSTACK_STATE_ROOT (every gstack launch file exports one) cannot pick the root. */ + function withEnv(given: Record, work: () => T): T { + const env = { GSTACK_STATE_ROOT: undefined, GSTACK_STATE_DIR: undefined, CLAUDE_PLUGIN_DATA: undefined, ...given }; const previous = Object.fromEntries(Object.keys(env).map(k => [k, process.env[k]])); for (const [k, v] of Object.entries(env)) if (v === undefined) delete process.env[k]; else process.env[k] = v; try { return work(); } diff --git a/test/bootstrap-retention-shard.test.ts b/test/bootstrap-retention-shard.test.ts index cbeac1275..2b120bd4e 100644 --- a/test/bootstrap-retention-shard.test.ts +++ b/test/bootstrap-retention-shard.test.ts @@ -62,7 +62,7 @@ describe.skipIf(process.platform !== 'linux')('bootstrap paid-shard cleanup inte fs.writeFileSync(path.join(root, 'controller.stdout.log'), controller.stdout ?? '', { mode: 0o600 }); fs.writeFileSync(path.join(root, 'controller.stderr.log'), controller.stderr ?? '', { mode: 0o600 }); expect(controller.error).toBeUndefined(); - expect(controller.status).toBe(0); + expect(controller.status, `controller exited ${controller.status} (signal ${controller.signal}); stderr tail: ${(controller.stderr ?? '').slice(-600).replace(/\s*\n\s*/g, ' | ')}`).toBe(0); const outcome = JSON.parse(fs.readFileSync(outcomePath, 'utf8')); if (scenario === 'ack-failure') { expect(controller.stdout).toContain('(fail) qa-bootstrap'); diff --git a/test/codex-probe-cache.test.ts b/test/codex-probe-cache.test.ts index 002fdb921..981fde6dd 100644 --- a/test/codex-probe-cache.test.ts +++ b/test/codex-probe-cache.test.ts @@ -247,6 +247,31 @@ describe('probe lock recovery and failure', () => { expect(calls(f)).toBe(1); })); + test('a lock released while a waiter reads its age is not abandoned; the next owner\'s fresh lock survives', withFixture((f) => { + const sig = sigOf(f); + const lock = path.join(f.locks, sig); + const once = path.join(f.home, 'vanish-once'); + const real = JSON.stringify(Bun.which('stat')); + fs.mkdirSync(lock, { recursive: true }); + fs.writeFileSync(path.join(f.home, 'bin/stat'), `#!/usr/bin/env bash +if [ "$3" = "$RACE_LOCK" ] && [ -f "$RACE_ONCE" ]; then + case "$1" in + -c) rmdir "$RACE_LOCK"; [ "$RACE_PHASE" = early ] && mkdir "$RACE_LOCK"; exit 1 ;; + -f) ${real} "$@"; rc=$?; rm -f "$RACE_ONCE"; [ -d "$RACE_LOCK" ] || mkdir "$RACE_LOCK"; exit "$rc" ;; + esac +fi +exec ${real} "$@" +`, { mode: 0o755 }); + for (const phase of ['early', 'late']) { + fs.writeFileSync(once, 'armed'); + const r = probe(f, { _GSTACK_CODEX_LOCK_WAIT: '1', RACE_LOCK: lock, RACE_ONCE: once, RACE_PHASE: phase }); + expect(fs.existsSync(once)).toBe(false); + expect(r.stdout).toContain('MODEL_PROBE_INCONCLUSIVE (another probe of this model still holds its lock)'); + expect(fs.existsSync(lock)).toBe(true); + expect(calls(f)).toBe(0); + } + })); + test('an unreadable cache or uncreatable lock is unverified and launches no paid probe', withFixture((f) => { fs.mkdirSync(f.cache); const dir = probe(f); diff --git a/test/evidence.test.ts b/test/evidence.test.ts index e40d6769c..0617ef8ed 100644 --- a/test/evidence.test.ts +++ b/test/evidence.test.ts @@ -74,6 +74,14 @@ describe('gstack-evidence run', () => { expect(fs.readFileSync(rec.log_path, 'utf-8')).toContain('ok'); }); + test('the lane command sees its own log path as GSTACK_EVIDENCE_LOG (the free runner\'s home tripwire excludes exactly that file)', () => { + const r = run(['run', '--label', 'focused', '--', 'printf "%s" "$GSTACK_EVIDENCE_LOG"']); + expect(r.status).toBe(0); + const rec = records().pop(); + expect(path.isAbsolute(rec.log_path)).toBe(true); + expect(r.stdout).toBe(rec.log_path); + }); + test('propagates a failing exit code and records it', () => { const r = run(['run', '--label', 'tests', '--', 'exit 3']); expect(r.status).toBe(3); diff --git a/test/free-home-guard.test.ts b/test/free-home-guard.test.ts index d01c8c979..4f387b5bd 100644 --- a/test/free-home-guard.test.ts +++ b/test/free-home-guard.test.ts @@ -78,6 +78,34 @@ describe('free-suite home-write tripwire (#2895)', () => { expect(failure).toContain('only'); }); + test.skipIf(process.platform === 'win32')('the wrapping gstack-evidence lane log is excluded by its exact path; a foreign write beside it still trips', async () => { + const home = fakeHome(); + const logs = path.join(home, '.gstack/projects/p/logs'); + fs.mkdirSync(logs, { recursive: true }); + const laneLog = path.join(logs, 'focused.log'); + fs.writeFileSync(laneLog, 'lane start\n'); + const unexcluded = guardFreeHome(['test/a.test.ts'], { HOME: home }); + const options = (script: string, name: string) => ({ env: { ...process.env, HOME: home, GSTACK_EVIDENCE_LOG: laneLog }, quiet: true, log: () => {}, + logFilePath: path.join(home, `${name}.log`), + commandFor: () => ({ command: process.execPath, args: ['-e', `${script}console.log('Ran 1 tests across 1 files. [1.00ms]')`] }) }); + const grow = `require('fs').appendFileSync(${JSON.stringify(laneLog)}, 'lane output\\n');`; + const errors: string[] = []; + const spy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => { errors.push(args.join(' ')); }); + try { + expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(grow, 'lane'))).status).toBe('passed'); + expect(unexcluded.verify()).toContain('~/.gstack/projects/p/logs/focused.log'); + const foreign = `${grow}require('fs').writeFileSync(${JSON.stringify(path.join(logs, 'other.log'))}, 'x');`; + expect((await runFreeShard(['test/a.test.ts'], 1, 1, options(foreign, 'foreign'))).status).toBe('failed'); + expect(errors.join('\n')).toContain('~/.gstack/projects/p/logs/other.log'); + expect(errors.join('\n')).not.toContain('focused.log'); + } finally { + spy.mockRestore(); + for (const retained of errors.flatMap(line => /retained (\S+)$/.exec(line)?.[1] ?? [])) { + if (/^gstack-free-shard-/.test(path.basename(retained))) fs.rmSync(retained, { recursive: true, force: true }); + } + } + }); + test('a concurrent phase guard names no shard and points at attribution', () => { const home = fakeHome(); const guard = guardFreeHome(['test/a.test.ts', 'test/b.test.ts'], { HOME: home }, { kind: 'concurrent', shards: 3 }); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index 6349ec7d9..39388ac41 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -1460,8 +1460,13 @@ describe('scan: option-like bases and page-controlled inline ignores', () => { fs.mkdirSync(many, { recursive: true }); for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}'); try { - const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } }); + const log = path.join(SANDBOX, 'argv-many.log'); + fs.rmSync(log, { force: true }); + const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } }); expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files + const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]); + expect(calls).toHaveLength(1); + expect(calls[0].some(x => x.endsWith(path.join('src', 'many')))).toBe(true); const files = fs.readdirSync(many).map(f => path.join('src', 'many', f)); const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '1000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '1500' } }); expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 7500ms exceeded, \d+ of 11 batches not run/); diff --git a/test/helpers/free-owned-browser-settlement.ts b/test/helpers/free-owned-browser-settlement.ts index c1a488154..bfe6aa896 100644 --- a/test/helpers/free-owned-browser-settlement.ts +++ b/test/helpers/free-owned-browser-settlement.ts @@ -17,7 +17,7 @@ const ROOT = path.resolve(import.meta.dir, '../..'); export const SETTLEMENT_MODE_GROUPS = { lifecycle: ['success', 'failure', 'timeout', 'cancel', 'cancel-force', 'cancel-cold-probes', 'settle-cold-probes', 'settle-slow-exit', 'cancel-vanishing-record'], ownership: ['endpoint-mix', 'full-state-mix', 'terminal-mix', 'chromium-mix', 'replaced-pid', 'stale-child-start', 'replaced-start', - 'exit-environment-race', 'unavailable-environment', 'directory-remove-failure'], + 'exit-environment-race', 'unavailable-environment', 'directory-remove-failure', 'exited-child-race'], } as const; const OWNERSHIP_ACTOR = ` @@ -50,6 +50,7 @@ if (role === 'leaf') { })); await Bun.sleep(50); const owned = [identity(process.pid), ...children.map(child => identity(child.pid))]; + if (mode === 'exited-child-race') await new Promise(resolve => { children[0].once('exit', resolve); children[0].kill('SIGKILL'); }); const server = Bun.serve({hostname:'127.0.0.1', port:0, fetch() { fs.appendFileSync(file('.http'), 'request\\n'); return new Response('ok'); }}); diff --git a/test/helpers/restamp-fixture.ts b/test/helpers/restamp-fixture.ts index 2b6366c99..d76f58add 100644 --- a/test/helpers/restamp-fixture.ts +++ b/test/helpers/restamp-fixture.ts @@ -32,6 +32,31 @@ export const BASE_POLICY = { stamp_sensitive_gates: ['digest'], }; +function fixtureGit(root: string) { + const home = path.join(root, 'home'); + const work = path.join(root, 'work'); + const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + const git = (...args: string[]): string => { + const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr?.toString()}`); + return r.stdout?.toString().trim() ?? ''; + }; + return { env, git }; +} + +/** + * A private copy of a built fixture (origin, clone, home, and any files under its root), for a test file that + * would otherwise rebuild the same multi-second fixture in every test. Only the origin remote holds an absolute path. + */ +export function copyFixtureRepo(src: FixtureRepo, prefix = 'gstack-restamp-fx-'): FixtureRepo { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + fs.cpSync(src.root, root, { recursive: true, preserveTimestamps: true }); + const { env, git } = fixtureGit(root); + const origin = path.join(root, 'origin.git'); + git('remote', 'set-url', 'origin', origin); + return { root, work: path.join(root, 'work'), origin, home: env.HOME, git, env }; +} + export function makeFixtureRepo(opts: { policy?: Record | null; version?: string; prefix?: string } = {}): FixtureRepo { const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-restamp-fx-')); const origin = path.join(root, 'origin.git'); @@ -39,13 +64,8 @@ export function makeFixtureRepo(opts: { policy?: Record | null; const home = path.join(root, 'home'); fs.mkdirSync(home, { recursive: true }); fs.mkdirSync(work, { recursive: true }); - const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + const { env, git } = fixtureGit(root); fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); - const git = (...args: string[]): string => { - const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env }); - if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr?.toString()}`); - return r.stdout?.toString().trim() ?? ''; - }; spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } }); git('init', '-q', '-b', 'main'); const version = opts.version ?? '1.2.3.0'; diff --git a/test/office-hours-attempt.test.ts b/test/office-hours-attempt.test.ts index c36da4486..c96bc8889 100644 --- a/test/office-hours-attempt.test.ts +++ b/test/office-hours-attempt.test.ts @@ -312,7 +312,8 @@ describe('Office Hours real session runner with fake processes', () => { expect(captured.output).toBe('captured output'); const diagnostic = JSON.parse(fs.readFileSync(path.join(dir, '.gstack/test-transcripts/drain-failure.json'), 'utf8')); expect(diagnostic.stderr).toContain('fixture auth failure'); - expect(running(Number(fs.readFileSync(path.join(dir, 'child.pid'), 'utf8')))).toBe(false); + const child = fs.readFileSync(path.join(dir, 'child.pid'), 'utf8').trim(); + expect(running(Number(child)), `held-pipe child after the runner returned (group ${fs.readFileSync(path.join(dir, 'parent.pid'), 'utf8').trim()}): ${spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,stat=,etime=', '-p', child], { encoding: 'utf8', timeout: 5_000 }).stdout.trim() || 'gone now'}`).toBe(false); }); }, 10_000); } diff --git a/test/pregate-regate.test.ts b/test/pregate-regate.test.ts index 2e31a4c5f..3ecccc3bb 100644 --- a/test/pregate-regate.test.ts +++ b/test/pregate-regate.test.ts @@ -6,11 +6,11 @@ * the base branch and every other open PR, and a lookup that cannot finish is * `unknown`, which withholds `REGATE: not-needed`. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; +import { BASE_POLICY, cleanup, copyFixtureRepo, makeFixtureRepo, makePr, recordAndBundle, restamp, runBin, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); @@ -39,10 +39,13 @@ console.error("gh shim: unsupported "+a.join(" ")); process.exit(1)' "${file}" " } const ghCalls = (repo: FixtureRepo) => fs.readFileSync(path.join(repo.root, 'gh-regate', 'calls.log'), 'utf8'); +type Lanes = Array<{ label: string; command: string }>; +const TESTS_LANE: Lanes = [{ label: 'tests', command: 'true' }]; +const DIGEST_LANES: Lanes = [...TESTS_LANE, { label: 'digest', command: 'cat generated/digest.md' }]; + /** pr-a adds migration v240, is stamped 1.2.4.0 and gated (lanes recorded and bundled); then restamped to 1.2.5.0. */ -function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [{ label: 'tests', command: 'true' }]) { +function buildGated(lanes: Lanes) { const repo = makeFixtureRepo({ prefix: 'pregate-regate-', policy: POLICY }); - repos.push(repo); makePr(repo, 'pr-a', { expects: '1.2.4.0' }); fs.mkdirSync(path.join(repo.work, 'migrations'), { recursive: true }); fs.writeFileSync(path.join(repo.work, 'migrations', 'v240-add-index.sql'), 'create index a;\n'); @@ -54,7 +57,20 @@ function gatedThenRestamped(lanes: Array<{ label: string; command: string }> = [ const head = repo.git('rev-parse', 'HEAD'); expect(restamp(repo, ['--version', '1.2.5.0']).status).toBe(EXIT.ok); repo.git('commit', '-qam', 'stamp 1.2.5.0'); - return { repo, head, bundle, current: repo.git('rev-parse', 'HEAD') }; + return { repo, head, current: repo.git('rev-parse', 'HEAD') }; +} + +// Each fixture costs four gstack bin runs (two restamps, an evidence run and a bundle); build each lane set once +// and give every test its own copy instead of rebuilding it per test. +const templates = new Map>(); +beforeAll(() => { for (const lanes of [TESTS_LANE, DIGEST_LANES]) templates.set(lanes, buildGated(lanes)); }, 60_000); +afterAll(() => { for (const t of templates.values()) cleanup(t.repo); }); + +function gatedThenRestamped(lanes: Lanes = TESTS_LANE) { + const { repo: template, head, current } = templates.get(lanes)!; + const repo = copyFixtureRepo(template, 'pregate-regate-'); + repos.push(repo); + return { repo, head, bundle: path.join(repo.root, 'bundle.json'), current }; } const regate = (repo: FixtureRepo, args: string[], env: Record) => runBin(repo, 'gstack-pregate', ['regate', '--base', 'main', ...args], env); @@ -97,7 +113,7 @@ describe('gstack-pregate regate: evidence, stamps and lane verdicts', () => { }); test('a stamp-sensitive lane reruns, so a regate is needed; a code edit is not stamp-only and names the file and line', () => { - const { repo, head, bundle } = gatedThenRestamped([{ label: 'tests', command: 'true' }, { label: 'digest', command: 'cat generated/digest.md' }]); + const { repo, head, bundle } = gatedThenRestamped(DIGEST_LANES); const env = ghShim(repo, { list: [] }); const r = regate(repo, ['--against', head, '--bundle', bundle], env); expect(r.status).toBe(EXIT.fail); diff --git a/test/session-update-autostash.test.ts b/test/session-update-autostash.test.ts index f2ab223d9..d5a0cff24 100644 --- a/test/session-update-autostash.test.ts +++ b/test/session-update-autostash.test.ts @@ -284,7 +284,8 @@ describe('gstack-session-update lock identity + TTL (#2613)', () => { const r = runScript(base, install, state); expect(r.status).toBe(0); const log = await waitForLog(state, /UP_TO_DATE|UPDATING/); - expect(log).toMatch(/UP_TO_DATE|UPDATING/); + const lockPid = fs.existsSync(path.join(lockDir, 'pid')) ? fs.readFileSync(path.join(lockDir, 'pid'), 'utf8').trim() : 'no lock'; + expect(log, `planted dead pid ${dead.pid} (alive now: ${isAlive(dead.pid)}); lock pid after 15 s: ${lockPid} (alive: ${isAlive(Number(lockPid))}); script exit ${r.status}, stderr: ${r.stderr}`).toMatch(/UP_TO_DATE|UPDATING/); } finally { fs.rmSync(base, { recursive: true, force: true }); } diff --git a/test/shard-engine.test.ts b/test/shard-engine.test.ts index 272d5d740..60c99de61 100644 --- a/test/shard-engine.test.ts +++ b/test/shard-engine.test.ts @@ -171,7 +171,10 @@ describe('shard engine: process lifecycle', () => { }); expect(paid.status).toBe('timed-out'); expect(summaryExitCode(summarize([paid]))).toBe(1); - expect(await waitDead(Number(fs.readFileSync(pidFile, 'utf8')))).toBe(true); + const paidChild = Number(fs.readFileSync(pidFile, 'utf8')); + const paidChildDead = await waitDead(paidChild); + const procStat = (pid: number) => { try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim(); } catch { return 'no /proc entry'; } }; + expect(paidChildDead, `paid grandchild ${paidChild} of group ${paid.groupPid} 2 s after the timeout kill: ${procStat(paidChild)}`).toBe(true); }, 30_000); test('win32 semantics: no process group and never a negative-pid kill', async () => { diff --git a/test/ship-budget.test.ts b/test/ship-budget.test.ts index 2ce43a144..5043da5a1 100644 --- a/test/ship-budget.test.ts +++ b/test/ship-budget.test.ts @@ -308,7 +308,7 @@ describe('Claude Code child launch', () => { const file = ho.out.match(/^HANDOFF: (\S+)/m)![1]!; const argsFile = path.join(h.root, 'claude-args.txt'); const fake = path.join(h.root, 'fake-claude'); - fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}"\n`, { mode: 0o755 }); + fs.writeFileSync(fake, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsFile}.tmp" && mv "${argsFile}.tmp" "${argsFile}"\n`, { mode: 0o755 }); const r = h.sb(['launch', '--in', file], { GSTACK_CLAUDE_BIN: fake }); expect(r.code).toBe(0); const session = h.field(r.out, 'CHILD', 'session'); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index ad5c056a3..9a46e65be 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -35,13 +35,16 @@ const result = (out: string) => line(out, 'GSTACK_RESULT:'); const next = (out: string) => line(out, 'NEXT:'); const remoteRefs = (fx: TrainFixture) => fx.repo.git('ls-remote', 'origin'); -// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/, which a local copy refuses on every run; no_claim=which process removed the loose object those runs named +// fails_when=coordinatorClone copies origin.git/objects file by file again, so an entry that appears or vanishes there mid-copy (the detached maintenance.lock every push into the fixture origin leaves) fails the clone, as in two Free Tests reds on 2026-10-28 (GSTA-139 ledger); why_new=no other test clones while origin's objects directory changes; seam=a dangling symlink planted under objects/ beside a held maintenance.lock, which a local copy refuses on every run; the seam has its own name because the fixture push's own detached maintenance can hold objects/maintenance.lock at that moment (EEXIST in Free Tests 41288253305 and 41288417894, 2026-11-01); no_claim=which process removed the loose object those runs named test('the coordinator clone reads origin through git, never by copying its objects directory (GSTA-139)', () => { const fx = make(); - fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(fx.repo.origin, 'objects', 'maintenance.lock')); + const objects = path.join(fx.repo.origin, 'objects'); + fs.writeFileSync(path.join(objects, 'maintenance.lock'), ''); + fs.symlinkSync(path.join(fx.repo.root, 'gone'), path.join(objects, 'gstack-dangling-seam')); const coord = coordinatorClone(fx.repo); expect(spawnSync('git', ['-C', coord, 'rev-parse', 'origin/main'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...fx.repo.env } }).stdout.trim()).toBe(fx.repo.git('rev-parse', 'origin/main')); expect(fs.existsSync(path.join(coord, '.git', 'objects', 'maintenance.lock'))).toBe(false); + expect(fs.readdirSync(path.join(coord, '.git', 'objects'))).not.toContain('gstack-dangling-seam'); }); describe('the train command surface', () => {