Summary
Three machine-state files that are read back automatically are still written in place (writeJson = open + truncate + write), while the same codebase already writes config.yaml (#831) and the votes file atomically for exactly this reason. A crash, kill, disk-full or power loss mid-write leaves a truncated JSON, and the reader silently treats it as absent:
| File |
Written by |
Read back by |
What a torn write loses |
state.json (user + per-checkout) |
saveState / saveStateForScope (src/config.ts:145, :301) |
loadStateForScope → readJson → null → StateSchema.parse({}) |
lastPullRev and every per-checkout push base — the next push then compares against a stale/absent base, which is the exact stale-base overwrite class #827 closed (worktree fixes #823) |
search index (search-index.json) |
buildIndex (src/utils/search-index.ts:811) |
loadIndex → null |
all recall knowledge silently gone until the next rebuild; the shrink guard also loses its baseline |
The repo's own writeJsonAtomic (src/utils/fs.ts:161) already exists — temp file + rename, preserving existing permission bits — and saveUserVotes documents the identical failure mode for votes ("A torn plain overwrite would leave truncated YAML, and loadUserVotes falls back to an empty object — silently wiping every doc's counts, all pending deltas, and the whole upvote ledger"). config.yaml got the same treatment in #831 (Issue #823 item 14).
readJson logs a warn and returns null on a parse error, so nothing crashes — the loss is silent, which is what makes the state.json case dangerous: an empty State parses fine and the push base is gone without a word.
Proposed fix
Move the three writers to writeJsonAtomic (same shape as #831):
saveState and saveStateForScope
- the
buildIndex save
No reader changes; no behavior change on a successful write. PR incoming.
Evidence note
The torn-write window is code-path-proven (truncate before write, readJson → null → parse({})), not observed in the wild. The unit tests reproduce the #831 method: intercept fse.writeFile to truncate, run a concurrent reader, assert it sees the old complete value — these fail on main and pass with the atomic writers.
Summary
Three machine-state files that are read back automatically are still written in place (
writeJson= open + truncate + write), while the same codebase already writesconfig.yaml(#831) and the votes file atomically for exactly this reason. A crash, kill, disk-full or power loss mid-write leaves a truncated JSON, and the reader silently treats it as absent:state.json(user + per-checkout)saveState/saveStateForScope(src/config.ts:145,:301)loadStateForScope→readJson→ null →StateSchema.parse({})lastPullRevand every per-checkout push base — the next push then compares against a stale/absent base, which is the exact stale-base overwrite class #827 closed (worktree fixes #823)search-index.json)buildIndex(src/utils/search-index.ts:811)loadIndex→ nullThe repo's own
writeJsonAtomic(src/utils/fs.ts:161) already exists — temp file + rename, preserving existing permission bits — andsaveUserVotesdocuments the identical failure mode for votes ("A torn plain overwrite would leave truncated YAML, and loadUserVotes falls back to an empty object — silently wiping every doc's counts, all pending deltas, and the whole upvote ledger").config.yamlgot the same treatment in #831 (Issue #823 item 14).readJsonlogs a warn and returns null on a parse error, so nothing crashes — the loss is silent, which is what makes the state.json case dangerous: an emptyStateparses fine and the push base is gone without a word.Proposed fix
Move the three writers to
writeJsonAtomic(same shape as #831):saveStateandsaveStateForScopebuildIndexsaveNo reader changes; no behavior change on a successful write. PR incoming.
Evidence note
The torn-write window is code-path-proven (truncate before write,
readJson→ null →parse({})), not observed in the wild. The unit tests reproduce the #831 method: interceptfse.writeFileto truncate, run a concurrent reader, assert it sees the old complete value — these fail onmainand pass with the atomic writers.