Skip to content

fix(finalize): retry transient Windows locks around the atomic seal — v0.1.6 - #11

Merged
siddhant573 merged 2 commits into
LambdaTest:mainfrom
siddhant573:fix/win32-seal-eperm-retry
Jul 8, 2026
Merged

fix(finalize): retry transient Windows locks around the atomic seal — v0.1.6#11
siddhant573 merged 2 commits into
LambdaTest:mainfrom
siddhant573:fix/win32-seal-eperm-retry

Conversation

@siddhant573

@siddhant573 siddhant573 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

Live testrun on a Windows (HyperExecute) runner failed at seal time, after all members passed:

error: testrun failed: EPERM: operation not permitted, rename 'D:\...\38dfc7a4-....evidence' -> 'D:\...\38dfc7a4-....evidence.bak-ac3218fb4c5c'

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 with ERROR_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

  • New src/fs-retry.tsretryTransient(): bounded retry on EPERM/EACCES/EBUSY only; 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 .bak cleanup rm gets maxRetries and is now best-effort: the pack is already sealed at that point, and a lingering .bak- aside is redundant by design — sweepIncomplete deletes it on the next startup. Failing the seal over cleanup helped nobody.
  • sweepIncomplete() — the .bak restore rename retries; the .bak rm gets maxRetries; the .tmp- rm is wrapped in retryTransient (NOT rm's maxRetries, which Node ignores without recursive: 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 through fs.promises: transient EPERM on the dir→bak rename, on the tmp→sealed rename, persistent EPERM on the .bak cleanup, 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.
  • Full suite: 177/177, npm run build clean.

Version bumped to 0.1.6 (0.1.5 is already published).

🤖 Generated with Claude Code

siddhant573 and others added 2 commits July 8, 2026 18:58
…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>
@siddhant573
siddhant573 merged commit fbefcf1 into LambdaTest:main Jul 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant