Skip to content

fsync atomic writes before rename and retry Windows rename locks - #1169

Open
nordicnode wants to merge 2 commits into
CodebuffAI:mainfrom
nordicnode:oss/runstate-recovery-1166-related
Open

fsync atomic writes before rename and retry Windows rename locks#1169
nordicnode wants to merge 2 commits into
CodebuffAI:mainfrom
nordicnode:oss/runstate-recovery-1166-related

Conversation

@nordicnode

@nordicnode nordicnode commented Aug 31, 2026

Copy link
Copy Markdown

Summary

Durability half of #1169, narrowed per review to the root-cause fix: the recovery/rotation/UI machinery now lives in #1195 so each piece can be reviewed and perf-checked separately.

  • writeFileAtomic/writeFileAtomicAsync now fsync the temp before renaming. Without it, a power loss can land the rename while the data blocks were never written — a torn run-state.json exactly where the atomic rename was supposed to guarantee a complete file, which is what made resumed chats amnesiac (details in CLI: torn run-state.json silently resumes chats with no agent context (model amnesiac, transcript intact) #1168). EINVAL filesystems that reject fsync degrade gracefully to the old rename-only guarantee.
  • The async rename retries briefly on EPERM/EBUSY/EACCES. On Windows the just-closed handle can still be held by AV/indexer scans — a transient the fsync widens the window for. Bounded (5 attempts, exponential backoff), non-matching errors rethrow immediately. Now covered by tests (previously prose-only, per review).

Root cause

rename(2) is atomic but not durable: it orders the name against the filesystem metadata journal, not the file's data blocks. A rename that survives a power cut can still point at a file whose contents were never flushed, so the "atomic" write yields a torn file. fsync on the temp fd before the rename closes that gap.

Changes

  1. cli/src/utils/write-file-atomic.tsfsyncFile(fd) after write, before rename, in both the sync and async writers; renameWithRetry in the async path for the Windows handle-release race.

Tests

  • 3 new tests in cli/src/utils/__tests__/write-file-atomic.test.ts: fsync-before-rename ordering (spied fs.fsyncSync/fs.renameSync call order), transient EPERM retry succeeds on attempt 3, non-transient ENOENT rethrows on attempt 1.
  • NODE_ENV=production bun test src/utils/__tests__/write-file-atomic.test.ts — 13 pass (10 pre-existing + 3), 0 fail.

Validation

  • NODE_ENV=production bun test src/utils/__tests__/run-state-storage.test.ts src/utils/__tests__/write-file-atomic.test.ts — 50 pass, 0 fail.
  • bun x tsc --noEmit -p cli/tsconfig.json — 10 errors, all pre-existing on main, none in the changed files.
  • bunx prettier --check clean on both changed files.

Refs #1168, #1195

A resumed chat could start amnesiac — the transcript intact, the model
with no memory of earlier turns — through a chain with no single point
of failure:

- writeFileAtomic renamed the temp over the target without fsync, so a
  power loss could land the rename while the data blocks were never
  written: the 'atomic' file contained garbage. fsync the temp before
  renaming (both sync and async paths).
- loadMostRecentChatState tried only the primary and fell back to a
  RunState placeholder with no sessionState. The SDK starts a fresh
  session when previousRun.sessionState is absent, so the next turn
  silently lost every earlier turn. Now the load tries the rotated
  .bak (the previous complete generation) and then the newest complete
  checkpoint temp (a SIGKILL between write and rename leaves one
  behind), self-heals the primary from whichever recovered, and only
  gives up when all three are unreadable.
- The loss used to be invisible to the user: the transcript rendered
  normally and the model just 'forgot'. loadMostRecentChatState now
  reports whether agent context survived, and the resume flow
  prepends an error-variant notice when it did not.

Also: retry the async rename briefly on EPERM/EBUSY/EACCES — on
Windows the just-closed handle can still be held by AV/indexer scans,
which the new fsync widens the window for.

Refs CodebuffAI#1166 (persistence-side companion to the compaction wipes)
@codebuff-team

Copy link
Copy Markdown
Contributor

The core insight is right: writeFileAtomic/writeFileAtomicAsync renaming without an fsync can leave a torn file after power loss even though the rename itself is atomic. That's a real, well-known durability gap and the fix in write-file-atomic.ts is small and correct on its own.

The rest of the diff goes much further than that fix, though, and each piece adds risk that isn't isolated or fully covered:

  • saveChatState now does an extra renameSync on every save to rotate the previous run-state.json into .bak, on top of the existing atomic write. Run state is described elsewhere in this file as multi-MB and rewritten every agent step — doubling the file operations per step deserves its own perf note/benchmark, not just an assertion in the PR body.
  • readRunStateWithRecovery mutates disk state (writes the recovered generation back as the primary) from inside what's conceptually a load/read path. That's a reasonable self-heal, but it's a meaningful side effect bundled into loadMostRecentChatState with no isolation.
  • The newest-.tmp selection sorts by mtimeMs, which can be unreliable on filesystems/CI with coarse mtime resolution — the test writes two temp files back-to-back and could be flaky for reasons unrelated to the logic being tested.
  • renameWithRetry's Windows EPERM/EBUSY/EACCES backoff is described in prose but has no accompanying test in the diff.

Each of these is defensible individually, but together they turn a one-file durability fix into a 380-line change touching save/load/clear/UI. I'd suggest splitting: land the fsync fix alone first (it's the actual root cause per the PR's own narrative), then propose the backup/recovery/retry/UI-messaging behavior as a separate, smaller PR so each piece can be reviewed and perf-checked on its own merits.

@codebuff-team codebuff-team added bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written labels Sep 1, 2026
Companion regression tests for the fsync-before-rename durability fix: the
fsync must precede the rename (power loss between them leaves the old file
intact), and the async rename retries EPERM/EBUSY/EACCES with bounded backoff
while rethrowing anything else on the first attempt. Recovery loader, backup
rotation, and UI notice split out to a follow-up PR per review.
@nordicnode nordicnode changed the title fix(cli): recover agent context when run-state.json is torn fsync atomic writes before rename and retry Windows rename locks Sep 1, 2026
@nordicnode

Copy link
Copy Markdown
Author

Split done as suggested — thanks for the push on the missing coverage:

  • This PR (narrowed, retitled): the fsync-before-rename durability fix in writeFileAtomic/writeFileAtomicAsync plus the Windows renameWithRetry. The previously prose-only retry behavior is now covered by tests per the review: fsync-ordering (spied call order), transient EPERM retry succeeding, non-transient ENOENT rethrowing on the first attempt.
  • Recover agent context from torn run-state.json backups #1195: the backup rotation, recovery loader, and UI notice, as a separate PR. On the two perf/side-effect concerns: rotation is a metadata-only renameSync (an inode re-link within the same directory, no data copy) on top of an already multi-syscall save — not a doubled write; the self-heal write-back is real disk mutation inside a load path, but it is what makes the recovery durable across the next load rather than re-falling back every resume, and it is best-effort try/catch with the warning logged either way. The .bak-vs-.tmp selection there is now recency-ordered with a deterministic tie-break, and the newest-temp test pins mtimes with utimesSync so back-to-back writes cannot flake on coarse-mtime filesystems.

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

Labels

bot:triaged Classified by the community triage bot pr:needs-work Right idea, not mergeable as written

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants