Skip to content

install: resolve file: and link: dependencies on the same directory as separate packages - #38114

Open
robobun wants to merge 1 commit into
mainfrom
farm/a667644f/folder-resolution-link-kind
Open

install: resolve file: and link: dependencies on the same directory as separate packages#38114
robobun wants to merge 1 commit into
mainfrom
farm/a667644f/folder-resolution-link-kind

Conversation

@robobun

@robobun robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • One install that references the same directory as both "a": "link:../shared" and "z": "file:../shared" records whichever alias resolves first for both of them. With link: first, z is written to bun.lock as shared@link:../shared and its dependencies are dropped, so nothing gets installed for the file: consumer. With file: first, the link: consumer is written as shared@file:../shared and installed as a folder copy (exit 0, so this direction is silent).
  • Cause: folder_resolver::get_or_put (src/install/resolvers/folder_resolver.rs) caches resolutions in PackageManager.folders keyed only by the hash of <dir>/package.json. The key does not record how the package.json was read: file: parses it with Features::FOLDER and stores a Folder resolution, link: parses it with Features::LINK (no dependencies) and stores a Symlink resolution. A link: value starting with . is resolved against the project root (normalize_package_json_path), so link:../shared and file:../shared produce the same key and the second lookup is served the first one's package.
  • Reproduces on bun 1.4.0 in both alias orders.

