Skip to content

install: keep linking the remaining bins when one bin or one dependency's bins fail to link - #38972

Open
robobun wants to merge 5 commits into
mainfrom
farm/6af69c2b/isolated-link-sibling-bins
Open

install: keep linking the remaining bins when one bin or one dependency's bins fail to link#38972
robobun wants to merge 5 commits into
mainfrom
farm/6af69c2b/isolated-link-sibling-bins

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

Found by reading the bin linking code while fixing #38954; there is no user report. Two places stop linking bins after the first failure and take the bins that come after it down with the failing one:

  • Isolated linker, across dependencies: Installer::link_dependency_bins (src/install/isolated_install/Installer.rs) runs one bin::Linker per dependency of an entry and returned on the first linker error, so every dependency after the failing one got no link in that entry's node_modules/.bin. In the test below the project ends up with a-cli only and the workspace with no .bin at all. The error was reported as ENOTDIR: failed to link binaries for package: root@, naming the parent and not the dependency whose bins failed. The hoisted linker reports the bad package and links the rest (PackageInstaller::link_tree_bins).
  • Both linkers, within one package: bin::Linker::err (src/install/bin.rs) is the error reported for the package, and link_bin_or_create_shim also used it, after every bin, to decide whether the link it just created should be kept. Once one bin of a bin map or directories.bin failed, each later bin of that package was created, skipped by the chmod, and unlinked again, and on a reinstall a bin that had been linked correctly before was deleted. A package with bin: {<bad>, multi-b, multi-c} links nothing with either linker.

Fix

  • link_dependency_bins attempts every dependency and collects the failures. If there were any it returns the new TaskError::DependencyBinaries, one (dependency entry, error) per failed dependency, and on_task_fail prints one line per entry: ENOTDIR: failed to link binaries of dependency bad-bin-dir-1@deps/bad-bin-dir-1 for package: root@. Continuing the loop is safe: the linker restores node_modules_path after appending to it, the path buffers are rebuilt by every link() call, and the seen map is per .bin directory on purpose. A failure of the .bin directory itself (say EACCES) is now reported once per dependency that has bins, which is what the hoisted linker prints too.
  • The entry's task still fails after the loop, as it did before, so the isolated model is unchanged: an entry whose .bin is incomplete is not committed to the global store (its staging directory is removed in on_task_fail), the error shows up again on the next install instead of being hidden by the warm-hit path, and the install exits 1. Only the aggregation and the attribution change. symlink_dependencies (the node_modules/<dep> links) keeps failing on the first error: a package missing one of its dependencies is broken either way, and retrying the rest would not make the entry usable.
  • link_bin_or_create_shim takes err out of the linker before creating the link and merges it back afterwards (the first error of the package is what stays reported), so the unlink and the chmod only look at the bin that was just attempted (on Windows this scope includes opening the target, whose failure is the other error path of the same bin). A bin that failed is also removed from seen and its destination unlinked, so a later package providing the same name, or the retry without the native bin link, can still create it. The two isolated retries reset err and skipped_due_to_missing_bin before calling link() again, which is what the hoisted linker already does by building a fresh linker for the retry; without the reset the first attempt's error would now survive a successful retry.
  • Not changed: the two returns in the bin map and directories.bin loops for a bin name longer than the path buffer (several KiB); those lines are being reworked by install: stop panicking on bin values longer than the path buffer #38954.
  • Tests:
    • test/cli/install/isolated-install.test.ts: a root depending on a-bin, two packages whose directories.bin points at a file, multi-bin (first bin name longer than a file name may be, then multi-b and multi-c) and z-bin, plus a workspace depending on one bad package and z-bin. Asserts the full .bin listings of the root, the workspace and multi-bin's own store entry, the seven error lines, and exit code 1. On the released build the root has a-cli only and the other two listings are empty.
    • test/cli/install/bun-install-registry.test.ts ("binaries"): the same multi-bin package with the hoisted linker links multi-b and multi-c, still reports Failed to link multi-bin, and exits 1. On the released build .bin is empty.
    • Both pass with this change on Linux and on Windows (where the over-long name fails with ENOENT instead of ENAMETOOLONG); the binaries block of the registry tests, isolated-relink.test.ts and bun-install-native-binlink.test.ts (the retry paths) pass with the debug build.

