Repository navigation
fix(opencode): notice an opencode.db replaced at the same path - #1082
Conversation
Refs #910 (item 4). `openOpencodeStore` keys its shared connection on path PLUS device/inode precisely so that a database replaced at the same path opens a new generation instead of keeping a lease on the unlinked file. The host never reached that: `createOpencodeDatabase` resolved the path once and cached the handle for the process lifetime, so after a replacement every reader — history for parked panes, the transcript MCP tools, Agent Management's locator — kept reading the deleted inode. New sessions simply never appeared until Agent Code restarted. `store()` now re-stats the file, at most once every 5 s, and reopens when the identity changed. Bounded because `store()` is on the path of every history chunk and every transcript tool call; 5 s is far below any human's "why is my new session missing" and far above the burst of calls a pane load makes. Two rules the tests pin, both deliberate: - a stat that FAILS keeps the handle. A missing or momentarily unreadable path is not evidence of a replacement, and swapping on it trades a handle that still reads for an open that cannot succeed. - the superseded handle is released on a timer, not inline. Callers borrow this shared handle and read from it synchronously after `await store()`, and promise continuations are microtasks — all of them drain before the next timer callback, so a queued continuation cannot have the handle pulled out from under it. Relocation stays out of scope and the comment says so: that answer comes from `resolveOpencodeDbPath`, which memoises for the process lifetime on purpose (one call costs a Bun process start). Re-resolving here would pay that cost on every history call to catch a reinstall. The system test builds two REAL databases from recorded sessions and renames one over the other, the way the verifier reproduced it, with a positive control that a store acquired directly from the package already sees the replacement. Mutation-tested: never revalidating (the shipped behaviour), revalidating on every call, swapping on a stat failure, not recording the real identity (which would churn the connection every interval) and leaving `pending` set across a swap are all caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing at event-loop phases Review findings on #1082. FINDING 1 (high, and a regression this PR introduced): the macrotask release was a rationalisation. The comment claimed "nothing in the app holds the handle across a further await" — and `AgentTranscriptReader.transcriptRecords` does exactly that: it walks an OpenCode session one page at a time and yields with `setImmediate` between pages, by design. A `setTimeout(…, 0)` release fires in the TIMERS phase, which precedes the CHECK phase where `setImmediate` resumes, so the release reliably won that race and the walk died mid-way with `transcript_read_failed`. All three transcript MCP tools route through it. The facade now counts holders. `lease()` returns a borrow that stays valid across awaits; a handle retired while leases are open is closed by the last `release()`, never before. `store()` keeps its old meaning — valid until the caller's next await — because every other consumer reads synchronously and wants exactly that. The transcript reader takes a lease for the length of the walk and drops it in a `finally`. FINDING 2a (medium): a single failed stat at open time left `openedIdentity` null, and the `openedIdentity === null` early return then disabled revalidation for the handle's life — silently reinstating the bug this file exists to fix. The early return is gone: the first successful stat after an unknown identity counts as a change, costing one spurious reopen that records a real one. FINDING 2b (medium): the identity was observed AFTER the open, which is a third independent stat. A replacement landing between the package's own confirming stat and ours recorded the NEW identity against a handle on the OLD inode — equal forever, unrecoverable. It is observed BEFORE the open now, so the same race records a STALE identity instead, which the next interval corrects. FINDING 4 (low): the changed-identity path made exactly the trade the stat-failure rule refuses — it released a working handle before knowing the reopen would succeed. `opencode.db` mid-rebuild has a new inode and is not yet a valid database. The old handle now keeps serving until the new one is in hand, and a reopen that throws leaves it in place for the next interval to retry. FINDING 5 (low): re-resolving the path is free (the memo answers every call); what would cost a Bun start is INVALIDATING it. The comment said the opposite. The scope decision itself is unchanged and correct. FINDING 3 (medium): the release decision was pinned by nothing, which is why the reviewer's M10 and M16 both lived. Twelve mutations now, no survivors: the macrotask release, an inline release ignoring leases, never releasing, a retired handle never closed on the last drop, the 2a early return, the 2b stat order, surfacing a reopen failure, retiring before the reopen, never revalidating, swapping on a stat failure, a non-idempotent lease release, and revalidating on every call. The end-to-end proof is a real walk over a 60-message session with the database replaced inside its first `setImmediate` gap. The facade-level tests pin the invariant directly, since the phase race is not deterministic across machines: with no lease the superseded handle is closed SYNCHRONOUSLY; with a lease it still reads until the lease drops. Refs #910 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Adversarial review returned REQUEST CHANGES with a blocker. All five findings addressed in 1. The macrotask release is a rationalisation (HIGH) — valid, and the whole design changedYou are right, and the reproduction is the kind that settles it. My comment asserted "nothing in the app holds the handle across a further await" and The fix is the second option you named: the facade counts holders.
Two facade-level tests pin the invariant directly, because the phase race is not deterministic across machines — I could not make it lose on mine, which is itself the argument for not testing the race: with no lease the superseded handle is closed synchronously; with a lease it still reads until the lease drops, and the swap still happens. The end-to-end proof is a real walk over a 60-message session with the database replaced inside its first Counting holders also disposes of your M15 ( 2. Two permanent wedges (MEDIUM) — both valid, both fixed2a. Exactly right, and the worse half is that it re-creates the bug this file exists to fix, silently. The 2b. Also right, and the comment was plainly false — I wrote "the identity the package validated its connection against" while taking a third independent stat. It is observed before the open now, so the same race records a stale identity, which the next interval corrects. Wrong in the direction that self-heals. Staging 2b took a specific fixture: the seam opens the handle on the old file and renames the replacement in immediately after, which is the only arrangement where stat-before and stat-after disagree. My first attempt renamed before the open and did not discriminate at all. 3. The release decision was pinned by nothing (MEDIUM) — validYour table is the useful half of this review: "no survivors" was true only of the five mutations I chose, which is not a mutation result, it is a restatement of the tests. Twelve now, no survivors:
Your M17 ( On "swap on a stat failure": it now takes an assertion that 4. The changed-identity path broke the PR's own principle (LOW) — valid, fixedA fair catch. The old handle keeps serving until the new one is in hand; a reopen that throws leaves it in place and the next interval retries. The test builds a new inode that is not yet a database (renamed into place, never written over the inode still in use — my first attempt corrupted the file the handle was reading) and asserts both halves: the reader keeps working, and it self-heals one interval later. 5. Comment inaccuracy in the scope rationale (LOW) — valid, correctedRe-resolving is free; invalidating the memo is what costs a Bun start. The paragraph says that now, and says what it used to say. On what held upThe inode check, the positive control, the no-churn assertion, the generation-counter walkthrough, the
|
… window Picks up Juliusolsson05/opencode-terminal-headless#5 (#910 item 1): all three exit transactions share one bounded retry window, each retried only while it is the one still deferring, and the outcome names the step that was actually stuck and the time actually spent. The package's own package.json is unchanged, so the lockfile does not need a resync (see project_submodule_bump_lockfile: only resync when the submodule's manifest changes). Refs #910 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Item 4 of #910 — the deferred medium/low findings from the OpenCode Terminal round-2 verification. Item 1 is the package half, opened as Juliusolsson05/opencode-terminal-headless#5; this branch will bump the submodule once that lands. Items 2 and 3 are #1081.
The bug
openOpencodeStorekeys its shared connection on path plus device/inode, precisely so that anopencode.dbreplaced at the same path opens a new generation rather than keeping a lease on the unlinked file (review R1-F10). The host never reached that mechanism:createOpencodeDatabaseresolved the path once and cached the handle for the process lifetime.So after a replacement — a reinstall, a restore from backup, a rebuilt file — every main-process reader kept reading the deleted inode: history for parked and reloaded panes, the agent-transcript MCP tools, Agent Management's transcript locator. New OpenCode sessions simply never appeared until Agent Code restarted.
The fix
store()re-stats the file, at most once perREVALIDATE_INTERVAL_MS(5 s), and reopens when the identity changed. Bounded becausestore()is on the path of every history chunk and every transcript tool call: astatSyncis microseconds, but per call it is still a syscall in a hot loop for an event that needs someone to replace a file by hand.Two rules the tests pin, both deliberate:
await store(), and a promise continuation is a microtask — all queued microtasks drain before the next timer callback, so a continuation that is already queued cannot have the handle pulled out from under it (the package makes a released handle throw).What this deliberately does not cover
The database moving. That answer comes from
resolveOpencodeDbPath, which memoises for the process lifetime on purpose — one call costs a Bun process start, paid before every pane's TUI can spawn — and its own invalidation policy already says relocation is picked up on the host's next start. Re-resolving here would pay that cost on every history call to catch a reinstall. The comment inopencodeDatabase.tsstates the split.Tests
opencodeDatabase.system.test.tsbuilds two real SQLite databases from recorded sessions (createProjectionDatabase) and renames one over the other — the verifier's V2 reproduction. It carries a positive control: a store acquired directly from the package already sees the replacement, so whatever the facade still misses is the facade's doing and not the package's generation selection. A second test covers the stat-failure rule.Mutation results
pendingset across a swapNo survivors.
npx tsc -bclean on Node 24.--project unit src/providers/opencode/ src/main/agentTranscripts/: 67 passed.🤖 Generated with Claude Code