Skip to content

fix(fileutil): keep ReplaceFile atomic under transient rename failures / 瞬时重命名失败时保持 ReplaceFile 的原子发布#6029

Merged
SivanCola merged 1 commit into
esengine:main-v2from
SivanCola:fix/replacefile-no-torn-fallback
Jul 5, 2026
Merged

fix(fileutil): keep ReplaceFile atomic under transient rename failures / 瞬时重命名失败时保持 ReplaceFile 的原子发布#6029
SivanCola merged 1 commit into
esengine:main-v2from
SivanCola:fix/replacefile-no-torn-fallback

Conversation

@SivanCola

Copy link
Copy Markdown
Collaborator

Problem

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 can observe an empty or half-written file — silently breaking the atomic-publication contract every AtomicWriteFile caller relies on (session leases, credentials, plugin state, mcp.json, session saves).

CI caught the window lining up on a real runner: TestStateLoadDuringSaveNeverSeesTornFile failed on windows-latest with unexpected 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 concurrent LoadState read 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:

  • Transient locks (antivirus, search indexer, a concurrent reader without delete sharing): retry longer (12 attempts, ~1.5s of backoff, up from 8 / ~0.7s), then surface the rename error. A loud failure beats a silently torn file — no caller of an atomic write API wants best-effort truncate-in-place semantics.
  • Cross-device class (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 renameCrossesDevice helpers (golang.org/x/sys/windows.ERROR_NOT_SAME_DEVICE on Windows, syscall.EXDEV elsewhere).

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_DEVICE routes to the fallback; ERROR_SHARING_VIOLATION / ERROR_ACCESS_DENIED must not.
  • A renameFile test 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 — pass
  • go 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 — pass
  • go vet, gofmt, go mod tidy (no-op; x/sys already a direct dependency), desktop module builds
  • git diff --check clean

…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.
@SivanCola SivanCola requested a review from esengine as a code owner July 5, 2026 15:33
@github-actions github-actions Bot added the v2 Go rewrite (1.x) — main-v2 branch, active development label Jul 5, 2026
@SivanCola SivanCola merged commit 6fae377 into esengine:main-v2 Jul 5, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant