install: fail the isolated install when a link: dependency's target is missing - #38045
install: fail the isolated install when a link: dependency's target is missing#38045robobun wants to merge 5 commits into
Conversation
…s missing With the isolated linker, a store entry for a link: resolution was marked done without looking at the filesystem, and the dependents then symlinked node_modules/<name> at the global link dir entry whether or not it existed. Installing a lockfile whose linked package had since been unlinked or deleted produced a dangling symlink and exit code 0, while the hoisted linker reports the failure and exits 1. Open the link target as a directory when the entry is processed, exactly as the hoisted linker's install_from_link does, and route a failure through on_task_fail so the install reports the package and exits 1.
|
Warning Review limit reached
Next review available in: 16 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: ready for review. Reproduced on the released build (1.4.0-canary da3851e): With this branch the same sequence prints Test: |
There was a problem hiding this comment.
LGTM — small, well-scoped parity fix bringing the isolated linker in line with hoisted for missing link: targets.
What was reviewed:
- Verified
global_link_dir_pathis ensured on the main thread (isolated_install.rs:2080-2087) before the newappend_store_pathcall reaches the Symlink arm'sdebug_assert. - Confirmed
on_task_faildecrements pending tasks and handlesResolutionTag::Symlinkas a no-op for store cleanup, so the install loop terminates and nothing spurious is deleted. - Checked the test isolates
BUN_INSTALLper case, drains stdout/stderr concurrently, and covers bothbun unlinkand directory-deleted (dangling registration) with plain and scoped names.
Extended reasoning...
Overview
This PR touches two files: a ~22-line change to the ResolutionTag::Symlink arm of install_isolated_packages in src/install/isolated_install.rs, and a new describe block in test/cli/install/isolated-install.test.ts. Previously, symlink (link:) entries were marked Done and completed as Skipped unconditionally. Now the target path is built via the existing append_store_path helper and probed with sys::openat(O_RDONLY | O_DIRECTORY) — the same call the hoisted linker's install_from_link uses. On success the fd is closed and the entry completes as before; on failure the entry is routed through on_task_fail(TaskError::LinkPackage(err)).
Security risks
None. The path being opened is derived from the lockfile's Resolution.symlink name joined onto the global link directory — the same path the isolated linker already symlinks dependents to today, and the same path the hoisted linker already opens. No new untrusted input is introduced; this is purely an existence probe that turns a silent success into a reported failure.
Level of scrutiny
Medium-low. The change is confined to one match arm on the main thread, reuses existing helpers (append_store_path, on_task_fail, TaskError::LinkPackage, AbsPath::init_top_level_dir, sys::openat), and follows the file's local conventions (use bun_sys::FdExt as _; matches lines 1719/1917; AbsPath::init_top_level_dir() matches line 2227). I traced the preconditions: global_link_dir_path is lazily ensured at lines 2080-2087 before this loop begins, so the debug_assert!(!symlink_dir_path.is_empty()) inside append_store_path's Symlink branch cannot trip. on_task_fail calls decrement_pending_tasks() + resume_unblocked_tasks(), so the pending-task accounting stays balanced whether the openat succeeds or fails. The step is stored as Done before the probe, so dependents' is_task_blocked checks are unaffected on the failure path (matching the PR description's stated intent that dependents still create their symlink and the next install retries).
Other factors
The test coverage is thorough: test.concurrent.each over two removal modes (bun unlink removing the registration entry; rm -rf leaving it dangling) × plain and scoped names, each asserting the success-then-fail sequence for both reinstall-over-existing and fresh-install-from-lockfile. Each case gets its own tempDir and per-test BUN_INSTALL, so concurrent runs don't touch the real global link dir or each other. The runBun helper drains stdout/stderr/exited concurrently. Assertions check for the specific error text (ENOENT, failed to link package: <name>@link:, Failed to install 1 package) and exit code 1. The PR description states USE_SYSTEM_BUN=1 fails and bun bd test passes. No outstanding reviewer comments; the only timeline entry is a CodeRabbit rate-limit notice.
|
Updated 5:05 AM PT - Aug 13th, 2026
❌ @robobun, your commit 5dbf7e1 has some failures in 🧪 To try this PR locally: bunx bun-pr 38045That installs a local version of the PR into your bun-38045 --bun |
There was a problem hiding this comment.
LGTM — focused parity fix bringing the isolated linker in line with the hoisted linker for missing link: targets.
Reviewed the ResolutionTag::Symlink arm change: append_store_path clears the buffer and builds the absolute global-link-dir path, global_link_dir_path is ensured on the main thread before this loop runs, and on_task_fail correctly no-ops the store-deletion step for Symlink entries while still decrementing pending tasks (so the install loop terminates). The openat(O_RDONLY | O_DIRECTORY) matches PackageInstall::install_from_link exactly. Tests are hermetic (per-test BUN_INSTALL), drain stdout/stderr concurrently, and cover both unlink and dangling-directory cases with scoped and unscoped names.
Extended reasoning...
Overview
This PR fixes a silent-success bug in the isolated linker: when a link:<name> dependency in the lockfile no longer has a valid bun link registration (unlinked or directory deleted), bun install with linker = "isolated" would exit 0 and leave a dangling node_modules/<name> symlink. The hoisted linker already fails this case with exit 1.
The fix adds ~19 lines to the ResolutionTag::Symlink arm of the main-thread install loop in src/install/isolated_install.rs: before marking the entry done, it builds the same target path the dependents will symlink to (via append_store_path) and opens it with O_RDONLY | O_DIRECTORY. On failure it routes through the existing on_task_fail(TaskError::LinkPackage(err)) handler. A new describe("link: dependencies") block in test/cli/install/isolated-install.test.ts covers both removal modes.
Security risks
None. This adds a read-only openat on a path derived from the lockfile and the global link directory, both of which are already trusted inputs. No new attack surface.
Level of scrutiny
Medium. The package manager install path is production-critical, but this change is narrow: it only affects link: dependencies under the isolated linker, and only converts a silent-success into the same failure the hoisted linker already produces. I traced the surrounding invariants:
global_link_dir_pathis lazily ensured earlier ininstall_isolated_packages(line ~2084) before any entry is processed, so thedebug_assert!(!symlink_dir_path.is_empty())inappend_store_pathholds.append_store_pathforSymlinkcallsbuf.clear()first, so theinit_top_level_dir()seed is irrelevant — the result is the absolute global-link path.on_task_failexplicitly handlesResolutionTag::Symlinkas a no-op in the store-cleanup match, incrementssummary.fail, and callsdecrement_pending_tasks()+resume_unblocked_tasks(). Since pending tasks are incremented upfront bystore.entries.len(), the loop still terminates on the failure branch.- The
openatflags matchPackageInstall::install_from_linkatsrc/install/PackageInstall.rs:2104exactly, so both linkers fail on the same set of states (missing, dangling symlink/junction, not-a-directory).
Other factors
- Tests follow REVIEW.md conventions:
test.concurrent.each, per-testBUN_INSTALLfor hermeticity,Promise.allon stdout/stderr/exited, stderr assertions before exit-code assertions,tempDirfrom harness. The PR states both cases fail withUSE_SYSTEM_BUN=1and pass withbun bd test. - No CODEOWNERS entry covers
src/install/. - The comment-cop bot flagged comment length twice; both threads are resolved and the final comment is one line pointing at the hoisted counterpart.
- No design decision here — this is bringing one linker into parity with the other on an unambiguous failure case.
Problem
linker = "isolated",bun installof alink:<name>dependency whosebun linkregistration no longer exists (the package wasbun unlinked, or its directory was deleted) creates a danglingnode_modules/<name>symlink, printsDone! Checked N packages (no changes)and exits 0. The hoisted linker fails the same state withFileNotFound: failed linking dependency/workspace to node_modules for package <name>and exits 1.Package "<name>" is not linked). That is the common case: a clone withbun.lockchecked in installs green with a brokennode_modules.src/install/isolated_install.rsmarksResolutionTag::Symlinkstore entriesDonewithout touching the filesystem ("no installation required"). Thenode_modules/<name>link is created later by each dependent'sSymlinkDependenciesstep (isolated_install/Installer.rs,append_store_path), which builds the target path and symlinks to it without checking that it exists. Nothing in the isolated path ever opens the link target, so there is nothing to fail.Fix
Symlinkarm of the main-thread install loop, build the entry's target path withappend_store_path(the same path the dependents will point their symlinks at) andopenat(O_RDONLY | O_DIRECTORY)it. On success the entry completes as before; on failure it goes throughon_task_fail(TaskError::LinkPackage(err)), which prints the error, counts the entry insummary.fail(Failed to install 1 package, exit 1) and lets the install continue for the other entries.openat(O_RDONLY | O_DIRECTORY)is the exact callPackageInstall::install_from_linkmakes for the hoisted linker, so the two linkers now fail on the same set of states (missing entry, dangling symlink or junction, entry that is a file, unreadable entry) with the same errno. It follows symlinks and junctions, so a registration left dangling by deleting the package directory fails too, not just a removed one.link:package, so it is the only layer that can fail the install for it. Checking in each dependent'sSymlinkDependenciesstep instead would attribute the error to the dependent and, for the root entry, abort linking of all its other dependencies.append_store_pathfrominit_top_level_dir()checks exactly the path the dependents link to rather than a second hand-built one, so it stays correct if the Symlink target computation changes (for example path-formlink:targets in install: support path-form link: dependencies #35461).link:package on the main thread, which already does anexistsprobe per npm entry in the same loop.node_modulesend state: the dependents still create (or keep) theirnode_modules/<name>symlink after the failure, which is how every failed isolated entry is handled today (the install reports the failure and exits 1, the next install retries; hoisted differs here becauseinstall_from_linkremoves the old entry first).bun linking the package again makes the next install succeed without removingnode_modules. Removing dependents' links on failure would be a change toon_task_fail/SymlinkDependenciesfor every failure kind, not something specific tolink:.test/cli/install/isolated-install.test.ts(link: dependencies): two cases, a plain name removed withbun unlinkand a scoped name whose directory was deleted. Each case first installs successfully, then asserts that reinstalling over the existingnode_modulesand installing into a fresh one both printENOENT+failed to link package: <name>@link:...,Failed to install 1 package, and exit 1. Thebun linkregistration is pointed at a per-testBUN_INSTALLso the tests do not touch the real global link dir.USE_SYSTEM_BUN=1): both installs exit 0 with empty stderr.bun bd test.Background
link:<name>dependency:bun linkrun inside a package registers it by creating<global dir>/node_modules/<name>(a symlink on POSIX, a junction on Windows) pointing at the package directory. A consumer declaring"<name>": "link:<name>"gets anode_modules/<name>symlink to that registration. The lockfile stores only the name (Resolution.symlink), so on later installs nothing re-reads the linked package.install_isolated_packagescreates one store entry per package in the dependency graph and, on the main thread, either starts a worker task for it (npm, git, tarball, folder: materialize intonode_modules/.bun/<store path>) or completes it immediately. Each entry's task then runsSymlinkDependencies, which symlinks its dependencies' store paths into its ownnode_modules; for the root entry that is the project'snode_modules/<dep>.on_task_failis the shared failure path for an entry: it prints theTaskError, increments the install summary'sfailcounter (which drives theFailed to install N packagesline and the exit code) and releases the entry's pending-task slot so the install loop can finish.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/isolated-install.test.ts