Skip to content

install: link packages into a staging directory and rename them into place - #38783

Open
robobun wants to merge 3 commits into
mainfrom
farm/af8d4dad/install-stage-package-dirs
Open

install: link packages into a staging directory and rename them into place#38783
robobun wants to merge 3 commits into
mainfrom
farm/af8d4dad/install-stage-package-dirs

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A bun install killed while it is linking a package out of the cache leaves a truncated package directory in node_modules, and every later bun install (including --frozen-lockfile) reports no changes, exit 0. Reproduced on main with both linkers (fuzz ledger entry 14691; also on 1.3.14, so not a regression, but it ships in 1.4).
  • Both linkers use the installed package.json as the "this package is installed" marker: hoisted PackageInstall::verify reads node_modules/<pkg>/package.json (src/install/PackageInstall.rs, verify_package_json_name_and_version), isolated checks that node_modules/.bun/<store>/node_modules/<pkg>/package.json exists (src/install/isolated_install.rs, the needs_install block).
  • Both build that directory in place: hoisted init_install_dir creates node_modules/<pkg> and then walks the cache folder into it (same for the clonefile backends), isolated Hardlinker / FileCopier / FileCloner write straight to the store path. package.json is just one of the files in the walk, so a kill after it is linked produces a directory the next install trusts.
  • install: stage git cache folders, require completion markers on hit, fix bun patch for non-npm deps and isolated hang #38269 added the equivalent staging for the cache side; this is the node_modules side.

