fix(finalize): retry transient Windows locks around the atomic seal — v0.1.6 - #11
Merged
siddhant573 merged 2 commits intoJul 8, 2026
Merged
Conversation
…und the atomic seal — v0.1.6 On win32, renaming the live pack directory aside fails with EPERM while any process (AV real-time scan, search indexer) holds a handle to anything inside the tree — finalize writes dozens of files plus the sealed zip milliseconds before that rename, so the window is routinely hit on Windows runners. Seen live: testrun on HyperExecute failed with EPERM rename <id>.evidence -> <id>.evidence.bak-<rand> after all members passed. - new src/fs-retry.ts: retryTransient() — bounded linear backoff (10 tries, 100ms..1s cap), EPERM/EACCES/EBUSY only, everything else rethrows immediately - finalize: both seal renames retry; .bak cleanup rm gets maxRetries and is now best-effort (sealed pack + lingering .bak is redundant, sweepIncomplete collects it next startup — failing the seal over cleanup helped nobody) - sweepIncomplete: restore rename retries; rm calls get maxRetries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… seal-rename budget Self-review found two gaps in the previous commit: - sweepIncomplete's .tmp- rm passed maxRetries WITHOUT recursive:true — Node ignores maxRetries unless recursive is set, so the option was dead. Wrapped in retryTransient instead (new fault-injection test). - the two seal renames used the default 10-attempt (~4.5s) budget; the dir aside blocks on a handle to ANYTHING in the tree and the fresh zip is a prime real-time-scan target — raised to 20 attempts (~15s; graceful-fs's precedent for this failure is 60s). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
Live testrun on a Windows (HyperExecute) runner failed at seal time, after all members passed:
finalize()seals in place (decision 0042): write the zip to a.tmp-sibling → rename the live directory aside to.bak-→ rename the zip into place → rm the aside. On win32, renaming a directory fails withERROR_ACCESS_DENIED(Node:EPERM) while any process holds an open handle to anything inside its tree. Finalize writes dozens of files plus the sealed zip milliseconds before that rename, so antivirus real-time scans / search indexers routinely hit the window. POSIX renames never block on open handles — which is why this never reproduced on macOS/Linux. (Not an ACL problem: the same code successfully created and fsynced the.tmp-zip in the same parent moments earlier.)The atomic design held (live dir untouched, only a stray
.tmp-left for the sweep), but the run failed.Fix
src/fs-retry.ts—retryTransient(): bounded retry onEPERM/EACCES/EBUSYonly; linear backoff 100 ms → 1 s cap. Any other code rethrows immediately, so a real permission error on POSIX just fails a few seconds later.finalize()— both seal renames retry with a generous 20-attempt (~15 s) budget: the dir aside blocks on a handle to anything in the tree, and the fresh zip is a prime real-time-scan target (graceful-fs's precedent for this exact failure is 60 s). The.bakcleanuprmgetsmaxRetriesand is now best-effort: the pack is already sealed at that point, and a lingering.bak-aside is redundant by design —sweepIncompletedeletes it on the next startup. Failing the seal over cleanup helped nobody.sweepIncomplete()— the.bakrestore rename retries; the.bakrm getsmaxRetries; the.tmp-rm is wrapped inretryTransient(NOT rm'smaxRetries, which Node ignores withoutrecursive: true—.tmp-is a plain file).Tests
src/fs-retry.test.ts— 8 unit tests (success passthrough, per-code retry, immediate rethrow on non-transient/no-code, bounded attempts, capped linear backoff).src/finalize/win32-lock.test.ts— fault injection throughfs.promises: transient EPERM on the dir→bak rename, on the tmp→sealed rename, persistent EPERM on the.bakcleanup, transient EPERM on the sweep's.tmp-rm, and on the sweep restore rename. All reproduce the live failure before the fix and pass after.npm run buildclean.Version bumped to 0.1.6 (0.1.5 is already published).
🤖 Generated with Claude Code