Skip to content

fix(linker): stage every materialize so a failed link cannot poison the store - #598

Merged
colinhacks merged 5 commits into
mainfrom
fix-win-linker-staging
Jul 29, 2026
Merged

fix(linker): stage every materialize so a failed link cannot poison the store#598
colinhacks merged 5 commits into
mainfrom
fix-win-linker-staging

Conversation

@colinhacks

@colinhacks colinhacks commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

A link that failed partway left a half-written package under node_modules/.store, which the next run's exists() gate counted as already linked — installs then printed "Already up to date" over an unresolvable node_modules, recoverable only by deleting it.

Five link.rs sites wrote straight into the final entry. All now stage through .tmp-<pid>-<id> + atomic rename, so a failure leaves only a sweepable tmp dir.

Retried, each previously fatal: the per-file copy on a Windows sharing violation; the stale top-level entry removal; mkdirp. is_transient_rename_error never matched os 32 at all, because Rust decodes it to Uncategorized.

Prevention only — an already-broken tree still needs one rm -rf node_modules.

Merged with #595, which independently fixed the overlapping Windows failure and closed #566/#576.

Closes #552

…he store

A link that failed partway left a half-populated `.store/<dep_path>` behind,
and the next run's `if aube_entry.exists() { cached }` gate reported that
package as already linked. The tree never converged: every later install said
"Already up to date" over a `node_modules` that could not resolve, and
`rm -rf node_modules` was the only way out.

Five call sites in link.rs passed `base_dir == final dir` to `materialize_into`,
which documents that it "always writes into a fresh location" and therefore
skips both the defensive `remove_file(dst)` per file and the existence check
before `create_dir_link`. Those sites also had no cleanup on error, unlike the
staged callers which `remove_dir_all` their tmp base. All five now go through
`ensure_in_aube_dir`, which stages into `.tmp-<pid>-<id>` and atomic-renames
into place, so a failure leaves only a tmp dir that `sweep_stale_tmp_dirs`
reclaims. The two par_iter sites keep their outer `exists()` fast path so a warm
run still skips `load_index`.

Separately, the per-file link was the one operation with no transient-error
retry while the junction and directory-removal paths had carried one all along.
On Windows a single `ERROR_SHARING_VIOLATION` from another process holding a
handle — Defender mid-scan, the search indexer, a watcher — aborted the entire
install. The terminal copy in `link_file_fresh` now retries on the shared
ladder; `hard_link`/`reflink` deliberately do not, since both already fall
through to that copy.

`is_transient_rename_error` gets the same treatment for a subtler reason: Rust
maps `ERROR_SHARING_VIOLATION` to no `ErrorKind` at all (it decodes to
`Uncategorized`), so an `ErrorKind`-only predicate is silently blind to it and
the rename retry had never once fired for os 32. The raw-code arm is gated to
Windows because raw 32 is EPIPE on Unix. The same blindness made
`remove_dir_all_with_retry`'s `ErrorKind::Other` arm dead code; it is gone, and
all three paths now share one predicate.

Two fixes the staging change made necessary. `link_workspace` never swept
orphan `.tmp-*` dirs the way `link_all` does, so its staged materializations
would have leaked one per aborted install. And the GVS disk-materialize site
dropped a stale store symlink best-effort before writing; because
`ensure_in_aube_dir` opens with an `exists()` gate and `exists()` follows
symlinks, a surviving link would have read as "already materialized" and
silently skipped the ejection, leaving a store symlink where a real
project-local directory is required. That removal is now checked.

Prevention only: an already-corrupted tree still needs one `rm -rf node_modules`,
because the gate continues to trust an entry that exists.

Closes #552
Refs #566
Refs #576
Copilot AI review requested due to automatic review settings July 29, 2026 02:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review any files in this pull request.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@vercel

vercel Bot commented Jul 29, 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, Comment Jul 29, 2026 7:29am

Request Review

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No correctness issues found in the staging swap — two robustness rough edges inline, plus a coverage note below.

Reviewed changes — the full 4-file diff at 0b816d9a, which makes every per-project .aube/<dep_path> materialization atomic and teaches the Windows filesystem paths to retry sharing violations.

  • Route five direct materializations through ensure_in_aube_dirlink.rs no longer calls materialize_into(&aube_dir, &aube_dir, …) anywhere; all five sites now stage into .tmp-<pid>-<id>/ and atomic-rename, so a mid-link failure leaves a sweepable tmp dir instead of a half-populated entry that the next run's exists() gate reports as complete.
  • Add a shared Windows transient-retry ladderwith_transient_retry plus is_transient_fs_error (raw os 5/32) in sweep.rs; remove_dir_all_with_retry is refactored onto it and Unix stays a straight passthrough.
  • Make is_transient_rename_error see ERROR_SHARING_VIOLATION — os 32 decodes to Uncategorized, so the ErrorKind-only matches! had never matched the most common Windows transient the rename hits.
  • Retry the per-file byte transfer — every std::fs::copy in link_file_fresh (macOS small-file, reflink fallback, hardlink fallback, LinkStrategy::Copy) now goes through copy_through_transients.
  • Sweep stranded tmp dirs in link_workspace — the sweep link_all already ran was missing on the workspace path, so aborted workspace installs leaked one tmp dir each.
  • Check the stale shared-store link removal on the GVS eject path — a surviving symlink whose target is live would have read as "already materialized" and silently skipped the ejection; that now fails loudly.
  • Add a regression test and two predicate tests — the regression test removes a CAS shard so link_all fails mid-materialize, then asserts no .aube entry survives.

I traced the five swapped call sites against their pre-diff form and found no behavioral divergence beyond the intended staging: stats accounting is identical (ensure_in_aube_dir owns the exists() → packages_cached gate the callers did inline), the internal exists() gate shadows no caller precondition (the Directory/Portal forced re-materialize and the GVS eject path both establish non-existence first), and relative sibling symlinks stay invariant under the wrapper rename while Windows junctions are built against final_base_dir. ensure_in_aube_dir also predates this PR and is already exercised by the fetch-time materializer and the existing concurrency test, so this is a consolidation onto proven code rather than new staging logic.

ℹ️ The Windows half of both fixes is never executed by any CI job

Both mechanisms this PR adds are #[cfg(windows)], and no job runs the crate's tests on Windows. aube-linker is not a member of nub's root workspace, so nub's own cargo test never reaches it — and the workflow that does (aube-parity.yml, cargo test --workspace inside vendor/aube) is pinned to ubuntu-latest.

The practical consequence, per new mechanism:

  • with_transient_retry's retry loop — compiled by the Windows release build, executed by nothing.
  • sharing_violation_is_retriable_only_on_windows — asserts == cfg!(windows), so on the only OS that runs it the assertion reduces to "os 32 is not retriable". The branch the fix exists for is never checked.

Not a defect in the diff, but the fix for a Windows-only bug currently rests entirely on reading the code.

Technical details
# The Windows half of both fixes is never executed by any CI job

## Affected sites
- `.github/workflows/aube-parity.yml` — the `cargo test --workspace` job is `runs-on: ubuntu-latest` ("ubuntu-latest only for now; macOS legs come later"). It is the only job that runs `aube-linker`'s unit tests.
- Root `Cargo.toml``aube-linker` is a path dependency, not a workspace member. `cargo test -p aube-linker` from the repo root fails with "package `aube-linker` cannot be tested because it requires dev-dependencies and is not a member of the workspace", so nub's own test legs (Windows included) never build its `#[cfg(test)]` modules.
- `vendor/aube/crates/aube-linker/src/sweep.rs:72-96``with_transient_retry`'s loop, `#[cfg(windows)]`, no test coverage on any platform.
- `vendor/aube/crates/aube-linker/src/materialize.rs:1422-1434` — the assertion that carries the fix is `cfg`-conditioned to the platform CI does not run.

## Required outcome
- The `is_transient_fs_error` / `with_transient_retry` behavior on os 5 and os 32, and the retry-then-give-up boundary, are exercised on a real Windows runner (or by a platform-independent test that injects the error rather than depending on `cfg`).