Background

  • Isolated linker layout: every package gets a store entry under node_modules/.bun/<name>@<version>/node_modules/, and its dependencies are symlinked into that directory. The root project and each workspace are entries too; their store node_modules is their own node_modules.
  • Each entry is installed by a Task walking Steps on the thread pool. Step::SymlinkDependencyBinaries links the bins of the entry's dependencies into the entry's node_modules/.bin (for the root entry, the project's node_modules/.bin); Step::Binaries links the entry's own bins into the same directory. A step that fails returns a TaskError; the main thread prints it in on_task_fail, counts the entry as failed, and the entry's later steps (lifecycle scripts, committing a global-store entry) do not run.
  • bin::Linker is the bin linker shared by both installers, bun link and bun unlink. One instance links one package's bins (a single file, a bin map, or every file of directories.bin) into one .bin directory; callers read its err field once afterwards. seen is the set of .bin names already claimed in that directory, so the first package providing a name wins, like npm. directories.bin pointing at a missing directory is skipped silently (npm does the same); any other failure to open it is an error.
  • Native bin link: for packages such as esbuild the first attempt links the bin straight to the platform package; if that attempt skipped or failed, the same linker is run again against the package itself.
Repro on the released build (bun 1.4.0-canary.1)
deps/a-bin         bin: { "a-cli": "cli.js" }
deps/bad-bin-dir-1 directories: { bin: "package.json" }   (a file: opening it fails with ENOTDIR)
deps/bad-bin-dir-2 directories: { bin: "package.json" }
deps/multi-bin     bin: { "<300 x a>": "cli.js", "multi-b": "cli.js", "multi-c": "cli.js" }
deps/z-bin         bin: { "z-cli": "cli.js" }
package.json       depends on all five as file: deps, workspaces: ["packages/*"]
packages/ws        depends on bad-bin-dir-1 and z-bin

$ bun install --linker=isolated
ENOTDIR: failed to link binaries for package: bad-bin-dir-1@deps/bad-bin-dir-1
ENOTDIR: failed to link binaries for package: bad-bin-dir-2@deps/bad-bin-dir-2
ENAMETOOLONG: failed to link binaries for package: multi-bin@deps/multi-bin
ENOTDIR: failed to link binaries for package: ws@workspace:packages/ws
ENOTDIR: failed to link binaries for package: root@
$ ls node_modules/.bin
a-cli
$ ls packages/ws/node_modules/.bin
ls: cannot access 'packages/ws/node_modules/.bin': No such file or directory

$ bun install --linker=hoisted      (multi-bin alone)
error: Failed to link multi-bin: ENAMETOOLONG
$ ls node_modules/.bin
(empty)

With this change:

$ bun install --linker=isolated
ENOTDIR: failed to link binaries for package: bad-bin-dir-1@deps/bad-bin-dir-1
ENOTDIR: failed to link binaries for package: bad-bin-dir-2@deps/bad-bin-dir-2
ENAMETOOLONG: failed to link binaries for package: multi-bin@deps/multi-bin
ENOTDIR: failed to link binaries of dependency bad-bin-dir-1@deps/bad-bin-dir-1 for package: ws@workspace:packages/ws
ENOTDIR: failed to link binaries of dependency bad-bin-dir-1@deps/bad-bin-dir-1 for package: root@
ENOTDIR: failed to link binaries of dependency bad-bin-dir-2@deps/bad-bin-dir-2 for package: root@
ENAMETOOLONG: failed to link binaries of dependency multi-bin@deps/multi-bin for package: root@
$ ls node_modules/.bin
a-cli  multi-b  multi-c  z-cli
$ ls packages/ws/node_modules/.bin
z-cli

$ bun install --linker=hoisted      (multi-bin alone)
error: Failed to link multi-bin: ENAMETOOLONG
$ ls node_modules/.bin
multi-b  multi-c

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install-registry.test.ts test/cli/install/isolated-install.test.ts

…bins fail with the isolated linker

link_dependency_bins returned on the first bin::Linker error, so every
dependency after the failing one was left out of the entry's
node_modules/.bin. Attempt every dependency, then fail the task with one
error per dependency that could not be linked, naming that dependency.
@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: 06ab55fe-4bf1-41a3-aff1-95e11881a694

📥 Commits

Reviewing files that changed from the base of the PR and between 8437683 and a7b43f2.

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

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed (latest a7b43f2), CI pending.

Reproduced on the released build (1.4.0-canary.1) with the two tests in this PR:

  • test/cli/install/isolated-install.test.ts ("a bin that fails to link does not stop the remaining bins of the package or of its siblings from being linked"): with the isolated linker the project's .bin ends up with a-cli only, the workspace gets no .bin, and multi-bin's own .bin is empty.
  • test/cli/install/bun-install-registry.test.ts ("binaries > a bin that fails to link does not stop the remaining bins of the package from being linked"): with the hoisted linker a package whose first bin fails links none of its bins.

Both pass with this branch on Linux and Windows: every linkable bin is linked, each failing package is still reported (by name, per .bin it could not be linked into), and the install still exits 1.

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

LGTM — focused error-aggregation change that mirrors the hoisted linker's continue-on-error behavior; the inline note is a test-hygiene nit, not a correctness issue.

Extended reasoning...

Overview

This PR changes Installer::link_dependency_bins in src/install/isolated_install/Installer.rs so that when linking one dependency's bins into an entry's node_modules/.bin fails, the loop continues through the remaining dependencies instead of returning early. Failures are collected into a new TaskError::DependencyBinaries(Box<[DependencyBinariesError]>) variant, which on_task_fail prints one line per failed dependency, attributed to both the dependency and the parent entry. The step still fails overall (exit code 1, staging dir removed for global-store entries), so the isolated model's invariants are preserved — only aggregation and attribution change. A test in test/cli/install/isolated-install.test.ts covers the root and a workspace, asserts the sibling bins are linked, the five expected error lines, and exit code 1.

Security risks

None. This is error-handling control flow in the local package installer; no untrusted input parsing, no auth/crypto/network surface is touched.

Level of scrutiny

Moderate. It's a small, self-contained change to an error path in the isolated installer. I verified: the only caller of link_dependency_bins is Step::SymlinkDependencyBinaries (updated to pass the TaskError through directly); both match sites on TaskError (on_task_fail and TaskError::clone) handle the new variant; DependencyBinariesError is Copy so Box<[_]>::clone() works; the per-iteration state (node_modules_path, path buffers, seen) is safe to carry across a failed iteration per the same pattern already used in the hoisted PackageInstaller::link_tree_bins. The new error message follows the existing Output::err format used by sibling arms.

Other factors

The one finding is a test-hygiene nit (stdout: "pipe" not drained) — with 4 file: deps + 1 workspace the install summary is far under 64KB, so it can't deadlock in practice; switching to stdout: "ignore" would satisfy the convention. The test handles Windows (.bunx extension, path-separator normalization on stderr). I did not independently verify that Windows surfaces the open-file-as-directory failure as ENOTDIR specifically — CI will catch that if it differs, and it wouldn't affect the runtime fix either way. The PR description confirms the test fails on the released build and passes with the change, and that the related isolated-install test files pass.

Comment thread test/cli/install/isolated-install.test.ts Outdated
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 AM PT - Aug 15th, 2026

@robobun, your commit a7b43f2 has some failures in Build #98289 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38972

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

bun-38972 --bun

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
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Review round addressed: the test now drains stdout as well (fee48c9), and the doc comments flagged on the new error types are removed (ba91fc5). No behavior change since the first push; the two red tests in the first CI run (test/bake/deinitialization.test.ts on Windows, test/regression/issue/09041.test.ts under ASAN) are unrelated to the install code touched here and have been reported separately.

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

LGTM — the earlier stdout-drain nit and the comment-cop flags are addressed, and this pass found nothing new.

What was reviewed:

  • link_dependency_bins loop-continuation safety: each iteration builds a fresh bin::Linker with err: None and rebuilds both path buffers; seen is meant to persist across deps; node_modules_path is only read by build_destination_dir, so no per-iteration state leaks after a failure.
  • New TaskError::DependencyBinaries is wired into both on_task_fail (per-dep attribution via entry_node_idsnode_pkg_ids) and clone_for_owner; the task still fails, so staging cleanup and exit-1 semantics are unchanged.
  • Test cross-platform shape: Windows open_a with O::DIRECTORY routes through NtCreateFile+FILE_DIRECTORY_FILE, which maps to ENOTDIR; the test already handles .bunx and normalizes path separators, and sorts stderr lines for parallel task ordering.
Extended reasoning...

Overview

Two files: src/install/isolated_install/Installer.rs changes link_dependency_bins to collect per-dependency bin-link failures into a Vec and continue the loop instead of return Err on the first one, adds a DependencyBinariesError struct and a TaskError::DependencyBinaries(Box<[..]>) variant, handles it in on_task_fail (one line per failed dep, naming both the dep and the parent) and in clone_for_owner, and has Step::SymlinkDependencyBinaries propagate the TaskError directly rather than re-wrapping as TaskError::Binaries. test/cli/install/isolated-install.test.ts gains a test with a-bin / two bad directories.bin packages / z-bin at the root plus a workspace, asserting the sibling bins land in both .bin dirs, all five error lines appear, and exit code is 1.

Security risks

None. This is error aggregation in the isolated-linker bin step; no new inputs are trusted, no new filesystem writes are introduced, and the failure still fails the task and prevents committing an incomplete global-store entry.

Level of scrutiny

Medium-low. The behavioral change is narrow (early-return → collect-and-continue in one loop) and mirrors the hoisted linker's link_tree_bins, which already logs and continues. I traced bin::Linker::link for the Tag::Dir arm to confirm continuing is safe: the linker is constructed fresh each iteration with err: None, build_target_package_dir/build_destination_dir rewrite abs_target_buf/abs_dest_buf from scratch, and node_modules_path is only read (not appended to) by build_destination_dir, so a failed iteration leaves nothing dirty for the next. The shared seen map is intentionally per-.bin-directory state and should carry across deps.

Other factors

  • All prior review threads are resolved: my stdout-drain nit was fixed in fee48c9 (the diff now shows Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited])), and the three comment-cop doc-comment flags were addressed in ba91fc5 (the diff has no doc comments on the new types).
  • I checked the Windows path for the test's ENOTDIR assertion: open_dir_absoluteopen_a with O::DIRECTORY routes to the NtCreateFile path with FILE_DIRECTORY_FILE (src/sys/lib.rs:6698), which fails on a file and maps to ENOTDIR; the test already branches on isWindows for the .bunx extension and normalizes backslashes, and sorts the error lines to tolerate parallel task ordering.
  • clone_for_owner is updated for the new variant (errs.clone() on a Box<[DependencyBinariesError]> where the element is Copy), so the match stays exhaustive.
  • The test brackets the bad packages between a-bin and z-bin so it exercises deps both before and after the failing ones, and checks a workspace whose first dep is bad to cover the "nothing linked at all" case from the repro.

