fix(core/build): atomically promote git clones so a crashed build can self-heal - #2809
Conversation
|
😎 Merged successfully - details. |
|
Automated review by Claude — this is a fully automated review; no human has vetted it before posting. No issues found. Cloning into a unique The tests cover the key invariants: Generated by Claude Code |
|
🤖 Automated review by Claude — fully automated, no human in the loop; it may contain mistakes. The temp-dir + atomic-rename approach looks like the right root-cause fix — any dir at 1. The stale-sweep's age gate can misjudge a very long live clone. The comment on 2. The sweep only runs in the Neither blocks the fix. Generated by Claude Code |
2f1c55c to
60326d3
Compare
|
Automated review by Claude — this is a fully automated review, not vetted by a human. I looked at the diff and the surrounding code, not the PR description. I found one correctness gap: the atomic-promotion invariant only holds for the The temp-dir + atomic- But the comment the change adds asserts:
That isn't true for the other two arms that write
So the fix closes the wedge for Two prior points from earlier reviews still stand and aren't addressed by the current diff: the stale-sweep age gate can misjudge a very long live clone (the top-level temp mtime isn't bumped by writes into Generated by Claude Code |
|
The mechanics are right: the temp dir is a sibling so the rename is same-device and atomic, The gap is scope, not mechanism. The invariant this writes into the code — "any directory that exists at Two smaller things:
|
Address review on #2809. The "any directory at clone_dir is a complete clone" invariant only held for the NewClone arm; CopyAndFetch and RenameAndFetch still wrote into clone_dir in place, so a SIGKILL mid-copy or mid-fetch could still leave a broken .git at clone_dir and reproduce the #2808 permanent wedge. - Factor the atomic promotion into a shared `promote_clone(tmp, target)` helper (rename-into-place, with the concurrent-winner race handled) and route all three write arms (NewClone, CopyAndFetch, RenameAndFetch) through a temp sibling dir, so clone_dir is only ever created by the final rename. - Remove `sweep_stale_partial_clones`: it derived staleness from the temp dir's top-level mtime, which git does not touch while fetching objects under .git/, so a clone running longer than the threshold could be removed out from under a concurrent build of the same commit. Orphaned temp dirs from a hard crash are harmless (unique names, never matched by clone_dir_ready/Reuse) and are left for external cleanup. Tests: add `promote_clone` unit tests (absent-target move and concurrent-winner race), end-to-end promote tests for CopyAndFetch and RenameAndFetch (via a local origin remote), and failure-path tests asserting no target dir and no temp leftover for every arm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AYVWUwXULA9QoNbYJcLsF4
|
Thanks — all three are fair. Pushed a follow-up commit that addresses them: 1. Scope — extended temp-then-promote to all write arms. Factored the atomic promotion into a shared
So the "any dir at 2. Racy sweep — removed entirely. You're right that the top-level mtime doesn't advance while git writes objects under 3. Tests. New tests that genuinely exercise the mechanism:
On the honest limit you flagged: a true SIGKILL-mid-clone can't be reproduced in an in-process unit test, because every in-process error path runs The follow-up also merged cleanly with #2820 on the rebased base: a panic in the blocking clone/checkout task now routes through the temp-dir cleanup + Generated by Claude Code |
|
@phil-opp the Trunk merge queue failed for this PR. See the Trunk merge-status comment for details. Posted as a new comment so GitHub sends an email — Trunk's sticky comment is edited in place and won't trigger a notification. |
A `NewClone` cloned directly into the final `clone_dir`, so a crash (SIGKILL / power loss) mid-clone left a half-written repo at that path. Since #2795, a later build reusing such a leftover hits the Reuse arm's HEAD check, fails to resolve HEAD, and bails *without* cleanup — every subsequent rebuild repeats it, wedging the build permanently until the directory is deleted by hand (#2808). Branch/tag pins skip the HEAD check entirely and silently reuse the broken checkout. Fix the regression at its root: clone + checkout into a unique temporary sibling dir and `rename` it into `clone_dir` only once the checkout has fully succeeded. A crash then only ever leaves a temp dir, never a directory at `clone_dir` — so any dir that exists there is, by construction, a complete clone, and `clone_dir_ready`/Reuse can trust it again. Temp names are process+counter unique to stay cross-process safe, and a best-effort age-gated sweep reclaims temp dirs abandoned by crashed builds so they don't accumulate. Adds unit tests for atomic promotion + cleanup, the failure path leaving no target dir, unique temp paths, and the stale-temp sweep. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AYVWUwXULA9QoNbYJcLsF4
Address review on #2809. The "any directory at clone_dir is a complete clone" invariant only held for the NewClone arm; CopyAndFetch and RenameAndFetch still wrote into clone_dir in place, so a SIGKILL mid-copy or mid-fetch could still leave a broken .git at clone_dir and reproduce the #2808 permanent wedge. - Factor the atomic promotion into a shared `promote_clone(tmp, target)` helper (rename-into-place, with the concurrent-winner race handled) and route all three write arms (NewClone, CopyAndFetch, RenameAndFetch) through a temp sibling dir, so clone_dir is only ever created by the final rename. - Remove `sweep_stale_partial_clones`: it derived staleness from the temp dir's top-level mtime, which git does not touch while fetching objects under .git/, so a clone running longer than the threshold could be removed out from under a concurrent build of the same commit. Orphaned temp dirs from a hard crash are harmless (unique names, never matched by clone_dir_ready/Reuse) and are left for external cleanup. Tests: add `promote_clone` unit tests (absent-target move and concurrent-winner race), end-to-end promote tests for CopyAndFetch and RenameAndFetch (via a local origin remote), and failure-path tests asserting no target dir and no temp leftover for every arm. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AYVWUwXULA9QoNbYJcLsF4
87ab2c3 to
b8a8383
Compare
|
Heads up on the merge-queue failure ( Fixed by rebasing the branch onto the latest Generated by Claude Code |
The follow-up commit ( Two small leftovers, neither blocking:
No new blocking issues. Generated by Claude Code |
) `format!("file://{}", path.display())` is not portable. On Windows it yields `file://C:\Users\...`, which puts the drive letter in the URL authority and leaves the backslashes as non-separators, so libgit2 refuses to resolve it: failed to resolve path 'file://C:\Users\RUNNER~1\...\origin': The filename, directory name, or volume label syntax is incorrect. The tests added in #2809 hand that string straight to `git2::Repository::clone`, which broke `copy_and_fetch_promotes_into_target` and `rename_and_fetch_promotes_into_target` on the nightly Windows runner. The sites that funnelled the same string through `Url::parse` survived only because the url crate silently normalizes the malformed spelling. Route every test-side `file://` construction through one `file_url` helper built on `Url::from_file_path`, which emits the well-formed `file:///C:/Users/...` on all platforms. Production is unaffected: `clone_into` already takes an already-parsed `Url`. Two `Url::parse(&repo_url_str).unwrap()` round-trips fall out as dead weight now that the helper hands back a `Url` directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C74rZYpFMVdg4B9HHt5BYL
…#3137) (#3138) fix(core/build): build test file:// URLs with Url::from_file_path (#3137) `format!("file://{}", path.display())` is not portable. On Windows it yields `file://C:\Users\...`, which puts the drive letter in the URL authority and leaves the backslashes as non-separators, so libgit2 refuses to resolve it: failed to resolve path 'file://C:\Users\RUNNER~1\...\origin': The filename, directory name, or volume label syntax is incorrect. The tests added in #2809 hand that string straight to `git2::Repository::clone`, which broke `copy_and_fetch_promotes_into_target` and `rename_and_fetch_promotes_into_target` on the nightly Windows runner. The sites that funnelled the same string through `Url::parse` survived only because the url crate silently normalizes the malformed spelling. Route every test-side `file://` construction through one `file_url` helper built on `Url::from_file_path`, which emits the well-formed `file:///C:/Users/...` on all platforms. Production is unaffected: `clone_into` already takes an already-parsed `Url`. Two `Url::parse(&repo_url_str).unwrap()` round-trips fall out as dead weight now that the helper hands back a `Url` directly. Claude-Session: https://claude.ai/code/session_01C74rZYpFMVdg4B9HHt5BYL Co-authored-by: Claude <noreply@anthropic.com>
Summary
Fixes #2808.
ReuseOptions::NewClonecloned directly into the finalclone_dir, so a crash (SIGKILL / power loss) mid-clone left a half-written repo at that path. Since #2795 the Reuse arm no longer deletes a clone whose HEAD fails to resolve (correctly, to avoid re-opening #2711 for a concurrently-written clone). But that turned a crash leftover into a permanent wedge: every subsequent build of the samerepo@commitre-enters the Reuse arm, fails to resolve HEAD, andbail!s without cleanup — with no owning process left to recover it, only a manualrm -rfunblocks the build. For a branch/tag pin the outcome differs but is also bad: the HEAD check is skipped, so the broken checkout is silently reused.Fix
Fix the regression at its root rather than restoring the risky delete-on-
Err:renameit intoclone_dironly after the checkout fully succeeds.renamewithin the same parent directory is atomic, so a crash only ever leaves a temp dir — never a directory atclone_dir. Any directory that exists atclone_diris therefore, by construction, a complete clone, andclone_dir_ready/ the Reuse arm can trust it again.pid+counter unique, so no build ever writes into or reclaims another live build's temp dir — this keeps the change cross-process safe and does not re-open git clone reuse (#2482): cross-session HEAD-verify canremove_dir_alla directory a concurrent build session is still cloning into #2711.git2::Repositoryhandle is dropped before the rename so Windows (which refuses to rename a dir with open handles) is not broken.This also fixes the branch/tag-pin variant, since a half-written checkout can no longer exist at
clone_dirin the first place.Tests
New unit tests in
libraries/core/src/build/git.rs:new_clone_promotes_temp_into_target_and_cleans_up— clone lands at the target atomically, is a valid repo on the requested commit, and no temp sibling survives.new_clone_failure_leaves_no_target_dir— a failed clone leaves nothing at the target path (the property that prevents the wedge).partial_clone_path_is_a_unique_sibling— temp paths are distinct siblings, never the target.sweep_removes_abandoned_partial_dirs_only— the sweep removes stale temps and leaves the target / unrelated siblings alone.All 16
git::tests pass;cargo fmtandcargo clippy -p dora-core --features build --all-targets -- -D warningsare clean.Generated by Claude Code