Initial import from garrytan/gstack@026751e (main snapshot via local relay)
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled
Source: https://github.com/garrytan/gstack/commit/026751e
This commit is contained in:
110
browse/test/server-tmp-state-path.test.ts
Normal file
110
browse/test/server-tmp-state-path.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Regression: state-file temp path uniqueness.
|
||||
*
|
||||
* The daemon writes `.gstack/browse.json` via the standard atomic-rename
|
||||
* pattern: `writeFileSync(tmp, …) → renameSync(tmp, stateFile)`. The
|
||||
* pattern is correct for a single writer. It breaks for *concurrent*
|
||||
* writers when they share a single temp filename:
|
||||
*
|
||||
* t0 Writer A: writeFileSync(stateFile + '.tmp', payloadA)
|
||||
* t1 Writer B: writeFileSync(stateFile + '.tmp', payloadB) // overwrites A
|
||||
* t2 Writer A: renameSync(stateFile + '.tmp', stateFile) // moves B's payload
|
||||
* t3 Writer B: renameSync(stateFile + '.tmp', stateFile) // ENOENT — file gone
|
||||
*
|
||||
* A 15-CLI cold-start race against a fresh repo reproduces this in the
|
||||
* wild — one of the spawned daemons dies with:
|
||||
*
|
||||
* [browse] Failed to start: ENOENT: no such file or directory,
|
||||
* rename '…/.gstack/browse.json.tmp' -> '…/.gstack/browse.json'
|
||||
*
|
||||
* Fix: per-process temp path via `tmpStatePath()` (pid + 4 random bytes
|
||||
* of suffix). Each concurrent writer gets a unique path; the atomic
|
||||
* rename still gives last-writer-wins semantics on the final state file
|
||||
* content, but writers no longer kill each other on the rename step.
|
||||
*
|
||||
* This source-level guard locks two invariants:
|
||||
* 1. No remaining `stateFile + '.tmp'` literals in server.ts (regression
|
||||
* catch — a future copy-paste or revert would re-introduce the bug)
|
||||
* 2. The 4 known state-write call sites all use `tmpStatePath()`
|
||||
* (positive coverage)
|
||||
*
|
||||
* Same pattern as terminal-agent.test.ts and dual-listener.test.ts:
|
||||
* read source as text, assert invariant, no daemon required.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { readFileSync } from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const SERVER_TS = readFileSync(
|
||||
path.resolve(import.meta.dir, '../src/server.ts'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
describe('server.ts — state-file temp-path uniqueness', () => {
|
||||
test('no remaining `stateFile + \'.tmp\'` literals (regression catch)', () => {
|
||||
// The shared-temp-filename pattern that caused the cold-start ENOENT
|
||||
// race. A future contributor that copy-pastes the old pattern (or a
|
||||
// revert) will fail this test.
|
||||
const sharedTempLiterals = [
|
||||
...SERVER_TS.matchAll(/stateFile\s*\+\s*['"`]\.tmp['"`]/g),
|
||||
];
|
||||
expect(
|
||||
sharedTempLiterals.length,
|
||||
`Found ${sharedTempLiterals.length} reference(s) to the shared ` +
|
||||
`\`stateFile + '.tmp'\` pattern. Use \`tmpStatePath()\` instead — ` +
|
||||
`the shared pattern races on rename when two daemons spawn ` +
|
||||
`concurrently (cold-start race + parallel /tunnel/start).`,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('every state-file writeFileSync call uses tmpStatePath()', () => {
|
||||
// Find every `writeFileSync(X, JSON.stringify(stateContent...` or
|
||||
// `…(state, …)` call and verify X is `tmpStatePath()` or a variable
|
||||
// assigned from `tmpStatePath()`.
|
||||
const writeCalls = [
|
||||
...SERVER_TS.matchAll(
|
||||
/fs\.writeFileSync\s*\(\s*(\w+)\s*,\s*JSON\.stringify\(\s*(state|stateContent)/g,
|
||||
),
|
||||
];
|
||||
expect(
|
||||
writeCalls.length,
|
||||
'expected at least one state-file write site',
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
for (const m of writeCalls) {
|
||||
const varName = m[1]!;
|
||||
// Walk back to the assignment of varName — must come from tmpStatePath()
|
||||
const assignRe = new RegExp(
|
||||
`(?:const|let)\\s+${varName}\\s*=\\s*tmpStatePath\\(\\)`,
|
||||
);
|
||||
expect(
|
||||
assignRe.test(SERVER_TS),
|
||||
`state-file writeFileSync uses \`${varName}\` but no \`const ${varName} = tmpStatePath()\` ` +
|
||||
`assignment was found upstream. Either assign from tmpStatePath() ` +
|
||||
`or pass tmpStatePath() inline — the shared \`stateFile + '.tmp'\` ` +
|
||||
`pattern races under concurrent daemon startup`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('tmpStatePath() declaration includes a per-process unique suffix', () => {
|
||||
// Lock the suffix shape so a future contributor doesn't accidentally
|
||||
// strip the uniqueness back out by simplifying the helper.
|
||||
const declMatch = SERVER_TS.match(
|
||||
/function tmpStatePath\(\)[^{]*\{([\s\S]*?)\n\}/,
|
||||
);
|
||||
expect(declMatch, 'tmpStatePath() declaration not found').not.toBeNull();
|
||||
const body = declMatch![1]!;
|
||||
|
||||
// Must reference both process.pid and crypto.randomBytes — two
|
||||
// independent sources of uniqueness.
|
||||
expect(body, 'tmpStatePath() must include process.pid in the suffix').toContain(
|
||||
'process.pid',
|
||||
);
|
||||
expect(
|
||||
body,
|
||||
'tmpStatePath() must include a random suffix via crypto.randomBytes',
|
||||
).toContain('crypto.randomBytes');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user