…s bins fails

bin::Linker::err is reported once per package and was also what decided,
after every bin, whether the link just created is kept: once one bin of a
bin map or directories.bin failed, each later bin was created, left
without the executable bit and unlinked again, and a previously linked
bin was deleted on the next install. Scope the cleanup to the bin that
failed and keep the first error for reporting. A bin that failed is also
dropped from the seen set so a later package providing the same name, or
the retry without the native bin link, can still create it; the isolated
retries start from a clean err.
Comment thread src/install/bin.rs Outdated

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

Beyond the inline nit, I also checked: the entry_node_ids[dep_err.dep_entry_id] lookup in the new on_task_fail arm uses the same indexing pattern as link_dependency_bins at line ~2374 where dep.entry_id originates, so no OOB there; and the two new bin_linker.err = None resets (retry-after-native-redirect at ~1881 and the per-dependency loop at ~2448) are both required now that a stale err would otherwise survive into the next link() call — the prior_err.or(err) split in link_bin_or_create_shim and these resets are consistent with each other. Note the comment-cop bot flag on bin.rs:835 is still unresolved.

Extended reasoning...

Verified the new TaskError::DependencyBinaries handling in on_task_fail indexes entry_node_ids / node_pkg_ids with dep_entry_id exactly as link_dependency_bins already does when it iterates entry_deps[parent_entry_id].slice(), so the stored ids are valid indices. Also traced the two added bin_linker.err = None; skipped_due_to_missing_bin = false resets: without them, the loop's continuation past a failure (or the native-binlink retry) would carry a stale Some(err) into the next link(), which the new prior_err.or(err) in link_bin_or_create_shim would then keep as the "first" error and re-push into failed for every subsequent dependency — the resets prevent that. The Windows openat early-return gap is covered by the inline nit; nothing further to add.