Fix

  • The map key becomes folder_resolver::Key { kind, abs_hash }. Kind is derived from the GlobalOrRelative variant, i.e. from the resolver get_or_put is about to run: Folder (file: folders and workspace members), Link (link: targets), CacheFolder (npm packages read back from the cache). Same directory, different kind: separate entry. The abs_path comparison that guards same-kind hash collisions (install: compare stored scope name and folder path, not just their hash #32745) is unchanged.
  • Folders and workspaces deliberately stay one kind: both install the directory's dependencies, and a file: pointing at a workspace member, or at the root package that PackageManager::init seeds as PackageId(0), is meant to resolve to that existing package (isolated-install.test.ts "can install folder dependencies on root package" covers the seed). The seed is now inserted under Kind::Folder.
  • Why this is right: the cache exists so one package.json is not parsed twice into the same package. Two lookups that parse it with different Features and store a different Resolution do not produce the same package, so they must not share an entry. Once both consumers reach their own parse, Lockfile::get_package_id also keeps the two packages apart, because a Folder and a Symlink resolution never compare equal.
  • GlobalOrRelative::kind has an unreachable!() arm for Relative tags get_or_put cannot resolve today, so a future variant (for example the project-relative link: form in install: support path-form link: dependencies #35461) has to pick a kind explicitly instead of silently sharing with folders.
  • The map is in-memory only, so nothing about the lockfile format changes. A bun.lock that already holds a merged entry written by an older version is kept like any other pinned resolution until that dependency line changes, is passed to bun update, or the lockfile is removed; the fix takes effect whenever the dependency is actually resolved.
  • Verified with test/cli/install/bun-install.test.ts ("resolves file: and link: to the same directory as separate packages"), one case per alias order. The tests run bun install --lockfile-only and compare the packages section of bun.lock: on the released binary the file: alias comes back as shared@link:../shared with no extra entry (or the link: alias as shared@file:../shared with one); with this change each alias gets its own package and only the file: one has extra. --lockfile-only is used because materializing a link:../x target is a separate, pre-existing limitation that would otherwise decide the exit code.
  • Also run locally against the debug build: the file: tests in bun-install.test.ts (14, including the abs-path hash collision test), bun-workspaces.test.ts (68), the folder dependency tests in isolated-install.test.ts, and bun-link.test.ts (3 of 4; "should link dependency without crashing" fails on debug builds regardless of this change because the debug-only install-failure trace ends up on stdout, which is tracked separately). cargo clippy -p bun_install is clean.

Background

  • Folder resolution map (PackageManager.folders): during resolution, every file:, workspace and link: dependency becomes a lockfile package by reading the target directory's package.json. The map remembers, per package.json path, which package that produced, so later dependencies on the same directory reuse it instead of appending a duplicate.
  • Features: the options Package::parse uses when reading a package.json. FOLDER/WORKSPACE read the dependency sections so those get installed; LINK reads none of them, because a link: dependency is only a symlink to a directory the user manages themselves.
  • Resolution: the lockfile's record of where a package comes from (Folder, Symlink, Workspace, Npm, ...). The installer decides how to materialize a package from this tag, which is why a link: consumer handed a Folder package ends up with a copy instead of a symlink.
  • GlobalOrRelative: the argument that selects the resolver inside get_or_put: Global for link: (joined onto the global link dir unless the value starts with .), Relative(Folder | Workspace) for file: and workspaces, CacheFolder for the offline auto-install path.

…t its path

The folder-resolution map was keyed only by the hash of the target's
package.json path. A link: dependency parses that file with Features::LINK
and stores a Symlink resolution, a file: dependency parses it with
Features::FOLDER and stores a Folder resolution, so when one install
referenced the same directory both ways, whichever resolved first was
handed out for the other: the file: consumer lost its dependencies, or the
link: consumer was recorded and installed as a folder.

Add the parse kind (folder/workspace, link, cache folder) to the key so the
same directory gets one entry per kind. Folders and workspaces keep sharing
an entry, including the root package seeded by PackageManager::init.
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:46 AM PT - Aug 13th, 2026

@robobun, your commit ba749637d12381bae798f5ee2593010aed7a8044 passed in Build #94402! 🎉


🧪   To try this PR locally:

bunx bun-pr 38114

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

bun-38114 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on bun 1.4.0 with a project declaring both link:../shared and file:../shared (both alias orders; the second alias to resolve gets the first one's package in bun.lock). Fix and tests are in this PR; both new tests fail on the released binary and pass with the change. Waiting on CI.

@coderabbitai

coderabbitai Bot commented Aug 13, 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: 4 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: 35a6a1cf-82c2-42a1-82bf-419b1a179da3

📥 Commits

Reviewing files that changed from the base of the PR and between b7a0431 and ba74963.

📒 Files selected for processing (3)
  • src/install/PackageManager.rs
  • src/install/resolvers/folder_resolver.rs
  • test/cli/install/bun-install.test.ts

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 reviewed this PR and didn't find any bugs. The cache-key widening is the canonical fix for the bug class and every .folders accessor and GlobalOrRelative caller lines up with the new Kind mapping. Because it changes package-manager resolution semantics — in particular the deliberate choice to keep Folder and Workspace under one Kind while splitting Link out — a human look from someone familiar with bun install would still be worthwhile.

What was reviewed

  • GlobalOrRelative::kind() matches the resolver dispatch in get_or_put arm-for-arm; the Relative(_) unreachable!() mirrors the existing one.
  • All .folders map accesses (init seed + three in get_or_put) migrated; the removed hash() helper has no remaining callers.
  • Root-package seed uses Kind::Folder, so file: deps on the root still hit it; the hash-collision abs_path guard is preserved.
  • Test covers both alias orders, drains pipes concurrently, and asserts the exact lockfile packages shape.
Extended reasoning...

Overview

The PR widens the folder-resolution cache key in src/install/resolvers/folder_resolver.rs from a bare u64 path hash to a Key { kind: Kind, abs_hash: u64 } struct, where Kind is Folder (file:/workspace), Link (link:), or CacheFolder (npm cache reads). PackageManager::init is updated to seed the root package under Kind::Folder, the FolderResolutionMap typedef in PackageManager.rs is updated to the new key type, and two parametrized regression tests are added to bun-install.test.ts. The old free hash() function is removed.

Security risks

None. This is an in-process cache-key change during dependency resolution; no untrusted input handling, path traversal, or network surface is introduced. The abs_path byte-comparison collision guard from #32745 is retained unchanged.

Level of scrutiny

High — this is bun install resolution logic, where a wrong cache hit silently produces an incorrect lockfile (as the bug being fixed demonstrates). I verified: (1) kind() agrees with the resolver get_or_put picks for every GlobalOrRelative variant, including the Relative(_) fallthrough which is unreachable!() in both places; (2) grepping for .folders. and folder_resolver::hash shows every map accessor was migrated and nothing still calls the removed helper; (3) the four get_or_put call sites in PackageManagerEnqueue.rs and PackageManagerResolution.rs only pass Global, Relative(Folder), Relative(Workspace), and CacheFolder, all of which kind() handles.

Other factors

The one non-mechanical choice is merging Folder and Workspace into a single Kind::Folder. The PR description justifies this well — both parse dependencies, and a file: pointing at a workspace member or the seeded root must reuse that package (covered by an existing isolated-install.test.ts case). Since the previous key had no kind dimension at all, this preserves the prior Folder↔Workspace sharing while newly separating only Link and CacheFolder, which is exactly the reported bug. Still, it is a semantic decision about what counts as "the same package" in the lockfile, and a maintainer who owns src/install/ is better placed to confirm there's no edge case (e.g. workspace-member-also-referenced-via-link:, or lockfile migration) that this reasoning misses. No CODEOWNERS entry covers this path. The tests follow harness conventions (tempDir, bunExe, concurrent pipe drain, exact toEqual on lockfile structure) and the --lockfile-only rationale is sound.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

On the two edge cases raised in the review:

  • Workspace member also referenced through link:: Kind is taken from how the lookup is going to parse the directory, not from where the directory is, so a link: lookup can never be served a folder or workspace entry (or the other way round), whichever target it points at. Workspaces and file: folders sit in the same Kind::Folder bucket, so the new tests exercise the identical mechanism. The tests use file: rather than a workspace member because on main a link: value only reaches this cache when it starts with ., which puts the target outside the project root, where a workspace member cannot be; the path-form link: work (install: support path-form link: dependencies #35461) would make link:./packages/x reach it too, and it gets its own entry by the same rule.
  • Lockfile migration: nothing from this map is persisted, so there is no format change. A bun.lock that already contains a merged entry from an older version is kept as is, like any other pinned resolution, until that dependency line changes, it is passed to bun update, or the lockfile is removed (checked: re-running the fixed build on a lockfile written by 1.4.0 leaves the two shared@link:../shared entries in place). The fix applies whenever the dependency is actually resolved. Added this note to the description.

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