mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
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>
This commit is contained in:
1 parent
83ae96e086
commit
fbbec20967
20 files changed
+164
-29
No files matched your search
@@ -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"
|
||||
|
||||
+1
-1
@@ -400,7 +400,7 @@ async function cmdRun(argv: string[]): Promise<number> {
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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); }
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T>(env: Record<string, string | undefined>, 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<T>(given: Record<string, string | undefined>, 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(); }
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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');
|
||||
}});
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<Lanes, ReturnType<typeof buildGated>>();
|
||||
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<string, string>) => 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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user