v1.91.250.0 fix(tests): remove three free-tier fixture races (GSTA-107 daily flaky pass) (#3474)

* fix(tests): remove three free-tier fixture races: atomic result files in dotenv-isolation and claude-code-runner, per-fixture Git repo in cso-git-hardening (GSTA-107)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

The new dotenv-isolation test spawns Bun with emptyBunConfig() instead of a
literal --config=/dev/null, which the Windows lane cannot open; the runner's
timeout case asserts the descendant published a pid before asking whether it
is running, so a missed readiness window names itself.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(tests): cso-git-hardening fixtures turn git auto-maintenance off

seedRepo commits with fixtureGit's replacement env, which never sees the
shard backstop, so each commit started a detached git maintenance run that
creates and deletes .git/objects/maintenance.lock under the tests' own .git
reads and the afterEach rmSync (docs/TESTING_INTERNALS.md, Free-suite flake
rules). Found by /ship's documentation audit; git 2.55.0 trace2 shows the
maintenance spawn with the keys unset and none with them set.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-26 17:13:33 +00:00
1 parent 3ab7b149c9
commit 39b06e22af
7 files changed
+72 -18

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# Changelog
## [1.91.250.0] - 2026-10-26
**Three free-tier fixtures stop racing themselves (GSTA-107 daily flaky pass).** Two tests polled for a result file's existence while its writer created it with a plain `writeFileSync`, so a poll between create and write read an empty file; a third copied a shared template repository with `fs.cpSync` and threw once inside the directory walk.
### Fixed
- **`browse/test/dotenv-isolation.test.ts`** (Free Tests 39902683183 and 40322888041, shard 2, "JSON Parse error: Unexpected EOF" at the `agent-env.json` read): the probe writes its result to a temp file and renames it into place, and `waitForFile` waits for content, not only for the file. A new test feeds the old race (an empty file filled 150 ms later) and runs the probe source itself.
- **`test/claude-code-runner.test.ts`** (Free Tests 39694715116, shard 13, `running(pid)` false at the timeout case): the descendant publishes `descendant.pid` by rename, and the test polls `publishedPid()`, a positive integer, instead of the file's existence, so an empty read can no longer become pid 0.
- **`test/cso-git-hardening.test.ts`** (Free Tests 40288142343, shard 17, `fs.cpSync` threw in `fixture()` with an empty message and passed on the serial retry): every fixture builds its own repository with Git instead of copying a shared template, with auto-maintenance off (`maintenance.auto=false`, `gc.auto=0`) so the detached `git maintenance run` a commit starts no longer touches `.git` while a test walks or removes it; the `beforeAll` still pays the cold macOS Git spawns once, on a throwaway repository. No timeout changed.
## [1.91.249.0] - 2026-10-26
**Five paid-case flakes from the 2026-10-26 train gates, repaired at their causes (GSTA-107 daily flaky pass).** The "ship-docsync family" was three distinct slips, not one shared cause: a mistyped `--help` path the detector refused although the usage had already been read, an undeclared refusal of gstack's own state directory as an artifact location, and a fixture that did not install the shared preamble a generated skill requires. tpa-present's consent question paraphrased "defer" as "wait for now", and qa-functional-webhook-fix ran the probe's `--help` bare before its first capture.
+1 -1
View File
@@ -1 +1 @@
1.91.249.0
1.91.250.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.249.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.250.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+25 -2
View File
@@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { emptyBunConfig } from '../src/bun-child-config';
import { spawnTerminalAgent } from '../src/terminal-agent-control';
const ROOT = path.resolve(import.meta.dir, '..', '..');
@@ -22,13 +23,22 @@ function seedHostileProject(dir: string): void {
fs.writeFileSync(path.join(dir, 'bunfig.toml'), 'preload = ["./preload.ts"]\n');
}
// The probe publishes its result by rename, so a poller never sees the file
// half-written (Free Tests 39902683183 and 40322888041: a 50 ms poll read the
// empty file writeFileSync had just created, "JSON Parse error: Unexpected EOF").
function probeSource(out: string): string {
return `require('node:fs').writeFileSync(${JSON.stringify(out)}, JSON.stringify({ sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null }));\n`;
return `const fs = require('node:fs'); const tmp = ${JSON.stringify(out)} + '.tmp'; fs.writeFileSync(tmp, JSON.stringify({ sandbox: process.env.GSTACK_CHROMIUM_NO_SANDBOX ?? null })); fs.renameSync(tmp, ${JSON.stringify(out)});\n`;
}
/** The probe's complete result: waits for the file to exist and to hold content, not only to exist. */
async function waitForFile(file: string): Promise<string> {
const deadline = Date.now() + 20_000;
while (!fs.existsSync(file) && Date.now() < deadline) await Bun.sleep(50);
let content = '';
while (Date.now() < deadline) {
try { content = fs.readFileSync(file, 'utf-8'); } catch {}
if (content) return content;
await Bun.sleep(50);
}
return fs.readFileSync(file, 'utf-8');
}
@@ -44,6 +54,19 @@ afterEach(() => {
});
describe('D0: project .env and bunfig.toml are ignored', () => {
test('waitForFile returns a result that is still being written only once it holds content', async () => {
const out = path.join(path.dirname(project), 'late.json');
fs.writeFileSync(out, '');
setTimeout(() => fs.writeFileSync(out, '{"sandbox":null}'), 150);
expect(JSON.parse(await waitForFile(out))).toEqual({ sandbox: null });
const atomic = path.join(path.dirname(project), 'atomic.json');
fs.writeFileSync(path.join(path.dirname(project), 'atomic-probe.ts'), probeSource(atomic));
const run = Bun.spawnSync([process.execPath, '--no-env-file', `--config=${emptyBunConfig()}`, path.join(path.dirname(project), 'atomic-probe.ts')], { cwd: project, env: { PATH: process.env.PATH ?? '' }, stdout: 'pipe', stderr: 'pipe', timeout: 30_000 });
expect(run.exitCode, run.stderr.toString()).toBe(0);
expect(JSON.parse(fs.readFileSync(atomic, 'utf-8'))).toEqual({ sandbox: null });
expect(fs.existsSync(atomic + '.tmp')).toBe(false);
});
test('every compiled binary in scripts/build.sh turns dotenv and bunfig autoload off, and the flags work', () => {
const lines = fs.readFileSync(path.join(ROOT, 'scripts', 'build.sh'), 'utf-8').split('\n').filter(l => /build --compile/.test(l));
expect(lines.length).toBeGreaterThanOrEqual(5);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.249",
"version": "1.91.250",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+16 -4
View File
@@ -18,8 +18,10 @@ const RESOLVED_DIR = realpathSync(DIR);
// Publish readiness only after the grandchild has initialized and flushed both
// inherited pipes; a PID returned by spawn alone does not establish that state.
// The pid lands by rename, so a poller that sees the file sees the whole pid
// (Free Tests 39694715116: an existence poll read the empty file as pid 0).
writeFileSync(DESCENDANT, `
import { appendFileSync, writeFileSync } from 'node:fs';
import { appendFileSync, renameSync, writeFileSync } from 'node:fs';
const record = stage => {
if (process.env.FAKE_MODE === 'timeout') {
try { appendFileSync(${JSON.stringify(DESCENDANT_STAGES)}, JSON.stringify({ stage, atUnixMs: Date.now(), processNs: process.hrtime.bigint().toString(), pid: process.pid }) + '\\n'); } catch {}
@@ -32,7 +34,8 @@ await new Promise(resolve => process.stdout.write(' ', resolve));
record('stdout_flushed');
await new Promise(resolve => process.stderr.write(' ', resolve));
record('stderr_flushed');
writeFileSync(process.env.PID_FILE!, String(process.pid));
writeFileSync(process.env.PID_FILE! + '.tmp', String(process.pid));
renameSync(process.env.PID_FILE! + '.tmp', process.env.PID_FILE!);
record('pid_published');
`);
@@ -136,6 +139,13 @@ function running(pid: number): boolean {
} catch { return false; }
}
/** The descendant's published pid: a positive integer in the pid file, or undefined while unpublished. */
function publishedPid(): number | undefined {
let text: string;
try { text = readFileSync(PID, 'utf8'); } catch { return undefined; }
return /^[1-9]\d*$/.test(text) ? Number(text) : undefined;
}
async function expectDescendantDead() {
const pid = Number(readFileSync(PID, 'utf8'));
try {
@@ -295,8 +305,10 @@ describe('Claude Code restricted execution', () => {
try {
expect(fireTimeout).toBeDefined();
const readyBy = Date.now() + 2000;
while (!existsSync(PID) && Date.now() < readyBy) await Bun.sleep(5);
expect(running(Number(readFileSync(PID, 'utf8')))).toBe(true);
while (publishedPid() === undefined && Date.now() < readyBy) await Bun.sleep(5);
const descendantPid = publishedPid();
expect(descendantPid, 'descendant never published its pid within the readiness window').toBeDefined();
expect(running(descendantPid!)).toBe(true);
const start = Date.now();
const expire = fireTimeout!;
fireTimeout = undefined;
+19 -9
View File
@@ -1,4 +1,4 @@
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test';
import { afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
@@ -10,20 +10,30 @@ const roots:string[]=[];
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
const fixtureGit=(repo:string,home:string)=>(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:home,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;};
let template='';
// Each fixture builds its own repository from Git; nothing is copied from a shared
// template (Free Tests 40288142343 shard 17: fs.cpSync of the template threw inside
// its directory walk once and passed on the serial retry). Auto-maintenance is off
// because `git commit` otherwise starts a detached `git maintenance run --auto` that
// creates and deletes .git/objects/maintenance.lock after the commit returns, under
// the tests' own .git reads and the afterEach rmSync (docs/TESTING_INTERNALS.md,
// Free-suite flake rules); fixtureGit's replacement env never sees the shard backstop.
function seedRepo(repo:string,home:string){
const git=fixtureGit(repo,home);fs.mkdirSync(repo);
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');
git('config','maintenance.auto','false');git('config','gc.auto','0');
fs.writeFileSync(path.join(repo,'tracked.ts'),'export const tracked = true\n');git('add','tracked.ts');git('commit','-qm','base');
return git;
}
// The first /usr/bin/git spawns on a cold macOS runner can eat a test's whole 5 s budget before the code under test runs; pay them once here.
beforeAll(()=>{
template=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-template-')));
const repo=path.join(template,'repo'),git=fixtureGit(repo,template);fs.mkdirSync(repo);
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');
fs.writeFileSync(path.join(repo,'tracked.ts'),'export const tracked = true\n');git('add','tracked.ts');git('commit','-qm','base');
const warm=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-warm-')));
try{seedRepo(path.join(warm,'repo'),warm);}finally{fs.rmSync(warm,{recursive:true,force:true});}
},{timeout:30_000});
afterAll(()=>fs.rmSync(template,{recursive:true,force:true}));
function fixture(){
const root=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-'))),repo=path.join(root,'repo'),runDir=path.join(root,'state','run');
roots.push(root);fs.cpSync(path.join(template,'repo'),repo,{recursive:true});fs.mkdirSync(runDir,{recursive:true,mode:0o700});
return{root,repo,runDir,git:fixtureGit(repo,root)};
roots.push(root);const git=seedRepo(repo,root);fs.mkdirSync(runDir,{recursive:true,mode:0o700});
return{root,repo,runDir,git};
}
function replaceWithSymlinkAfterLstat(target:string,replacement:string,occurrence=1){