Comment thread src/install/bin.rs Outdated
@robobun robobun changed the title install: link the remaining dependencies' bins when one dependency's bins fail with the isolated linker install: keep linking the remaining bins when one bin or one dependency's bins fail to link Aug 15, 2026
@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Second review round, after a closer look at the shared linker: the same first-failure problem also existed one level down in bin::Linker (a failing bin of a bin map or directories.bin caused every later bin of that package to be created and unlinked again, with both linkers), so 2aa0cdc scopes the cleanup in link_bin_or_create_shim to the bin that was just attempted and resets the isolated retries, and a7b43f2 moves the Windows target open into the same scope. The isolated test now also covers a multi-bin package and asserts the full .bin listings, and there is a hoisted-linker test for the same package in bun-install-registry.test.ts. Both were checked on Linux and Windows; the PR description is rewritten to match.

@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 again after a7b43f2 and didn't find any bugs — that commit addresses the Windows openat early-return I flagged (it now falls through to the shared prior_err.or(err) / seen.remove cleanup), and also drops the doc comment that comment-cop flagged on bin.rs. Because 2aa0cdc widened the scope from the isolated linker into bin::Linker::link_bin_or_create_shim itself — first-error-wins across a package's bins, seen rollback on failure, and a restructured Windows shim path that every caller (hoisted included) goes through — a human look at that shared piece is still worthwhile.