Fix

  • StagingPath (PackageInstall.rs): a package is linked into a dot-prefixed sibling of its final directory, .bun-tmp-<hash of the package path> (node_modules/.bun-tmp-3fa369cacb4f6ef8, node_modules/@types/.bun-tmp-..., and the same inside node_modules/.bun/<store>/node_modules/), and the directory is renamed onto the final path once every file is in place. The final path is therefore either absent or complete, and the existing package.json / .bun-tag checks become correct without changing them.
  • The name is a hash rather than .<name>.bun-tmp because an alias can already be 255 bytes long (bun-install-registry's long path test); it is deterministic so the next install of the same package removes a stale one before linking (the backends overlay an existing tree, and a stale one may belong to another version), and a failed link removes its own. The leading dot keeps it out of package resolution and bun pm scans, which already skip dot entries. .old-<hex> (the existing rename-aside in uninstall_before_install) is unchanged.
  • Hoisted: install() computes the staging name, clears a stale one, runs the existing backend dispatch (now install_into, with the destination passed explicitly instead of read from self.destination_dir_subpath) against it, then renames. If the final path turns out to be occupied it is moved aside with uninstall_before_install and the rename repeated: with a fresh node_modules nothing is moved aside up front, and a workspace that is depended on under a second name is linked into node_modules under both names, so the hoisted linker walks the packages nested in it once per name and installs them twice into the same directory (migration/complex-workspace in CI; previously the second install silently overwrote the first file by file). New Step::MovingIntoPlace names the rename in the existing failed <step> for package error. uninstall_before_install still runs first whenever it did before, so failure behaviour is unchanged.
  • Isolated: append_real_store_path(.., Which::Staging) now yields the package-level staging directory for project-local entries (global-store entries keep their entry-level staging, which already covered this). Step::LinkPackage clears both the previous tree and a stale staging tree, links, and commit_local_store_package renames; the three backends break to that shared tail instead of advancing the step themselves. No occupied-destination handling is needed here because the final path is deleted in the same step. on_task_fail now removes the staging directory; the block it replaces unlinked node_modules/<storepath>, a path that does not exist in the store layout, so it never removed anything.
  • rename_staging_into_place is shared by both linkers. On Windows it retries EPERM/EACCES/EBUSY with backoff (630ms total) unless the destination exists, because NTFS refuses to rename a directory while a scanner holds a freshly written file inside it open, and reports an occupied destination with the same errors (bun install fails on Windows: Operation not permitted (NtSetInformationFile()) #11250 is the scanner failure for the cache publish; install: retry cache publish renames on Windows while a scanner holds a file open #35568, if it lands, is the natural replacement for this loop). On POSIX it is a single renameat.
  • Not changed on purpose: folder:/root entries in the isolated linker are relinked on every install and never consult the marker, so they still link in place; install_from_link creates a single symlink, which is already atomic; the double walk of multiply-aliased workspaces is pre-existing and left alone.
  • Verified by test/cli/install/bun-install-staging.test.ts, each case for both linkers:
    • Interrupted install: installs a 2048-file package from a local Bun.serve registry, deletes node_modules, runs bun install again and SIGKILLs it the moment anything appears where the package goes, then asserts the final directory is absent or complete and that one more bun install yields the complete tree with nothing else (no leftover staging directory) next to it. Fails on main for both linkers (final directory present with a handful to a few hundred of its 2081 entries; release and debug builds, Linux and Windows); with this change the kill lands inside .bun-tmp-<hash>.
    • A workspace depended on under two names, with a dependency inside it that cannot hoist, installs under both names (failed with ENOTEMPTY for the hoisted linker on the first revision of this branch).
    • A 255-character alias installs (failed with the name-based staging directory).
    • The file is its own because it needs a synthetic registry and a kill harness shared across both linkers.
  • Also run locally against the debug build: isolated-install, isolated-relink, bun-install-patch, bun-patch, bun-install-hardlink-fallback (hardlink to copyfile fallback inside the staging dir), bun-install-git-deps, bun-prune, bun-workspaces, bun-install-registry's long path test, bun-install and bun-install-lifecycle-scripts (remaining failures in the last two need network access, node on PATH, or are the debug-build stack dump on install failure). cargo check -p bun_install for the linux, aarch64-apple-darwin and x86_64-pc-windows-msvc targets.

Background

  • Hoisted linker (PackageInstall): for each package in a node_modules tree, verify() decides whether it is installed; if not, install() picks a backend (clonefile on macOS, hardlink elsewhere, copyfile fallback, per-file symlinks for file: folders) and materialises the cache folder under node_modules/<alias>. uninstall_before_install renames any existing directory to .old-<hex> and deletes it on the thread pool; it is skipped when node_modules was just created.
  • Trees: the hoisted linker installs one node_modules directory per tree. A workspace package linked into node_modules under two names yields two trees whose paths differ but resolve, through the workspace symlinks, to the same packages/<ws>/node_modules directory.
  • Isolated linker: each package gets a store entry node_modules/.bun/<name>@<version>[+peers]/node_modules/<name>; isolated_install.rs decides per entry whether it needs_install, and Installer::Task runs the steps LinkPackage (files), SymlinkDependencies, binaries, scripts. Entries that are skipped still get a relink task for their symlinks, so the package directory is the only thing the marker has to vouch for.
  • Global virtual store: an opt-in isolated mode where whole entries are built under <cache>/links/<entry>.tmp-<suffix> and renamed by commit_global_store_entry; Which::{Final, Staging} is the enum its path builders use, now also covering the project-local case.

[review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-install-staging.test.ts
bun test v1.4.0 (e67d0ba00)

test/cli/install/bun-install-staging.test.ts:
147 |     rmSync(join(root, "node_modules"), { recursive: true });
148 | 
149 |     await installAndKillWhileLinking(root, installedParent);
150 |     // Either the package is not at its final path yet, or all of it is.
151 |     if (existsSync(installed)) {
152 |       expect(compareWithExpectedTree(installed)).toEqual(completeTree);
                                                       ^
error: expect(received).toEqual(expected)

  {
-   "entries": 2081,
-   "firstMissing": [],
-   "missing": 0,
+   "entries": 37,
+   "firstMissing": [
+     "package.json",
+     "d0",
+     "d0/f0.js",
+   ],
+   "missing": 2044,
    "unexpected": [],
  }

- Expected  - 3
+ Received  + 7

      at <anonymous> (/workspace/bun/test/cli/install/bun-install-staging.test.ts:152:50)
(fail) hoisted linker: a package whose install was interrupted is installed again [1988.61ms]
147 |     rmSync(join(root, "node_modules"), { recursive: true
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (b7a043103)

test/cli/install/bun-install-staging.test.ts:
147 |     rmSync(join(root, "node_modules"), { recursive: true });
148 | 
149 |     await installAndKillWhileLinking(root, installedParent);
150 |     // Either the package is not at its final path yet, or all of it is.
151 |     if (existsSync(installed)) {
152 |       expect(compareWithExpectedTree(installed)).toEqual(completeTree);
                                                       ^
error: expect(received).toEqual(expected)

  {
-   "entries": 2081,
-   "firstMissing": [],
-   "missing": 0,
+   "entries": 1,
+   "firstMissing": [
+     "package.json",
+     "d0",
+     "d0/f0.js",
+   ],
+   "missing": 2080,
    "unexpected": [],
  }

- Expected  - 3
+ Received  + 7

      at <anonymous> (/workspace/bun/test/cli/install/bun-install-staging.test.ts:152:50)
(fail) hoisted linker: a package whose install was interrupted is installed again [651.04ms]
147 |     rmSync(join(root, "node_modules"), { recursive: true });
148 | 
149 |     await installAndKillWhileLinking(root, installedParent);
150 |     // Either the package is not at its final path yet, or all of it is.
151 |     if 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/bun-install-staging.test.ts
bun test v1.4.0 (e67d0ba00)

test/cli/install/bun-install-staging.test.ts:
(pass) hoisted linker: a package whose install was interrupted is installed again [2820.57ms]
(pass) isolated linker: a package whose install was interrupted is installed again [2403.42ms]
(pass) hoisted linker: a package reached through two workspace aliases is installed under both [398.82ms]
(pass) isolated linker: a package reached through two workspace aliases is installed under both [384.41ms]
(pass) hoisted linker: a package whose alias is 255 characters long is installed [272.83ms]
(pass) isolated linker: a package whose alias is 255 characters long is installed [264.84ms]

 6 pass
 0 fail
 28 expect() calls
Ran 6 tests across 1 file. [11.15s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     e67d0ba004
  features     baseline

22 deps, 123 codegen, 1176 objects in 1035ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] fetch zlib
[zlib] up to date
[4/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[5/1238] fetch tinycc
[tinycc] up to date
[6/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[7/1237] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[8/1237] fetch nodejs (prebuilt)
[nodejs] up to date
[9/1237] fetch picohttpparser
[picohttpparser] up to date
[10/1237] gen JSEvent.lut.h
Generating /workspace/bun/build/release/codegen/JSEvent.lut.h from /workspace/bun/src/jsc/bindings/webcore/JSEvent.cpp
[11/1237] gen ProcessBindingBuffer.lut.h
Generating /workspace/bun/build/release/codegen/Pr
... (truncated)
diff hotspot
src/install/PackageInstall.rs                | 170 +++++++++++++++----
 src/install/isolated_install.rs              |   1 +
 src/install/isolated_install/Installer.rs    | 143 +++++++++-------
 test/cli/install/bun-install-staging.test.ts | 237 +++++++++++++++++++++++++++
 4 files changed, 461 insertions(+), 90 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                          reads  edits  tests
src/install/PackageInstall.rs                    21     27      0
src/install/isolated_install.rs                   3      3      0
src/install/isolated_install/Installer.rs        14     21      0
test/cli/install/bun-install-staging.test.ts      3      3      0

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 5 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f778c3b9-a855-4682-953e-d9948090d8e4

📥 Commits

Reviewing files that changed from the base of the PR and between 71815dd and e2740a7.

📒 Files selected for processing (4)
  • src/install/PackageInstall.rs
  • src/install/isolated_install.rs
  • src/install/isolated_install/Installer.rs
  • test/cli/install/bun-install-staging.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:03 AM PT - Aug 15th, 2026

@robobun, your commit e2740a7 has 3 failures in Build #97573 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38783

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

bun-38783 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on main with both linkers by killing bun install while it links a 2048-file package out of a warm cache (test/cli/install/bun-install-staging.test.ts): the truncated directory stays at node_modules/<pkg> and the next install reports no changes. With this branch the kill lands in node_modules/.bun-tmp-<hash> and the next install links the package again.

The first CI run found two cases the first revision got wrong, both fixed in d0a3640 and covered by tests in the same file: aliases that are already 255 bytes long (the staging name is now a hash), and a workspace depended on under two names, whose nested packages the hoisted linker installs twice into the same directory (an occupied final path is now replaced like a reinstall). Both tests that failed in that run (migration/complex-workspace, bun-install-registry's long path test) also pass on a Windows Server 2019 machine with this branch.

CI for e2740a7 (rebased onto current main, adapting to #38271's init_install_dir change): every install test passes on every lane. The remaining red is bake/deinitialization (segfault on Windows), workerd/html-rewriter-leak and cli/run/require-cache (ASAN lane), none of which touch the package installer; they have been reported separately. Ready for review.

@github-actions

Copy link
Copy Markdown
Contributor

Found 6 issues this PR may fix:

  1. node_modules packages are empty after bun install #16288 - bun i reports "11 packages installed" but node_modules/elysia is empty, and the re-run only reports "3 packages installed" — the truncated dirs are accepted as already-installed and never repaired (a commenter reproduces the same with --frozen-lockfile).
  2. Bun doesn't install dependencies on Windows. #14391 - "Cannot find module... some of the folders in node_modules are empty too" on Windows, with specific dependency folders staying empty across repeated bun install runs while npm install fixes them.
  3. Bin executable does not exist on disk #23592 - Hits Bun's own "corrupted node_modules directory" bin-remap error; bun install --force completes without repairing it, which is exactly what a package dir containing package.json but missing its bin target produces.
  4. Incomplete install of next.js package #19554 - node_modules/next is left partial (Cannot find module '../server/require-hook') and repeated installs report tiny install counts without ever rebuilding the truncated directory.
  5. Cannot install project dependencies #11497 - Install reports success but only some packages land in node_modules, and the error persists even after re-running bun install — the never-repaired partial tree stated explicitly.
  6. Bun run dev crashes #22380 - Same "corrupted node_modules directory" bin-remap failure on Windows where --force does not help and the only reported fix is deleting node_modules entirely.

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

Fixes #16288
Fixes #14391
Fixes #23592
Fixes #19554
Fixes #11497
Fixes #22380

🤖 Generated with Claude Code

@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 reviewed this PR and the automated bug hunt found no issues. Given that it restructures the core install path for both linkers (stage-then-rename, backend-loop control flow, on_task_fail cleanup) plus a Windows-specific rename retry, a human look from someone who owns bun_install would still be worthwhile.

What was checked:

  • append_real_store_path(.., Which::Final) is behavior-preserving for non-global entries; append_store_path's _ arm now delegates to the new helper with the same output.
  • delete_tree returns Ok on ENOENT, so making the pre-link cleanup fatal in Step::LinkPackage doesn't break the fresh-install case.
  • Folder/Root entries branch off before the staged 'backend loop and keep their own next_step, so they neither stage nor hit commit_local_store_package; on_task_fail correctly drops Folder from the cleanup match for the same reason.
  • install_from_link is a separate entry point from install() and is not routed through the staging path.
Extended reasoning...

Overview

The PR changes both package linkers to write into a dot-prefixed staging sibling (.<name>.bun-tmp) and renameat onto the final path once linking completes, so an interrupted install never leaves a partial tree that later installs treat as complete. Touches PackageInstall.rs (hoisted linker: new StagingPath, rename_staging_into_place, install() split into a wrapper + install_into() with all backend methods now taking an explicit dest_subpath), isolated_install/Installer.rs (isolated linker: 'backend loop restructured to break to a shared commit_local_store_package tail, new append_store_package_path, on_task_fail cleanup rewritten), a comment in isolated_install.rs, and a new SIGKILL-during-link test.

Security risks

None identified. This is filesystem I/O inside node_modules; no auth, crypto, or untrusted-input parsing is added. The staging name is derived from the package name, which is already used to build the final path.

Level of scrutiny

High. This is the core write path of bun install — it runs for every package on every install, on all platforms, across five backends (clonefile, clonefile-each-dir, hardlink, copyfile, symlink) with fallback transitions between them. The isolated-linker change also restructures control flow (three step = self.next_step(); continue 'step; sites collapsed into break 'backend to a shared tail) and replaces the on_task_fail cleanup. The Windows retry loop is new platform-specific behavior. Any regression here breaks package installation.

Other factors

The PR description is thorough, cites the mechanism precisely, lists which existing test suites were run locally, and confirms cargo check on all three target triples. The new test covers both linkers with a real kill-during-link scenario and asserts both the partial-tree invariant and that a follow-up install repairs it with no leftover staging directory. Spot-checks (delete_tree ENOENT handling, Folder/Root bypass, Which::Final path-builder equivalence, install_from_link being a separate entry point, buf_print_z overflow handling) all held up. No prior human review on the timeline. The scope and criticality — every-install hot path, control-flow refactor, cross-platform rename semantics — put this outside what should land without a maintainer look, even absent identified bugs.

Comment thread src/install/PackageInstall.rs Outdated
Comment thread src/install/PackageInstall.rs Outdated
Comment thread src/install/PackageInstall.rs Outdated
Comment thread src/install/PackageInstall.rs Outdated
Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/PackageInstall.rs
Comment thread src/install/PackageInstall.rs
Comment thread src/install/PackageInstall.rs
Comment thread src/install/PackageInstall.rs
Comment thread src/install/isolated_install/Installer.rs
Comment thread src/install/isolated_install/Installer.rs
Comment thread src/install/isolated_install/Installer.rs
Comment thread src/install/isolated_install/Installer.rs
Comment thread src/install/isolated_install/Installer.rs

@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 reviewed this PR and didn't find any bugs. Because it changes the core install flow for both linkers (link-in-place → stage-then-rename) and adds Windows-specific retry behavior on the hot path of every package install, a human look would still be worthwhile.

What was reviewed:

  • Traced install()install_into() threading of dest_subpath through all five backends (clonefile, clonefile-each-dir, hardlink, symlink, copyfile) and the fallback chain — each now writes to the staging path.
  • Confirmed the isolated linker's folder/root branch keeps its own 'backend loop with append_store_path and next_step, so it does not stage or reach commit_local_store_package; the outer staging path is only reached for Npm/Git/Github/LocalTarball/RemoteTarball, matching the on_task_fail cleanup match.
  • Checked append_real_store_path(.., Final) vs (.., Staging) for those tags both resolve to siblings under node_modules/.bun/<storepath>/node_modules/, so commit_local_store_package's rename is between siblings, not into an ancestor.
  • delete_tree returns Ok on ENOENT, so the new hard-fail on stale-staging cleanup does not break fresh installs; it does upgrade a previously fire-and-forget delete of the final path to a hard error, which is required now that the rename needs it free.
Extended reasoning...

Overview

This PR changes how both the hoisted and isolated package linkers materialise packages into node_modules: instead of linking files directly into the final directory, each backend links into a deterministic .bun-tmp-<hash> sibling and renames it onto the final path once complete. The goal is to make an interrupted bun install leave either nothing or a complete package at the final path, so the existing package.json-based skip check remains correct. Touches PackageInstall.rs (+170/−30, new StagingPath, rename_staging_into_place, install() split into install() + install_into(), all backends now take an explicit dest_subpath), isolated_install/Installer.rs (+90/−60, staging for project-local store entries, commit_local_store_package, on_task_fail cleanup rewritten), a one-line comment in isolated_install.rs, and a new 237-line test file exercising both linkers.

Security risks

None identified. The staging directory is a dot-prefixed sibling inside the same node_modules (or .bun/<store>/node_modules) tree the package was already going to be written to; no new user-controlled path components are introduced (the hash is of the destination subpath). The rename is between siblings under a directory the installer already controls.

Level of scrutiny

High. This is the core file-materialisation step of bun install for both linkers — every package install now goes through an extra delete_tree and renameat. The change is architectural rather than a targeted bugfix, replaces a fire-and-forget cleanup with a hard failure in the isolated linker, and adds a Windows-only sleep-and-retry loop (up to 630ms) that is difficult to validate outside Windows CI. The append_real_store_path semantics are now asymmetric between Which::Final (whole-entry path via append_store_path) and Which::Staging (package-level path); I traced that the only callers reaching this with Npm/Git/Github/LocalTarball/RemoteTarball tags see sibling paths, but the asymmetry is subtle enough that a maintainer familiar with the isolated store layout should confirm it.

Other factors

  • The kill-during-install test polls in a tight Bun.sleep(0) loop and SIGKILLs on the first directory entry; it has a documented reason for not being test.concurrent, and the assertion structure (either absent or complete, then a repair install yields the complete tree with no leftover siblings) is sound. expect(stderr).not.toContain("error") is weaker than ideal but paired with an exit-code and full-tree check.
  • The occupied-destination retry in hoisted install() calls uninstall_before_install a second time, which was previously conditional on skip_delete; the second call is unconditional, which is correct for the two-alias-workspace case the PR describes and is covered by a test.
  • CI so far shows one unrelated failure (test-http-chunk-problem.js) and the new test file passes on both debug+ASAN and release per the PR evidence block.
  • The comment-cop bot's flags were addressed in e67d0ba and the remaining comments are one-to-three lines each documenting non-obvious constraints (NAME_MAX aliases, workspace double-walk, NTFS occupied vs locked); the author's justification for keeping them is reasonable.

…place

Both linkers decide whether a package is already installed by looking at
the directory it was installed into: the hoisted linker reads
node_modules/<pkg>/package.json and the isolated linker checks that
node_modules/.bun/<store>/node_modules/<pkg>/package.json exists. Both
also built that directory in place, file by file, so an install killed
while linking left a directory that the next install accepted as complete.

Each backend now links into a dot-prefixed sibling (.<name>.bun-tmp, next
to the final directory) which is renamed onto the final path once every
file is in place, so the final path is either absent or complete. The
staging name is deterministic; an install removes whatever a previous
interrupted one left there before linking, and removes its own on failure.
On Windows the rename is retried briefly, since a scanner holding a freshly
written file open fails directory renames (the same failure #11250 reports
for the cache publish).

The isolated installer's cleanup on a failed task unlinked
node_modules/<storepath>, a path that does not exist in the store layout;
it now removes the staging directory instead.
Two cases from the full suite:

An alias can already be 255 bytes long (bun-install-registry's long path
test), so a staging name built by adding to it exceeds NAME_MAX. The staging
directory is now .bun-tmp-<hash of the package path>, next to the package as
before.

A workspace that is depended on under a second name is linked into
node_modules under both names, and the hoisted linker walks the packages
nested inside it once per name (migration/complex-workspace). With a fresh
node_modules nothing is moved away before installing, so the second walk's
rename found the first walk's directory and failed with ENOTEMPTY. It now
moves the occupant aside the way a reinstall does and renames again. The
Windows retry helper returns at once when the destination exists, since NTFS
reports that with the same errors as a transient lock.
@robobun
robobun force-pushed the farm/af8d4dad/install-stage-package-dirs branch from e67d0ba to e2740a7 Compare August 15, 2026 07:11

@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 reviewed this PR and didn't find any bugs. Because it changes the core install path for both linkers — every backend now writes to a staging sibling and renames into place, with new failure-cleanup and Windows retry logic — a human look would still be worthwhile before this ships in 1.4.

Checked: StagingPath hashing keeps names under NAME_MAX and stays a sibling of scoped packages; the hoisted install() wrapper cleans up the staging tree on both link failure and rename failure, and the occupied-destination fallback is guarded against .; the isolated 'backend loop's new break tail reaches commit_local_store_package from all three arms while the Folder/Root inline block still continues past it; append_store_package_path(.., Final) is byte-identical to the code it replaced in append_store_path; on_task_fail's dropped Folder arm was previously a no-op unlink on a directory path.

Extended reasoning...

Overview

This PR changes both install linkers to build each package in a dot-prefixed staging sibling (.bun-tmp-<wyhash>) and rename it onto the final path once complete, so an interrupted bun install never leaves a truncated directory that the next install's package.json check accepts as installed. It touches src/install/PackageInstall.rs (new StagingPath, rename_staging_into_place, Step::MovingIntoPlace; install() now wraps a new install_into() and every backend takes an explicit dest_subpath), src/install/isolated_install/Installer.rs (the LinkPackage step now clears both final and staging trees, all three backend arms break 'backend to a shared commit_local_store_package tail; on_task_fail rewritten to delete the staging tree; new append_store_package_path), a one-line comment in isolated_install.rs, and a new 237-line test file exercising interrupted installs, doubly-aliased workspaces, and 255-byte aliases across both linkers.

Security risks

None identified. The change is filesystem-local within node_modules and the isolated store; the staging name is a wyhash of the package path (no user-controlled component beyond the alias, which was already used verbatim as a directory name). No new external inputs are parsed and no trust boundaries are crossed.

Level of scrutiny

High. bun install is a production-critical path exercised by every user, and this PR changes its write strategy across every backend (clonefile, clonefile-each-dir, hardlink, copyfile, symlink) on every platform. The Windows retry loop in rename_staging_into_place (six attempts, 630ms total, gated on !directory_exists_at(dest)) is a heuristic a maintainer should sign off on. The occupied-destination fallback in the hoisted linker (a second uninstall_before_install when the rename fails) and the control-flow reshaping in the isolated step machine are both correct on my reading but subtle enough to deserve a second pair of eyes.

Other factors

The PR description is unusually thorough and the test file demonstrably fails on main and passes with the fix (the gate evidence shows both linkers leaving 37- and 1-entry truncated trees on main). CI is green except for test-http-chunk-problem.js, which the author confirms also fails on main and is unrelated. The comment-cop bot flagged several multi-line comments; the author shortened most and justified the remainder as recording non-obvious constraints (all threads resolved). Nothing outstanding blocks review, but the scope and criticality put this beyond what I'd approve without a human maintainer's sign-off.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants