Skip to content

[bug] state.json and the search index are written in place: a torn write silently resets push bases / recall #854

Description

@ydflow

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions