Skip to content

install: never delete a concurrently published cache entry on Windows - #33884

Open
Jarred-Sumner wants to merge 5 commits into
mainfrom
claude/install-cache-publish-loser-race
Open

install: never delete a concurrently published cache entry on Windows#33884
Jarred-Sumner wants to merge 5 commits into
mainfrom
claude/install-cache-publish-loser-race

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

The race

Two bun install processes sharing the package cache on Windows can corrupt each other: when both extract the same package, the loser's rename into the final cache path fails because the winner already published it, and the loser's error handling then renames the winner's already-published cache entry out of the cache and delete_trees it before retrying its own rename. During that 10-150 ms window the cache entry does not exist, so the winner, which already moved on to the install phase, fails with ENOENT: failed opening cache/package/version dir for package <name>.

Full analysis with line references: #28062 (comment)

The fix

Cache entries are keyed by package identity, so the cache is effectively write-once and an existing destination is an equivalent entry that a concurrent bun install won the race to publish. On EXIST/NOTEMPTY/PERM/BUSY the loser now opens the destination with DELETE access and:

  • accepts it if it is a valid cache entry by package_missing_from_cache()'s own check (Npm: has package.json; Github/LocalTarball/RemoteTarball: directory exists). Delete only our own temp extraction and proceed with the winner's directory.
  • evicts it by handle if it is an Npm entry without package.json, i.e. a stale entry nothing reads from as valid. move_opened_file_at on the opened handle renames the directory whose package.json was checked, never a concurrently-published fresh entry that landed at the same path in the gap; the POSIX arm already gets this from RENAME_EXCHANGE.
  • backs off and retries (10/20/40/80 ms) when the destination will not open, or when the eviction rename itself fails (AV / Search Indexer / mid-flight rename holding a conflicting share mode).

This keeps the existing "re-extract over a corrupt cache entry" behaviour that bun-install-registry.test.ts exercises while never deleting a concurrently published entry.

Verification

test/regression/issue/28062.test.ts runs four parallel installs against a shared BUN_INSTALL_CACHE_DIR for eight rounds on Windows.

# system bun (before): fails on round 1
(fail) concurrent installs sharing a cache dir do not delete each other's cache entries
  ENOENT: failed opening cache/package/version dir for package a-dep
  ENOENT: failed opening cache/package/version dir for package basic-1
  ... (3 of 4 installs failed)

# bun bd (after): all 32 installs succeed
(pass) concurrent installs sharing a cache dir do not delete each other's cache entries [3691.15ms]
  64 expect() calls

Also on Windows with this build: bun-install-registry.test.ts -t "invalid cached package if package.json is missing" passes (it failed on a6b5e63 when the destination was accepted unconditionally).

Fixes #28062


no test proof · iteration 3 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/regression/issue/28062.test.ts

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:00 AM PT - Aug 2nd, 2026

@robobun, your commit 45ed0f6 has 1 failures in Build #87621 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33884

That installs a local version of the PR into your bun-33884 executable, so you can run:

bun-33884 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. error: moving "" to cache dir failed ENOTEMPTY: Directory not empty (NtSetInformationFile()) #18248 - Reports ENOTEMPTY on NtSetInformationFile() when moving packages to cache dir on Windows — exactly the error code path this PR fixes
  2. bun install occasionally fails with parallel runs #12917 - Parallel bun install runs occasionally fail with EEXIST errors, consistent with the concurrent cache publish race this PR addresses
  3. bun install fails on Windows: Operation not permitted (NtSetInformationFile()) #11250 - EPERM on NtSetInformationFile() during Windows install cache operations — one of the exact error codes handled by this PR's new non-destructive path

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #18248
Fixes #12917
Fixes #11250

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Windows cache publish handling

Layer / File(s) Summary
Cache move failure recovery
src/install/extract_tarball.rs
Windows cache publishing now closes the source after a failed move, treats an existing destination as a concurrent install, removes the temporary extraction directory, and otherwise performs bounded exponential-backoff retries for selected errors.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The change addresses the Windows race by preserving the published cache entry and letting concurrent installs succeed.
Out of Scope Changes check ✅ Passed The diff appears scoped to the Windows cache publish fix with no unrelated code changes.
Title check ✅ Passed The title clearly summarizes the Windows fix that prevents deletion of concurrently published cache entries.
Description check ✅ Passed The description explains the race, the fix, and verification results in sufficient technical detail.

Comment @coderabbitai help to get the list of available commands.

@claude claude 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.

I didn't find any bugs and the logic looks sound, but this changes Windows-only cache-publish semantics that were only verified via cargo check (no runtime execution), and the PR explicitly flags a behavioral trade-off around corrupt cache entries — worth a maintainer sign-off, ideally with the requested Windows soak test.

Extended reasoning...

Overview

This PR modifies the Windows-only error-recovery arm in src/install/extract_tarball.rs::move_to_cache_directory. Previously, when renaming a freshly-extracted temp dir into the cache failed with NOTEMPTY/PERM/BUSY/EXIST, the code would rename the destination cache entry out to a temp path and delete_tree it before retrying — which could destroy a concurrent bun install's just-published entry. The new code closes dir_to_move, probes whether the destination now opens, and if so accepts the winner's entry (deleting only its own temp copy and breaking to the shared re-open path). The bounded backoff retry is retained only for the case where the destination is not yet openable. Net: ~50 lines of destructive recovery replaced with ~20 lines of accept-the-winner logic.

Security risks

None identified. This is cache-publish contention handling; no new inputs are parsed, no path construction is added, and the only new filesystem operation is cache_dir.open_at(folder_name) on a name already computed and used by the surrounding code. The returned Dir from the .is_ok() probe is closed via Drop, so no fd leak.

Level of scrutiny

Medium-high. The diff is small and the reasoning is well-argued, but:

  • The entire hunk is behind #[cfg(windows)] and the author states it was only type-checked (cargo check --target x86_64-pc-windows-msvc), never executed. The PR description explicitly requests a maintainer with a Windows machine to soak-test concurrent installs.
  • This is the package-manager cache-publish path — it runs on every uncached bun install on Windows.
  • The PR explicitly calls out a behavioral trade-off: the deleted destructive path incidentally self-healed pre-existing corrupt/incomplete cache directories, and this change will now accept them instead. The author offers a follow-up; a maintainer should decide whether that's acceptable as-is.

Other factors

  • No automated test is included (deliberately, with a stated rationale that the racy path is unreachable off-Windows). Per repo guidelines this normally blocks, so a human should confirm the exception is acceptable here.
  • The sys::close(dir_to_move) was hoisted to the top of the Err arm, which is a correct simplification (previously duplicated across the retry and fall-through paths).
  • No CODEOWNERS entry covers src/install/.

Given the untested-on-target-platform status and the flagged design trade-off, this warrants human review rather than auto-approval.

Jarred-Sumner pushed a commit that referenced this pull request Aug 2, 2026
…#36613)

Fixes the Windows CI flake in `test/regression/issue/36577.test.ts`
introduced by #36578, and brings the test under the default timeout on
debug builds.

### Repro

With the CI runner's env on Windows:
```powershell
$env:BUN_INSTALL_CACHE_DIR=$tmp; $env:BUN_TMPDIR=$tmp; $env:TEMP=$tmp
bun test test/regression/issue/36577.test.ts
```
fails 12/15 runs with either `expect(r.code).toBe(0)` receiving 1, or
stderr containing
```
error: failed to verify cache dir for "f012": ENOENT
```

### Cause

The two `test.concurrent` cases install overlapping package names
(`f000`-`f023`, `lib`, `carrier`, `pdep`, `zz-late`). The test set
`cache = "cache"` in bunfig to give each case an isolated cache, but
`scripts/runner.node.mjs` sets `BUN_INSTALL_CACHE_DIR` on the test
process, `bunEnv` inherits it, and `fetch_cache_directory_path` consults
that env var before the bunfig setting, so both concurrent `bun install`
processes shared one cache directory.

On Windows, the tarball-extraction retry path handles an occupied cache
slot by renaming the existing entry into the temp dir and deleting it
before retrying, so one process's post-rename verify can see `ENOENT` on
an entry the other process just moved aside. That underlying race is
#28062 (fix in progress in #33884). The POSIX path uses
`renameat_concurrently_a`, which does not remove the existing entry, so
the flake is Windows-only.

Separately, under debug+ASAN each case took ~5s (two subprocess installs
on 80-104 packages plus ~100 `Bun.Archive.write` calls for tarball
synthesis in the registry server), right at the 5s default timeout.

### Fix

- Override `BUN_INSTALL_CACHE_DIR` per test case in the spawn env so the
intended isolation holds regardless of the ambient environment; drop the
now-dead bunfig `cache` key and `mkdirSync`.
- Run both installs with `--lockfile-only`. The `Lockfile::eql`
comparison under test only needs a lockfile, so tarball download,
extraction and linking are irrelevant. With no tarball fetch the
registry server no longer needs to synthesize real tarballs, so the
`Bun.Archive.write` path and the tarball route are removed (integrity is
never verified on this path).

### Verification

