mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
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:
1 parent
3ab7b149c9
commit
39b06e22af
7 files changed
+72
-18
No files matched your search
@@ -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,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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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){
|
||||
|
||||
Reference in new issue
Block a user