fix(browse): per-process state-file temp path to fix concurrent-write ENOENT#1310
Open
yashkot007 wants to merge 1 commit intogarrytan:mainfrom
Open
fix(browse): per-process state-file temp path to fix concurrent-write ENOENT#1310yashkot007 wants to merge 1 commit intogarrytan:mainfrom
yashkot007 wants to merge 1 commit intogarrytan:mainfrom
Conversation
… ENOENT
The daemon writes `.gstack/browse.json` via the standard atomic-rename
pattern: `writeFileSync(tmp, …) → renameSync(tmp, stateFile)`. Four
sites in server.ts use this pattern (initial daemon-startup state at
:2002, /tunnel/start handler at :1479, BROWSE_TUNNEL=1 inline tunnel
update at :2083, BROWSE_TUNNEL_LOCAL_ONLY=1 update at :2113), and all
four hard-code the same temp filename `${stateFile}.tmp`.
Under concurrent writers the shared filename races on the rename:
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
Reproduced empirically with 15 concurrent CLIs against a fresh `.gstack/`:
[browse] Failed to start: ENOENT: no such file or directory,
rename '…/.gstack/browse.json.tmp' -> '…/.gstack/browse.json'
Pre-fix success rate: **0 / 15** under cold-start race.
Post-fix success rate: **15 / 15**, zero ENOENT.
Fix:
- New `tmpStatePath()` helper (server.ts:333) returns
`${stateFile}.tmp.${pid}.${randomBytes(4).toString('hex')}`
- All 4 call sites use `tmpStatePath()` instead of the shared literal
- Atomic rename still gives last-writer-wins semantics on the final
state.json content; only behavior change is that concurrent writers
no longer kill each other on the rename step
Source-level guard test (browse/test/server-tmp-state-path.test.ts)
locks two invariants: (1) no remaining `stateFile + '.tmp'` literals,
(2) every state-write `writeFileSync` call uses `tmpStatePath()`. Same
read-source-as-text pattern as terminal-agent.test.ts and
dual-listener.test.ts — no daemon required, runs in tier-1 free.
Test plan:
- [x] Targeted source-level guard test passes (3 / 0)
- [x] `bun run build` clean
- [x] Live regression: 15 concurrent CLIs against cold state →
15 / 15 healthy, 0 ENOENT (vs 0 / 15 pre-fix)
- [x] No `.tmp.*` orphans left behind after rename succeeds
- [x] Related test cluster (server-auth, dual-listener, cdp-mutex,
findport) — same pre-existing flakes as `main`, no new
regressions introduced
🤖 Generated with [Claude Code](https://claude.com/claude-code)
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The daemon writes
.gstack/browse.jsonvia the standard atomic-rename pattern:Four sites in
server.tsuse this pattern, and all four hard-code the same temp filename${stateFile}.tmp:20021479/tunnel/starthandler state update2083BROWSE_TUNNEL=1inline tunnel update2113BROWSE_TUNNEL_LOCAL_ONLY=1updateUnder concurrent writers the shared filename races on the rename:
The atomic rename was meant to protect the write, but it doesn't protect against two writers using the same temp filename. The second writer's
renameSyncthrows ENOENT mid-air, killing one of the spawning daemons during startup.How it surfaced
15 concurrent CLIs against a fresh
.gstack/:Status: healthyRepro:
for i in 1..15; do ./browse/dist/browse status & done; waitFix
New helper
tmpStatePath()returns a per-process unique temp path:All 4 call sites swap
config.stateFile + '.tmp'fortmpStatePath(). The atomic rename still gives last-writer-wins semantics on the final state file content; the only behavior change is that concurrent writers no longer kill each other on the rename step.cryptoandfsare already imported. No new dependencies.Regression test
browse/test/server-tmp-state-path.test.ts— source-level guard followingterminal-agent.test.tsanddual-listener.test.ts(read source as text, assert invariant, no daemon required).Locks three invariants:
stateFile + '.tmp'literals inserver.ts(regression catch — a future copy-paste or revert reintroducing the bug fails this)writeFileSynccall usestmpStatePath()(positive coverage)tmpStatePath()body references bothprocess.pidANDcrypto.randomBytes(locks the uniqueness shape so a future "simplification" doesn't strip uniqueness back out)Test plan
bun run build— clean rebuildmain).tmp.*files left behind after the rename completesmain— no new regressions introduced by this changeRelated
Independent of #1309 (also from this stress-test run —
flushBuffersundeclared variable). Both touchserver.tsbut at different sites; either can land first, the other rebases trivially.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need.