- Windows with CI-like env: 0/30 failures (was 12/15).
- `bun bd test`: 5/5 pass, 0.8-1.8s per case (was timing out at 5s).
- With the `src/install/lockfile.rs` change from #36578 reverted, both
cases still fail with `lockfile had changes, but lockfile is frozen`, so
the test continues to cover the original regression.

<!-- robobun:evidence:begin -->

---

**[stamp-90s]** gate passed · iteration 1 · 1 files touched

<details><summary>passes on PR (with fix)</summary>

```console
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/regression/issue/36577.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "test/regression/issue/36577.test.ts"
bun test v1.4.0 (6aa1437)

test/regression/issue/36577.test.ts:
(pass) frozen lockfile accepts a freshly generated lockfile (32 fillers) [2973.65ms]
(pass) frozen lockfile accepts a freshly generated lockfile (24 fillers) [3158.88ms]

 2 pass
 0 fail
 8 expect() calls
Ran 2 tests across 1 file. [5.50s]
Exit: 0
```

</details>

<details><summary>diff hotspot</summary>

```
test/regression/issue/36577.test.ts | 65 +++++++++----------------------------
 1 file changed, 16 insertions(+), 49 deletions(-)
```

</details>

**gate history** · 1 passed · 1 rejected · iteration 1

<details><summary>evidence per changed file</summary>

```
file                                 reads  edits  tests
test/regression/issue/36577.test.ts      6      9      0
```

</details>

**self-review** · no surviving concerns

33 concerns were raised and did not survive verification.

<!-- robobun:evidence:end -->
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator Author

@robobun rebase this PR

When two bun install processes share the package cache on Windows and both
extract the same package, the loser's rename into the final cache path fails
because the winner already published it. The loser then renamed the winner's
entry out of the cache and deleted it, leaving a window with no cache entry,
so the winner's own install failed with ENOENT.

Cache entries are keyed by package identity, so an existing destination is an
equivalent entry: accept it, delete only our own temp dir, and succeed. Keep
the bounded backoff retry only for a destination that is not yet openable.
This mirrors the POSIX arm, which already has these semantics via an atomic
exchange.

Fixes #28062
@robobun
robobun force-pushed the claude/install-cache-publish-loser-race branch from 3e9114a to a6b5e63 Compare August 2, 2026 03:29
@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto main (a6b5e63). No conflicts.

@claude claude 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.

I didn't find any bugs, but this changes Windows-only cache-publish error recovery with a stated behavioral trade-off (corrupt cache entries are no longer evicted) and ships without a runtime test by design — the author explicitly asks for a Windows soak-test and a maintainer call on the trade-off. Worth a human look, and worth confirming the post-rebase Windows CI is green (the pre-rebase run had bun-install-registry.test.ts failing on 🪟 x64).

What was reviewed

  • The new accept-winner path: cache_dir.open_at(folder_name).is_ok() returns an owned Dir whose Drop closes the fd, so the probe does not leak a handle.
  • Control flow after break: the loser falls through to the existing cache_dir.open_at(folder_name) re-open, so it proceeds with the winner's directory as described.
  • Retry path: the temp extraction dir is only deleted in the accept branch, so re-opening dir_to_move on continue still finds it; dir_to_move is closed on every error path before retry/fail.
Extended reasoning...

Overview

