fsync atomic writes before rename and retry Windows rename locks - #1169
fsync atomic writes before rename and retry Windows rename locks#1169nordicnode wants to merge 2 commits into
Conversation
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)
|
The core insight is right: The rest of the diff goes much further than that fix, though, and each piece adds risk that isn't isolated or fully covered:
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. |
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.
|
Split done as suggested — thanks for the push on the missing coverage:
|
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/writeFileAtomicAsyncnow fsync the temp before renaming. Without it, a power loss can land the rename while the data blocks were never written — a tornrun-state.jsonexactly 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.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
cli/src/utils/write-file-atomic.ts—fsyncFile(fd)after write, before rename, in both the sync and async writers;renameWithRetryin the async path for the Windows handle-release race.Tests
cli/src/utils/__tests__/write-file-atomic.test.ts: fsync-before-rename ordering (spiedfs.fsyncSync/fs.renameSynccall 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 onmain, none in the changed files.bunx prettier --checkclean on both changed files.Refs #1168, #1195