What was reviewed:

  • prior_err.take() / prior_err.or(err) scoping — verified the cleanup keys off this bin's err while the reported error stays first-wins, and that the Windows EISDIR fall-through matches the old early-return behavior.
  • The two isolated retry sites now reset err/skipped_due_to_missing_bin, matching the hoisted retry which reconstructs the linker fresh via continue.
  • The 300-byte bin name in the new tests hits the syscall (ENAMETOOLONG/ENOENT) inside link_bin_or_create_shim, not the abs_dest_buf overflow guard in Tag::Map, so it exercises the new per-bin scope.
Extended reasoning...

Overview

The PR now touches four files across two behavior changes: (1) link_dependency_bins in the isolated installer collects per-dependency bin-link failures into a new TaskError::DependencyBinaries instead of returning on the first one, with a new on_task_fail arm and sound_clone case; (2) bin::Linker::link_bin_or_create_shim in bin.rs — shared by both the hoisted and isolated linkers — now scopes its error-cleanup to the current bin (prior_err = self.err.take()self.err = prior_err.or(err)), removes the failed dest from seen, and moves the Windows target openat inside that scope so its error path gets the same treatment. Two new tests cover the isolated-linker sibling-dependency case and the hoisted-linker sibling-bins-within-one-package case.

Security risks

None identified. The change is confined to error aggregation and per-bin cleanup ordering; no new inputs are trusted, no path validation is relaxed, and seen.remove on failure only re-opens a slot that was never actually populated on disk.

Level of scrutiny

Medium-high. The isolated-installer half is straightforward (collect-then-report, mirroring PackageInstaller::link_tree_bins). The bin.rs half is subtler: it changes error semantics for every caller, restructures a #[cfg(windows)] block that CI on the latest commit hasn't finished on yet, and interacts with the native-binlink retry (which now explicitly resets err/skipped_due_to_missing_bin at both isolated call sites). I traced the EISDIR-on-Windows fall-through, the chmod_on_ok/try_normalize_shebang gating (both key off this bin's err, not the carried prior), and the Tag::Map ENAMETOOLONG buffer guard (unchanged early-return, but the test's 300-byte name doesn't reach it) and didn't find a regression.

Other factors

The scope grew mid-review — 2aa0cdc added the bin.rs per-bin continuation on top of the original isolated-linker fix, and a7b43f2 reshaped the Windows path in response to feedback. All prior review threads (stdout drain, doc-comment cop ×3, Windows openat) are addressed in the current head; the one remaining unresolved comment-cop on bin.rs is stale against 2aa0cdc's since-removed doc comment. Tests look solid (both linkers covered, Windows-branched expectations, exit code asserted last), but CI on a7b43f2 is still pending. Given the shared-code reach and the Windows restructuring, deferring to a human reviewer rather than auto-approving.

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.

1 participant