This PR rewrites the error-recovery arm of the Windows-only (#[cfg(windows)]) cache-publish loop in src/install/extract_tarball.rs::move_to_cache_directory. Previously, on EXIST/NOTEMPTY/PERM/BUSY from move_opened_file_at, the code renamed the destination cache entry into the temp dir and delete_tree'd it before retrying — destroying an entry a concurrent bun install had just published. The new code instead probes the destination with open_at; if it opens, the loser deletes only its own temp extraction and accepts the winner's entry. If the destination is not yet openable, the bounded exponential backoff (10/20/40/80 ms) is retained before failing. sys::close(dir_to_move) is hoisted to run once at the top of the error arm on every path.

Security risks

None identified. This is filesystem error-recovery inside the package cache; no untrusted input parsing, no path construction changes, and the destructive operation that remains (tmpdir.delete_tree(tmpname)) targets only the process's own randomly-named temp extraction directory.

Level of scrutiny

Medium-high. The diff is small (~30 net lines removed) and the logic is straightforward, but it is production error-handling in the package manager on a platform the author could not execute on (verified only via cargo check --target *-pc-windows-msvc). The PR description explicitly states a behavioral trade-off — a pre-existing corrupt/incomplete cache directory that the old destructive path incidentally evicted will now be accepted — and defers that call to a maintainer. REVIEW.md's "every behavioral change ships an automated test" is deliberately not met here, with a reasoned argument; that's a maintainer judgment, not a bot approval.

Other factors

  • I checked for an fd leak from the .is_ok() probe: Dir has a Drop impl (src/sys/dir.rs:14) that closes the fd, so the temporary is cleaned up.
  • The break after accepting the winner correctly falls through to the existing post-loop cache_dir.open_at(folder_name)get_fd_path_z sequence, so ExtractData is populated from the winner's directory.
  • On the retry continue, the temp dir still exists (only deleted in the accept branch), so re-opening dir_to_move at the top of the loop is sound.
  • The pre-rebase CI run (#71287) showed test/cli/install/bun-install-registry.test.ts failing on Windows x64 and x64-baseline. I can't tell from here whether that's related or pre-existing flake, but it's directly in the changed subsystem and should be confirmed green on the fresh run before merge.

…egression test

The accept-existing path must only accept a destination that actually has a
package.json. package_missing_from_cache() triggers re-extraction precisely
because the cache dir exists without one, and NtSetInformationFile cannot
replace a non-empty directory, so the fresh extraction was being discarded
and the stale entry kept. That broke the existing
'it should invalid cached package if package.json is missing' test on Windows.

Now on collision: if the destination has package.json, accept it (a concurrent
install published it); otherwise move the stale entry aside into the temp dir
and retry. That matches what the POSIX arm gets from RENAME_EXCHANGE.

The new test/regression/issue/28062.test.ts runs four parallel installs over a
shared cache for eight rounds on Windows. Without the fix it fails on the first
round with ENOENT opening cache/package/version; with the fix all 32 installs
succeed.
@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

CI on the rebase turned up a real regression in the original diff: bun-install-registry.test.ts "it should invalid cached package if package.json is missing" failed on both Windows lanes. Accepting any openable destination meant a stale cache dir (exists, no package.json) was kept and the fresh extraction discarded, so the cache never repopulated.

3b5cf84 gates the accept path on package.json being present in the destination. A destination without it is the stale entry package_missing_from_cache() already rejected; nothing reads from it as valid, so it's moved aside and the rename retried, same as before. Concurrent winners always have package.json (the directory is renamed into place atomically), so they're still accepted and never deleted.

Also added test/regression/issue/28062.test.ts (Windows-only): four parallel installs over a shared cache, eight rounds. Fails on round 1 under current canary with ENOENT: failed opening cache/package/version dir, passes with this branch. The registry test and the peer-hoist test both pass on Windows x64 with the updated build. PR body updated to match.

Comment thread src/install/extract_tarball.rs Outdated
Comment thread src/install/extract_tarball.rs Outdated
Comment thread src/install/extract_tarball.rs
…issing_from_cache() validity

Open the destination with DELETE access and evict via move_opened_file_at on
that handle instead of a path-based renameat, so the directory whose
package.json was checked is the one moved aside; a concurrently published
fresh entry at the same path is never touched.

Only gate on package.json for ResolutionTag::Npm. package_missing_from_cache()
uses directory_exists_at for Github/LocalTarball/RemoteTarball, and Github
entries are allowed to have no package.json, so accept any openable
destination for those tags.

Keep the backoff sleep before retry when the eviction rename fails (sharing
violation from AV/indexer); only skip it when the stale entry was actually
moved aside.

@claude claude 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.

Verified 8353d5b addresses the three concerns from the prior pass: eviction now goes through move_opened_file_at on the opened dest handle (no path re-resolution TOCTOU), non-Npm tags are accepted on directory existence alone so a package.json-less GitHub entry is never evicted, and the backoff sleep is restored on the eviction-failed retry path. Only new finding is the test-file nit below.

Extended reasoning...

The three inline concerns I raised on the previous revision are all resolved in the current diff — I re-read the Windows publish loop and confirmed each fix is present as described, not just claimed. Deferring rather than approving because this is still a subtle Windows-only concurrency change in the installer's cache publish path that has already needed two rounds of correction (one CI-caught regression, then three review-caught edge cases); a human sign-off on the final shape is warranted. The remaining finding is a CI-perf nit only.

Comment thread test/regression/issue/28062.test.ts
File-scope beforeAll runs even when every test is skipped, so non-Windows
lanes were forking verdaccio for a test that never runs. Guard the hook body
on isWindows and give it a 60s ceiling for cold debug VMs.

@claude claude 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.

All four prior review threads are addressed in the current diff and no new issues turned up this pass, but this is a subtle Windows-only concurrency fix in the installer's cache-publish path — worth a human look before merging.

What was reviewed:

  • Eviction now binds to the opened handle via move_opened_file_at(dest, …) — verified against src/sys/windows/mod.rs:1860 that this is NtSetInformationFile on the fd, not a path re-resolve.
  • Validity check mirrors package_missing_from_cache(): non-Npm tags accept on directory-open alone; Npm requires package.json.
  • Backoff sleep restored on the eviction-failed path; dest is closed on every arm (valid/evicted/max-retries/else).
  • Test hooks now skip the verdaccio fork on non-Windows lanes.
Extended reasoning...

Overview

The PR reworks the Windows #[cfg(windows)] retry loop in ExtractTarball::move_to_cache_directory (src/install/extract_tarball.rs). Pre-PR, a rename collision on the cache destination unconditionally renamed the existing cache entry out to a temp path and delete_tree'd it before retrying — which, when the existing entry belongs to a concurrent bun install, deletes a live cache entry the other process is about to read. The new logic opens the destination with can_rename_or_delete: true, accepts it if it satisfies the same validity predicate package_missing_from_cache() uses, and only evicts (by handle, not path) an Npm entry with no package.json. A new Windows-only regression test spins four concurrent installs against a shared cache for eight rounds.

This is the fourth revision I've looked at. The three earlier rounds each found real issues (path-based check-then-act eviction, wrong validity predicate for Github/tarball tags, lost backoff on eviction failure, unnecessary verdaccio fork on non-Windows), all of which are now fixed and verified in the current diff. The bug-hunting system found nothing new this round.

Security risks

None identified. The change is confined to how a rename collision is handled inside Bun's own package cache; no new inputs are parsed and no trust boundaries are crossed. The only destructive operations (delete_tree) target the process's own temp extraction (tmpname) or a handle-verified stale entry moved into tmpdir.

Level of scrutiny

High. This is production-critical installer code with a failure mode of "silently deletes another process's cache entry", the fix reasons about Windows handle semantics (DELETE access, sharing violations, NtSetInformationFile on an open handle vs. path-based rename), and it's exercised only on Windows CI lanes. The retry/backoff state machine has five distinct exit paths (accept-valid, evict-succeeded, evict-failed-with-backoff, open-failed-with-backoff, max-retries → error) and getting any one wrong either leaks a handle, reintroduces the race, or spins without waiting. I traced each path for handle balance and termination and they look correct — but three prior rounds each found something, so a maintainer familiar with the Windows install path should confirm.

Other factors

  • The regression test exercises the concurrent-winner path (the headline bug) but not the stale-entry eviction path under concurrency; that path is covered single-process by the existing bun-install-registry.test.ts "invalid cached package" test per the PR body.
  • CI build #87621 for the latest commit is still in progress at the time of this review; Windows lane results should be checked before merge.
  • The 120s test timeout and 8-round × 4-process shape is reasonable for a race repro but adds ~4s to the Windows lane; acceptable for a regression test guarding cache corruption.

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

CI on 45ed0f6 (build 87621): no new failures. bun-install-registry.test.ts and test/regression/issue/28062.test.ts pass on every Windows lane. The red is bun-upgrade.test.ts on windows-aarch64 (pre-existing on main, canary not published for that platform) plus parallel-batch flakes that all passed alone or on retry; none touch src/install/. Ready for review.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Another sighting of the same race, with the other face of it (the loser's error rather than the winner's ENOENT). Buildkite build 94864 on #36300, test/cli/install/hoist.test.ts: a describe.concurrent block where 5 installs shared the per-file BUN_INSTALL_CACHE_DIR and all needed no-deps@2.0.0. Both Windows lanes (2019 x64, 11 aarch64) failed with

error: moving "no-deps" to cache dir failed
ENOTEMPTY: Directory not empty (NtSetInformationFile())
  From: .63d76541b8c50b36-1.no-deps
    To: no-deps@2.0.0@@localhost@@@1
error: InstallFailed extracting tarball from no-deps

Linux and macOS lanes passed. With the current code each loser renames the entry that just landed out of the cache and deletes it before retrying, so with more than two installers they keep evicting each other until one of them runs out of the 4 retries and reports the ENOTEMPTY above. The accept path in this PR covers it: every loser that hits NOTEMPTY finds package.json in the destination and keeps the winner's entry, so nothing gets evicted and no retries are needed. open_dir_at_windows_a opens with FILE_SHARE_READ | WRITE | DELETE, so several losers opening the destination at once do not block each other either. The regression test here already catches this variant since it asserts exit code 0 for every install, not only the absence of ENOENT.

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.

Windows: parallel bun install --no-cache with shared BUN_INSTALL_CACHE_DIR can fail with ENOENT opening cache/package/version dir

2 participants