Skip to content

version-management: tolerate a Windows delete-pending race in license repair - #793

Open
pullfrog[bot] wants to merge 1 commit into
mainfrom
pullfrog/792-windows-license-repair-race
Open

version-management: tolerate a Windows delete-pending race in license repair#793
pullfrog[bot] wants to merge 1 commit into
mainfrom
pullfrog/792-windows-license-repair-race

Conversation

@pullfrog

@pullfrog pullfrog Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #792

CI run 32875385039 went red on main at a3083f48a with one failure, in embed-runtime verify (windows-latest):

---- version_management::tests::concurrent_exact_license_repairs_leave_one_attested_notice stdout ----
panicked at crates\nub-core\src\version_management\mod.rs:1655:18:
repair: moving stale ...\nub-concurrent-license-repair-5412-4\LICENSE aside
Caused by:
    Access is denied. (os error 5)

Not introduced by a3083f48a (#791 only touched submodule handling in recursive clones). It is a latent Windows race in atomic_replace_file, and the concurrent-repair test is what exposes it.

Mechanism

atomic_replace_file stages bytes in a temp file, installs them with hard_link(tmp, dest), and unlinks tmp via its FileGuard as soon as the link lands. tmp and dest are then the same file, so that unlink puts the shared file into Windows' delete-pending state, and NTFS answers every open of it — through either link — with ERROR_ACCESS_DENIED until the last handle closes. A sibling repairer's std::fs::read(&dest) handle is exactly what holds that window open.

Two things then went wrong in the loser thread:

  1. The std::fs::read(&dest).ok() content probe conflated that transient error with "the destination holds different bytes", so it proceeded to displace a destination that already held the correct content.
  2. std::fs::rename(&dest, &displaced) hit the same delete-pending state and was treated as fatal, surfacing a lost race as a permission failure.

The codebase already knows this behavior — node/spawn.rs:1659 and :1757 both order handle drops around it — and compile/launcher.rs's commit_template already uses the right remedy for a no-overwrite commit: on a failed install, re-probe the destination and accept it if a racer published the bytes we wanted.

Fix

Bound the displacement failures instead of erroring on the first one, and re-enter the loop so the existing content probe can observe the sibling's bytes once its handle closes. The rename error is reported unchanged, with its original context, only when the destination is still not ours on the final attempt. Backoff is 2ms doubling to 128ms, ~254ms total before giving up.

NotFound still continues as before, and a genuine EACCES on Unix still surfaces — just after a bounded delay rather than instantly.

Verification

  • cargo test -p nub-core --lib — 438 passed, 0 failed (includes the failing test).
  • cargo clippy -p nub-core --all-targets -- -D warnings — rc=0; cargo fmt --check -p nub-core clean.

Caveat worth stating plainly: delete-pending is Windows-kernel behavior with no Linux analogue, so the local run cannot exercise the path that failed. embed-runtime verify runs the full [ubuntu, windows, macos] matrix on pull requests, so the Windows leg re-runs here — but the original failure was intermittent, so one green run is not proof. No new test: the existing concurrent-repair test is the guard, and a deterministic reproduction would need a rename seam injected purely for the test.

Pullfrog  | View workflow run | via Pullfrog | Using Claude Opus𝕏

… repair

atomic_replace_file installs its staging file with a hard link and unlinks the
staging name once it lands. Windows holds the shared file delete-pending until
its last handle closes, so a sibling repairer's open of the destination answers
ERROR_ACCESS_DENIED rather than the bytes just installed there. The content
probe read that as a mismatch and the displacing rename then failed outright,
surfacing a lost race as a permission error.

Re-enter the loop across a bounded backoff instead, so the probe can observe the
sibling's bytes once its handle closes, and report the rename error only after
the destination is still not ours on the final attempt.
@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 25, 2026 6:11pm

Request Review

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.

CI is failing on main

0 participants