fix(fileutil): keep ReplaceFile atomic under transient rename failures / 瞬时重命名失败时保持 ReplaceFile 的原子发布#6029
Merged
SivanCola merged 1 commit intoJul 5, 2026
Conversation
…ename failures ReplaceFile fell back to copyOnto after exhausting its rename retries, regardless of why the rename failed. copyOnto truncates dest in place, so a reader racing the copy observes an empty or half-written file — silently breaking the atomic-publication contract every AtomicWriteFile caller relies on (session leases, credentials, plugin state, mcp.json). CI caught it once the window lined up: TestStateLoadDuringSaveNeverSees TornFile failed on windows-latest with 'unexpected end of JSON input' after a transient dest lock outlived the ~0.7s retry budget and the copy truncated the state file under the reader. Split the failure classes. Transient locks (antivirus, search indexer, a reader without delete sharing) now retry longer — 12 attempts, ~1.5s of backoff — and then surface the rename error; a loud failure beats a silently torn file. The cross-device class (ERROR_NOT_SAME_DEVICE / EXDEV, reported by encryption-software filter drivers even for a same-dir rename, esengine#2696) still takes the copy fallback because rename is structurally impossible there — but now immediately, instead of first sleeping through a retry ladder that cannot succeed, which also cuts ~0.7s of latency from every save on such hosts. The rename seam (renameFile) exists because neither failure class can be provoked portably on a real filesystem; the new tests pin dest integrity plus the surfaced error for the transient class, and the immediate no-retry copy for the cross-device class.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
ReplaceFilefell back tocopyOntoafter exhausting its rename retries, regardless of why the rename failed.copyOntotruncates dest in place, so a reader racing the copy can observe an empty or half-written file — silently breaking the atomic-publication contract everyAtomicWriteFilecaller relies on (session leases, credentials, plugin state, mcp.json, session saves).CI caught the window lining up on a real runner:
TestStateLoadDuringSaveNeverSeesTornFilefailed on windows-latest withunexpected end of JSON input(run 28745056100) — a transient dest lock outlived the ~0.7s retry budget, the copy fallback truncated the state file, and the concurrentLoadStateread the torn JSON. The torn read is exactly the state that test exists to rule out; the copy fallback is the only code path that can produce it.Fix
Split the two rename-failure classes that were previously handled identically:
ERROR_NOT_SAME_DEVICE/EXDEV): encryption-software filter drivers report this even for a same-directory rename (fix(io): copy-fallback for atomic writes on cross-device (EXDEV) rename #2696), and every retry fails identically. Only this class still takes the copy fallback — it is the only way to write at all on such hosts — and now immediately, instead of first sleeping through a retry ladder that cannot succeed. That also removes ~0.7s of latency from every save on those hosts (the cost previously accepted in the comment introduced by the retry-first ordering).Classification lives in build-tagged
renameCrossesDevicehelpers (golang.org/x/sys/windows.ERROR_NOT_SAME_DEVICEon Windows,syscall.EXDEVelsewhere).Tests
TestReplaceFileTransientFailureNeverTruncatesDest: transient-class failure surfaces the error, dest keeps its old content byte-for-byte, tmp survives for the caller.TestReplaceFileCrossDeviceCopiesImmediately: EXDEV-class failure copies without a single retry sleep (attempt count and elapsed-time pinned).TestRenameCrossesDeviceClassifiesFilterDriverError(windows-only):ERROR_NOT_SAME_DEVICEroutes to the fallback;ERROR_SHARING_VIOLATION/ERROR_ACCESS_DENIEDmust not.renameFiletest seam injects the failures — neither class can be provoked portably on a real filesystem. Existing behavior pins (TestReplaceFileNoRetryWhenTmpMissing,TestReplaceFileRetriesThenReturnsError,TestReplaceFileRenamesInPlace,TestCopyOntoOverwritesAndPreservesMode) all still pass unchanged.Verification
go test ./internal/fileutil/... -count=1— passgo test -race ./internal/pluginpkg/ -count=1— pass (the test that flaked on CI)GOOS=windows go vet ./internal/fileutil/...and Windows test-binary cross-compile — passgo vet,gofmt,go mod tidy(no-op; x/sys already a direct dependency), desktop module buildsgit diff --checkclean