## Suggested approach (optional)
Cheapest option is a Windows leg for the existing job — `runs-on: ${{ matrix.os }}` over `[ubuntu-latest, windows-latest]` — gated off `pull_request` if Windows runner cost is the concern, the same way `aube-conformance-windows` already is in that workflow.

An alternative that costs no runner minutes: make `with_transient_retry` testable without `cfg(windows)` by having it take the transient predicate (or by keeping the loop unconditional and letting a non-Windows `is_transient_fs_error` return `false`). The loop's backoff/attempt-budget behavior would then be assertable everywhere, leaving only the raw-errno mapping platform-specific.

For a one-off confirmation before merge, the repo's `ci-adhoc-test` skill runs a branch-scoped probe on a real `windows-latest` runner with no PR gating.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

/// retry: they already fall through to this copy, so retrying them too would
/// double the worst-case stall per file for no added recovery.
fn copy_through_transients(src: &Path, dst: &Path) -> io::Result<u64> {
crate::sweep::with_transient_retry(|| std::fs::copy(src, dst))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The ladder being reused here is documented (in sweep.rs) as "~10s worst case … short enough that a genuinely stuck file fails the install rather than hanging it" — reasoning that holds for one directory removal per install, but not for a per-file call. There is no shared deadline or attempt budget across files, so on a LinkStrategy::Copy install (the cross-volume Windows case the strategy docs call out) every file in every index can independently pay up to ~9.15s of sleep.

Technical details
# Per-file reuse of a ladder tuned for one-shot use

## Affected sites
- `vendor/aube/crates/aube-linker/src/sweep.rs:64-96``with_transient_retry`: 10 attempts, 50ms doubling to a 2s cap. Sleep total across attempts 0-8 is 9150ms. Its doc justifies that budget on the assumption of a single call.
- `vendor/aube/crates/aube-linker/src/materialize.rs:79-81``copy_through_transients` applies it per file.
- `vendor/aube/crates/aube-linker/src/materialize.rs:865, 941, 956, 963` — the four call sites. On Windows with `LinkStrategy::Copy` (store and project on different volumes) the fourth is every file; the hardlink fallback at 956 is every file on EXDEV.

## Required outcome
- A systemically slow-to-clear source (a real-time AV hook or an indexer sweeping the CAS store while the link pass runs) degrades install wall time by a bounded factor rather than by 9s × files-affected.
- `with_transient_retry`'s doc no longer implies a per-install bound while being called per file.

## Suggested approach (optional)
Either give the per-file path its own shorter ladder (the transient this guards clears in milliseconds when it clears at all — 3-4 attempts topping out near 400ms recovers essentially the same set), or thread a shared `Instant` deadline through the retry so the whole link pass has one budget instead of one per file. Also worth noting: `is_transient_fs_error` matches raw 5 (`ERROR_ACCESS_DENIED`), which is frequently *not* transient, so a genuine ACL problem pays the full ladder before surfacing.

## Open questions for the human
- Is a per-file budget acceptable given `LinkStrategy::Copy` is the cross-volume Windows default, or should the copy path get its own ladder?

if std::fs::read_link(&local_aube_entry).is_ok() {
let _ = std::fs::remove_dir(&local_aube_entry)
.or_else(|_| std::fs::remove_file(&local_aube_entry));
try_remove_entry(&local_aube_entry);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ This removal is what the new hard error below depends on, but try_remove_entry neither retries nor reports: it swallows both remove_dir_all and remove_file errors. So on Windows a transient ERROR_SHARING_VIOLATION on this junction — the exact failure class the rest of this PR teaches the linker to ride out — now aborts the install with a synthetic "failed to remove stale shared-store link" message that hides the real errno.

Technical details
# The new hard failure is reached through a non-retrying, error-swallowing removal

## Affected sites
- `vendor/aube/crates/aube-linker/src/link.rs:445``try_remove_entry(&local_aube_entry)` on the GVS disk-materialize eject path.
- `vendor/aube/crates/aube-linker/src/sweep.rs:162-165``try_remove_entry` is `let _ = remove_dir_all(...); let _ = remove_file(...)`. Deliberately best-effort, which was fine while the caller ignored the outcome; this PR makes the outcome fatal.
- `vendor/aube/crates/aube-linker/src/sweep.rs:98-120``remove_dir_all_with_retry` already exists for precisely this class of error and is not used here.

This path is live under nub: `crates/nub-cli/src/pm_engine/mod.rs:2224` populates `diskMaterializePackages` for Vite projects under the machine-global store, which is what routes execution into this branch.

## Required outcome
- A transient os 32 / os 5 on the stale junction is retried before the install is failed.
- When the removal genuinely cannot succeed, the returned `Error::Io` carries the underlying `io::Error` instead of `io::Error::other("...")`, so the report names the real cause.

## Suggested approach (optional)
Replace the best-effort call with a removal whose result is propagated and whose retry goes through `with_transient_retry`, then keep the `symlink_metadata` re-check as the belt-and-suspenders guard it is now. Note `remove_dir_all_with_retry` alone may not cover both shapes here (a junction on Windows vs. a symlink on Unix), which is why `try_remove_entry` tries both calls — a small retrying variant that keeps the last error is probably the cleaner fit.

The sibling site at `link.rs:234-244` has the same shape and predates this PR, so whatever helper lands here should be reusable there.

…ect junction

Four more instances of the class #552 exposed, found by auditing the paths the
first commit did not touch.

`reconcile_top_level_link` removed a stale `node_modules/<name>` entry without
retry, and its failure was fatal. That entry is the likeliest thing in the tree
to be held open — it is what a dev server, a `--watch` task, or an editor has
its handle on — so a reinstall aborted over a lock that would have cleared in
milliseconds.

Creating a junction failed on `ERROR_ALREADY_EXISTS` even when the junction
already pointed at the target we were about to point it at. Callers reach that
call after their own stale-entry reconciliation, so a surviving correct entry
means a concurrent writer got there first — the outcome we wanted. That is the
`os error 183` an incremental `add` surfaced. A junction pointing somewhere
else is still an error; the two are now distinguished by comparing both sides
through `canonicalize`, so a short name, a case difference, or a one-sided
`\\?\` prefix does not read as a mismatch.

`try_remove_entry` ran a raw `remove_dir_all` and discarded the result. A wipe
that aborts partway leaves files behind, and the refill that follows only
writes paths present in the NEW index — anything the old version had and the
new one does not survives and stays resolvable, silently blending two package
versions. This one produces a wrong tree rather than an error, which makes it
the worst of the four.

`mkdirp` and the hoisted per-file parent creation were unretried against a slot
in Windows' pending-delete state, which is reachable precisely because a wipe
just ran against a path something else holds open.

`mkdirp` now calls `std::fs::create_dir_all` directly rather than
`xx::file::mkdirp`, because `xx` wraps the error in its own type and drops
`raw_os_error()` — the retry predicate matches on the raw code, so wrapping the
`xx` call would have compiled, read correctly, and never once retried. The `xx`
helper is an `exists()` check plus the same call, and `create_dir_all` already
no-ops on an existing directory, so nothing else changes.

Windows compilation of all of this was verified by cross-checking against
`x86_64-pc-windows-gnu` with mingw; the msvc target cannot be checked here
because `zstd-sys` needs MSVC headers.

Refs #552
Refs #566
Refs #576
The retry added to `try_remove_entry` in the previous commit used the full
~10s ladder, but that function runs once per entry inside three sweep loops.
The cost there is not 10s, it is 10s PER LOCKED ENTRY — a branch switch with a
dev server running would have turned a cleanup pass into a multi-minute stall.

Split the ladder: operations whose failure is fatal to the install keep 10
attempts, best-effort removals the caller proceeds past either way get 4
(~750ms). The transient being waited out is an AV scan window, which clears
well inside a second, so the short ladder catches essentially everything the
long one would.

`sweep_stale_tmp_dirs` keeps the full ladder deliberately: its 10-attempt
behavior predates this branch, and the orphans it reclaims come from dead
processes, so nothing is holding them open.

Refs #552

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

Two of the new retry wrappers need a second look before merge: one spends multi-second backoff inside a sweep loop that throws the result away, and one covers the cooler of two structurally identical parent-directory batches.

Reviewed changes — the single new commit f2b23bd, which extends the Windows transient-retry ladder to the paths 0b816d9 left uncovered and stops a benign junction collision from failing an install.

  • Retry the top-level entry removalreconcile_top_level_link's Windows removal now goes through the ladder, so a node_modules/<name> held open by a dev server, a --watch task, or Defender mid-scan no longer aborts a whole reinstall.
  • Retry the shared entry wipe and the recursive mkdirtry_remove_entry and mkdirp both back off now. mkdirp also moved off xx::file::mkdirp onto std::fs::create_dir_all so the raw errno survives the retry predicate, and it returns a path-carrying Error::Io instead of the lossy Error::Xx.
  • Retry the hoisted parent-directory batch — the per-file fallback in hoisted.rs creates every intermediate parent in one pass up front; that pass is now retried.
  • Accept ERROR_ALREADY_EXISTS on a junction that already points where we wanted — the new junction_points_at compares both sides through canonicalize, so the concurrent-writer race behind #576 succeeds while a junction pointing elsewhere still fails. Two Windows-only tests cover both directions.
  • Reflow six hunks in patches.rs — rustfmt output, no behavior change.

I traced the os-183 acceptance across every create_dir_link caller and found it sound: junction_points_at requires both paths to canonicalize equal, so a wrongly-targeted junction, a plain directory left by an older layout, and a canonicalize failure all still surface as errors — and no caller consumes 183 as a signal. The mkdirp rewrite likewise checks out against xx::file::mkdirp's actual source (an exists() guard plus the same create_dir_all), apart from the two differences noted inline, and Error::Io's "I/O error at {path}: {err}" is a strict upgrade over Error::Xx's stringified wrap.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment on lines +820 to +821
crate::sweep::with_transient_retry(|| std::fs::create_dir_all(parent))
.map_err(|e| Error::Io(parent.clone(), e))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The structurally identical parent-batch loop at materialize.rs:633-635 — the isolated linker's per-file fallback, and the hotter of the two — still calls bare std::fs::create_dir_all, so "the remaining transient-fatal paths" is only half covered. This expression is now byte-equivalent to crate::mkdirp, so routing both loops through it closes the gap and drops the duplication in one move.

Technical details
# Fatal `create_dir_all` sites left unretried alongside the one that was fixed

## Affected sites
- `vendor/aube/crates/aube-linker/src/hoisted.rs:820-821` — the site this commit wrapped. `with_transient_retry(|| create_dir_all(parent)).map_err(|e| Error::Io(parent.clone(), e))?` is exactly what the new `mkdirp` (`sweep.rs:177-190`) does.
- `vendor/aube/crates/aube-linker/src/materialize.rs:633-635` — the same batch-every-unique-parent loop in `materialize_into`'s non-clonedir path, still `std::fs::create_dir_all(parent).map_err(|e| Error::Io(parent.clone(), e))?`. This runs for every non-clonedir package, i.e. the dominant path on Windows.
- `vendor/aube/crates/aube-linker/src/materialize.rs:1064`, `1207`, `1247` — three more fatal `create_dir_all` calls (clone parent, trees dir, per-file parent) with no retry.
- `vendor/aube/crates/aube-linker/src/link.rs:537-539` and `1121-1123` — the GVS `Stale` reconciliation still uses a single-attempt, unchecked `remove_dir().or_else(remove_file())`, while every other removal this PR touched was upgraded.

## Required outcome
- Every fatal directory-creation site in the linker rides the same ladder, so a `pending-delete` / `ACCESS_DENIED` slot does not abort an install on one path while being ridden out on another.
- The `hoisted.rs` loop does not carry a hand-inlined copy of `mkdirp`'s body.

## Suggested approach
Replace both loop bodies with `mkdirp(parent)?` (`crate::mkdirp` in `hoisted.rs`, the already-imported `mkdirp` in `materialize.rs`). `parent` is a `&PathBuf` in both, which coerces to the `&Path` parameter. `materialize.rs:1064`/`1207`/`1247` are the same one-line substitution.

## Open questions for the human
- Were `link.rs:537-539` / `1121-1123` left alone deliberately (the os-183 acceptance makes a failed removal there recoverable), or are they simply the next ones in line?
Suggested change
crate::sweep::with_transient_retry(|| std::fs::create_dir_all(parent))
.map_err(|e| Error::Io(parent.clone(), e))?;
crate::mkdirp(parent)?;

Comment thread vendor/aube/crates/aube-linker/src/sweep.rs Outdated
Comment on lines +189 to +190
with_transient_retry(|| std::fs::create_dir_all(dir))
.map_err(|e| Error::Io(dir.to_path_buf(), e))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Dropping the exists() short-circuit costs a failing mkdir plus an is_dir() stat on every warm call where the old path was a single stat, and mkdirp runs per package (link.rs:307, 691, 722, 890, 1071, 1426, 1445, 1468, 1693, 1806). The comment's "dropping the check changes nothing" also isn't quite right: an existing non-directory used to return Ok and now errors — an improvement, but not nothing.

Technical details
# The `exists()` fast path was load-bearing for syscall count

## Affected sites
- `vendor/aube/crates/aube-linker/src/sweep.rs:177-190` — the rewritten `mkdirp`.
- `xx::file::mkdirp` (xx 2.6.1) is `if path.exists() { return Ok(()) }` then `fs::create_dir_all`. `std::fs::create_dir_all` on an existing directory does `mkdir` (fails `AlreadyExists`) then `path.is_dir()` — two syscalls where the old guard was one.
- `vendor/aube/crates/aube-linker/src/materialize.rs:579-597` — the sibling comment documents collapsing "~25 redundant statx calls" per package as worth optimizing, which sets the bar this regresses against.
- Behavior delta: for a path existing as a **file**, `exists()` returned true so the old `mkdirp` returned `Ok`; `create_dir_all` returns `AlreadyExists`. `AlreadyExists` is raw 183 on Windows, so it is not in `is_transient_fs_error`'s `{5, 32}` and does not enter the ladder.

## Required outcome
- The warm path keeps its single-stat cost while the retry and the raw-errno-preserving error conversion stay.
- The comment reflects the actual delta rather than claiming equivalence.

## Suggested approach
Re-add the guard as `is_dir()` rather than `exists()`, which keeps the new (stricter, better) behavior for an existing non-directory while restoring the fast path.
Suggested change
with_transient_retry(|| std::fs::create_dir_all(dir))
.map_err(|e| Error::Io(dir.to_path_buf(), e))
if dir.is_dir() {
return Ok(());
}
with_transient_retry(|| std::fs::create_dir_all(dir))
.map_err(|e| Error::Io(dir.to_path_buf(), e))

Comment thread vendor/aube/crates/aube-linker/src/sys.rs Outdated
if file_lines.len() < context_index
|| file_lines.len() - context_index < hunk.original_length
{
if file_lines.len() < context_index || file_lines.len() - context_index < hunk.original_length {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ These six hunks are rustfmt reflow with no behavior change and no relation to the linker fix. Nothing gates formatting here — root cargo fmt --check only covers workspace members and vendor/aube is excluded, and aube-parity.yml has no fmt step — so this is conflict surface the next upstream aube sync has to reconcile rather than a gate being satisfied.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ The bounded ladder resolves the sweep-loop stall. One arithmetic discrepancy in its doc, inline.

Reviewed changes — the new commit 691b24a, which splits the retry ladder so a best-effort cleanup pass stops paying the full backoff.

  • Split with_transient_retry into a thin wrapper over with_transient_retry_bounded — the attempt count is now a parameter, the Unix passthrough moved into the shared body, and the 10-attempt path keeps its "failure is fatal to the install" framing.
  • Bound try_remove_entry to 4 attempts — the best-effort entry wipe that runs once per swept entry no longer multiplies the full ladder across a whole node_modules/, which was the concern raised on the prior revision.

Everything else in the split checks out: the parameter's #[cfg_attr(not(windows), allow(unused_variables))] is correct since attempts is genuinely unused off Windows, attempts.max(1) keeps the trailing unreachable! sound even for a zero argument, and the Unix path still calls op() exactly once.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

}

/// Short ladder for a BEST-EFFORT operation the caller proceeds past either
/// way: 4 attempts, ~750ms worst case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ Four attempts sleeps 350 ms, not ~750 ms: the back-off runs after attempts 0, 1, and 2 (50 + 100 + 200) and attempt 3 returns the error without sleeping. That matters here because the doc justifies the budget against a window that "clears well inside a second" — 350 ms is not comfortably inside that claim, so five attempts is what actually buys the 750 ms being described.

Technical details
# The bounded ladder's documented budget is 2× its actual budget

## Affected sites
- `vendor/aube/crates/aube-linker/src/sweep.rs:75` — "4 attempts, ~750ms worst case".
- `vendor/aube/crates/aube-linker/src/sweep.rs:97-105` — the loop sleeps only when it is *not* about to return, so N attempts produce N-1 sleeps: `50 << attempt.min(6)` for attempt 0, 1, 2 = 350 ms total.
- The same off-by-one is harmless on the full ladder (9 sleeps = 9150 ms, documented as "~10s" at line 66), which is why it reads as correct there.

## Required outcome
- The stated worst case matches the code, and the chosen budget matches the reasoning the doc gives for it.

## Suggested approach
Either bump the call at `sweep.rs:187` to `with_transient_retry_bounded(5, ...)` (50 + 100 + 200 + 400 = 750 ms, matching both the doc and the sub-second rationale) or correct the doc to `4 attempts, ~350ms worst case`.

## Open questions for the human
- Was 750 ms the intended ceiling, or was 4 the intended attempt count?

# Conflicts:
#	vendor/aube/crates/aube-linker/src/link.rs
#	vendor/aube/crates/aube-linker/src/sweep.rs
The real Windows CI leg disagreed with the assumption this was built on.
`recreating_a_junction_at_a_different_target_still_fails` asserted that
re-pointing an existing junction at a DIFFERENT target still errors; on a real
windows-latest runner `create_dir_link` returned `Ok` instead.

That matters more than the failing assertion. `junction::create` calls
`fs::create_dir(junction)` (junction-2.0.0, internals.rs:37), so 183 on an
existing junction is exactly what the code expected, and the guard should have
rejected a mismatched target and propagated the error. It did not, and I cannot
explain why from here — which means the tolerance was accepting cases I do not
understand, on a platform I cannot reproduce on. An arm that silently accepts a
junction pointing at the WRONG target would leave a package resolving to
another package's tree.

Nothing is lost by removing it. #595 fixed the actual cause of the reported
`os error 183` — a populated real directory cleared with a non-recursive
`remove_dir`, so junction creation aborted — and closed #566 and #576. This arm
was speculative hardening for a concurrent-writer collision I never
demonstrated, layered on top of an already-fixed bug.

The rest of the branch stands: it is verified by a control on any platform, and
none of it depends on this.

Refs #552

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

The merge resolves the one conflict that main had also just changed in favor of the branch, which shortens a retry ladder main deliberately lengthened two commits ago. Inline.

Reviewed changes — the merge commit c0ace66, which brings main into the staging branch. The branch forked at dc27843e, so the merge pulls in #595 (the overlapping Windows relink fix) and #601 (quarantine::strip_cached_entry) among others; the only substantive delta is how three conflicted regions were resolved.

  • Reordered link_all step 1a around main's cached-entry strip — the warm branch keeps its own exists() test so strip_cached_entry still runs on a hit, and the cold branch calls ensure_in_aube_dir.
  • Applied the same shape to link_workspace step 1aexists() → strip → continue, otherwise the staged materialize.
  • Kept the branch's 4-attempt ladder in try_remove_entry — main had just moved that function onto the full 10-attempt ladder, so the resolution changes shipped behavior rather than merely reconciling text.

I traced the three merge interactions that could have gone wrong and all three hold. #601's quarantine strip still fires under staging, because materialize_into strips pkg_nm_dir, which is derived from base_dir — the .tmp-* dir — and xattrs survive the rename into place. The os-183 junction acceptance short-circuits ahead of #595's new clear-and-retry wrapper, so a correct junction becomes a no-op while a wrongly-targeted one still falls through and errors. And the staging premise survives: materialize_into's only callers remain the two helpers that stage.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

// PER LOCKED ENTRY, turning a cleanup pass into a multi-minute stall when
// a dev server holds several. The transient here is an AV scan window,
// which clears well inside a second.
let _ = with_transient_retry_bounded(4, || std::fs::remove_dir_all(path));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Main now ships this removal on the full ladder — #595 routed try_remove_entry through remove_dir_all_with_retry — so resolving the conflict in favor of the branch narrows the transient window from ~9.15 s to 350 ms for all 14 callers, including the three that abort the install the moment the removal fails (link.rs:235, link.rs:451, link.rs:1026). The 4-attempt bound was asked for on behalf of the six discarded-outcome sweep loops; the other eight callers refill the slot, where a failed wipe is fatal rather than best-effort.

Technical details
# The merge shortens main's `try_remove_entry` ladder at the fatal call sites

## Affected sites
- `vendor/aube/crates/aube-linker/src/sweep.rs:194` — the third of the merge's three conflict resolutions. Base (`cff8bc74`, #595): `remove_dir_all_with_retry(path)`, i.e. `with_transient_retry` = 10 attempts, 50 ms doubling to a 2 s cap = 9150 ms of sleep. Merged: `with_transient_retry_bounded(4, …)` = 3 sleeps = 350 ms.
- Callers that hard-error the install the instant the entry survives: `link.rs:235` and `link.rs:1026` (`"failed to remove stale local dependency materialization"`), `link.rs:451` (`"failed to remove stale shared-store link…"`, added by this PR).
- Callers that refill the slot, so a failed wipe resurfaces as `ERROR_ALREADY_EXISTS` / os 183 from the following `create_dir_link`: `link.rs:547`, `link.rs:901`, `materialize.rs:417`, `hoisted.rs:742`, `hoisted.rs:781`.
- Callers the short ladder was introduced for, where the outcome is discarded: `sweep.rs:275`, `sweep.rs:289`, `sweep.rs:347`, `sweep.rs:350`, `sweep.rs:353`, `link.rs:1252`.
- The doc immediately above (`sweep.rs:74-83`) scopes the short ladder to "a BEST-EFFORT operation the caller proceeds past either way", which describes 6 of the 14 callers.

## Required outcome
- A sweep pass stays bounded per entry (the reason for `691b24a`), while the callers whose failure aborts the install keep at least the tolerance main ships today.

## Suggested approach
Split the two families instead of having one helper serve both: keep `try_remove_entry` on `with_transient_retry` for the refill and hard-error callers, and give the six sweep sites a short-ladder variant (or pass the attempt count at the call site). This is the split the earlier sweep-loop thread proposed, and it satisfies both constraints at once.

## Open questions for the human
- Was shortening #595's ladder intentional, or an artifact of resolving the conflict in favor of the branch?

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes — the single new commit 999d556, pushed while the previous review was in flight. It is a clean revert with no other edits.

  • Reverted the os-183 junction tolerance — the ERROR_ALREADY_EXISTS-is-success branch in create_junction_with_retry, the junction_points_at helper, and both Windows-only tests are gone; sys.rs is now byte-identical to main, and the PR is down to six files.

The revert leaves no gap: #576 is already closed by #595, whose create_dir_link wrapper clears the slot and retries once on AlreadyExists, which covers the same collision the tolerance was written for. It also retires the coverage concern raised on sys.rs — that thread is resolved.

Not approving only because the earlier revisions' threads are still open, including the try_remove_entry ladder point from the review a moment ago; none of them are re-raised here.

Pullfrog  | Fix it ➔View workflow run | Using Claude Opus𝕏

@colinhacks
colinhacks merged commit 1fff626 into main Jul 29, 2026
53 checks passed
@colinhacks

Copy link
Copy Markdown
Contributor Author

Shipped in v0.7.0: https://github.com/nubjs/nub/releases/tag/v0.7.0

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.

[Bug Report] Fail to remove dependencies [Bug Report] always display "failed to link node_modules"

2 participants