Skip to content

install: stop hoisting a dependency cycle forever - #38976

Open
robobun wants to merge 3 commits into
mainfrom
farm/f34f34cc/hoist-cycle-termination
Open

install: stop hoisting a dependency cycle forever#38976
robobun wants to merge 3 commits into
mainfrom
farm/f34f34cc/hoist-cycle-termination

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun install never returns on some dependency graphs: 100% CPU and memory growing without bound (about 1 GB after 3 s on a release build) until it is killed. Both linkers and --lockfile-only are affected because the hang is in the lockfile's hoisting pass (Lockfile::resolve -> Lockfile::hoist -> Tree::process_subtree), which runs before anything is written. Found by an install fuzzer (13% of its random graphs); most shapes also hang bun 1.3.14, one (a cycle closed through an optional peer) only hangs current main.
  • Smallest shape, no peers or workspaces needed: x@1 -> y@1 -> x@2 -> y@2 -> x@1, with the project depending on x@1.
  • Cause, src/install/lockfile/Tree.rs: when hoist_dependency walks up from a package and the first entry with the dependency's name it meets is a different version, process_subtree nests the dependency inside the package and queues that copy's own dependencies. Around a cycle through two versions of the same names every copy conflicts with the copy one level up, so each copy nests another one and the queue in Lockfile::hoist never drains. The only guard against this was for folder: dependencies.
  • A second shape hangs for a different reason: a package that bundles a dependency which depends (or has a peer dependency) back on it, the other graph the fuzzer reduced to. A bundled dependency's subtree uses the bundler's own node_modules as its hoist root, and the walk stops there without looking at the folder one level up that holds the bundler itself. The bundler is copied into its own node_modules, the copy bundles the dependency again, and so on.

Fix

  • process_subtree no longer builds a node_modules for a copy of a package that is nested somewhere below another copy of the same package. Everything that package depends on was placed while the copy above was processed; laying it out again can only re-nest the versions the levels in between shadow, which is the step that never converges. This is the same condition at which npm's placer stops nesting (@npmcli/arborist place-dep.js, which links back to the copy above at that point). Without a link the copy stays installed as is and its own dependencies resolve to whatever is on the path, so one edge of the cycle resolves to the other version; no finite node_modules layout of such a graph avoids that. Every copy on a path now belongs to a different package, so the pass is bounded for any input.
  • hoist_dependency, when it would place a dependency into a node_modules belonging to the very package the dependency resolves to (same name), treats it as already hoisted: that package is resolvable from everything inside its folder. This placement only ever happened in the bundled shape above, where it always led to the hang, so no terminating install changes.
  • The existing folder: guard in process_subtree stays. folder: dependencies never go through the walk, so that guard is what keeps a folder package from being copied at all when it is its own ancestor; the new check only decides whether a copy that was placed gets a node_modules.
  • bun.lock.rs: the loader binds a package's dependencies by walking up from each of its rows, last row winning. The unexpanded copy is always the deepest row of its package and by construction walks to the shadowing versions, so a clean checkout loaded the written lockfile into a different graph, re-hoisted it to a smaller tree, and --frozen-lockfile accepted it (the frozen check compares the loaded tree with itself after cleaning). Rows that sit below another row of the same package are now skipped when binding; the copy above, which the tree was built from, binds the package (PkgMap::is_below_copy_of, mirroring the check in process_subtree). Existing lockfiles have such rows only for a copy that was fully laid out, whose bindings agree with the copy above anyway.
  • Behaviour change outside the hang: a graph where a package is nested below a copy of itself and that copy's dependencies happened to converge on their own now leaves that copy without its own node_modules (the same layout npm produces); its rows under the copy disappear from bun.lock on the next write. --frozen-lockfile is unaffected since it compares hoisted trees, not rows.
  • Verified with test/cli/install/hoist.test.ts: the plain two-version cycle under both linkers (also checking the installed layout), the same cycle entered at both versions from the root and a workspace, the bundled host/plugin shape under both linkers, and a cycle closed through an optional peer. Every test installs from scratch, then installs the resulting bun.lock in a new directory with --frozen-lockfile and compares the installed packages, then re-prints it with --lockfile-only and compares the text. All of them time out on main. With only the Tree.rs half applied they still fail: the plain cycle reloads to 2 of 6 packages and the optional-peer one to 6 of 13, with exit code 0. Fixtures come from test/cli/install/registry/packages/create-hoist-cycle-packages.ts.
  • install: keep a bundle's dependency out of a slot the bundling package resolves through #38848 changes the same stop-at-the-bundle-root spot in hoist_dependency for a different bug (a bundled subtree placing another version of one of the bundler's own dependencies into the bundler's folder); the check here only concerns the bundler itself and is independent of it.
  • Both graphs the fuzzer reported (one with a workspace pinning a second version of a package, one closed through an optional peer) install and round-trip the same way against a local registry serving exactly those versions; the optional-peer fixture is a smaller version of the second one.

Background

  • Hoisting: Lockfile::hoist turns the resolved graph into the node_modules layout. Each Tree is one node_modules folder: tree.dependency_id is the dependency whose package owns the folder, tree.parent the folder that package sits in. It is built breadth first; placing a dependency that has dependencies of its own queues a FillItem, and process_subtree later creates that package's folder and places its dependencies one by one.
  • Placing a dependency (hoist_dependency) walks up from the dependent's own folder. No entry with that name anywhere on the path: it goes to the top. The same package found: nothing to place. A different version found first: the dependency is nested in the dependent's own folder, because Node would otherwise resolve the name to that other version. The last rule is what a two-version cycle drives forever.
  • The same builder runs once over the whole graph (the tree bun.lock is written from) and once with disabled and omitted dependencies filtered out (the tree that is installed). bun.lock keys packages by node_modules path, so one package can appear under several keys; loading walks up from each key to rebind the package's dependencies and then hoists again, and --frozen-lockfile compares that tree with the one a clean resolve produces. --lockfile-only always rewrites the file, which is why the tests use it to see the tree a reload builds.
  • Bundled dependencies (bundleDependencies) ship inside the bundler's tarball. Their own dependencies are kept below the bundler by giving their subtree a hoist root at the bundler's node_modules, which the walk never climbs past.

The tree builder nests a dependency inside its dependent whenever the
nearest node_modules holding that name has a different version. Around a
cycle through two versions of the same packages (x@1 -> y@1 -> x@2 -> y@2
-> x@1 ...) every copy conflicts with the copy one level up, so copies were
nested without end and `bun install` never returned while its memory grew.
A package bundling a dependency that depends back on it looped as well: the
bundled subtree's walk stops at the bundler's own node_modules without
seeing the bundler one level up, so the bundler was copied into itself and
the copy bundled the dependency again.

process_subtree now leaves a copy of a package that is nested below another
copy of the same package without a node_modules of its own (the point at
which npm links back to the copy above), which bounds every path by the
number of packages, and hoist_dependency resolves a dependency onto the
package whose node_modules it would otherwise be placed in.

The bun.lock loader bound a package's dependencies from each of its rows in
turn, so the unexpanded copy, always the deepest row, rebound them to the
versions it sits under and a clean checkout installed a smaller tree while
--frozen-lockfile passed. Rows below another row of the same package are
skipped now; the copy the tree was built from binds the package.
@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: 16 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: 00e21016-c466-4fd5-bfd3-7369a4f10f2e

📥 Commits

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

📒 Files selected for processing (25)
  • src/install/lockfile/Tree.rs
  • src/install/lockfile/bun.lock.rs
  • test/cli/install/hoist.test.ts
  • test/cli/install/registry/packages/create-hoist-cycle-packages.ts
  • test/cli/install/registry/packages/hoist-bundled-cycle-host/hoist-bundled-cycle-host-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-bundled-cycle-host/package.json
  • test/cli/install/registry/packages/hoist-bundled-cycle-plugin/hoist-bundled-cycle-plugin-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-bundled-cycle-plugin/package.json
  • test/cli/install/registry/packages/hoist-cycle-x/hoist-cycle-x-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-cycle-x/hoist-cycle-x-2.0.0.tgz
  • test/cli/install/registry/packages/hoist-cycle-x/package.json
  • test/cli/install/registry/packages/hoist-cycle-y/hoist-cycle-y-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-cycle-y/hoist-cycle-y-2.0.0.tgz
  • test/cli/install/registry/packages/hoist-cycle-y/package.json
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-entry/hoist-optional-peer-cycle-entry-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-entry/package.json
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-x/hoist-optional-peer-cycle-x-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-x/hoist-optional-peer-cycle-x-2.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-x/package.json
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-y/hoist-optional-peer-cycle-y-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-y/hoist-optional-peer-cycle-y-2.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-y/package.json
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-z/hoist-optional-peer-cycle-z-1.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-z/hoist-optional-peer-cycle-z-2.0.0.tgz
  • test/cli/install/registry/packages/hoist-optional-peer-cycle-z/package.json

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

@robobun

robobun commented Aug 15, 2026

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

@robobun, your commit 86a1bc6 has some failures in Build #97864 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38976

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

bun-38976 --bun

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; CI is green on every lane that ran.

Reproduced on the released bun (and on main) with a local registry: x@1 -> y@1 -> x@2 -> y@2 -> x@1 and host bundling a plugin that has a peer on host both make bun install spin at 100% CPU with memory growing until killed, under both linkers and with --lockfile-only. The two graphs from the fuzz report (one splitting two versions of a package between the root and a workspace, one closed through an optional peer) hang the same way; all of them install with this branch and reload from the written bun.lock into the same tree.

The new cases in test/cli/install/hoist.test.ts time out on main, and still fail with only the Tree.rs change applied (the lockfile reloads to a smaller tree), so they cover the loader change too.

Build 97864 (current head): 177 of 179 jobs passed, including this test on Linux, macOS x64 and both Windows targets. The remaining two are the darwin 14 aarch64 - test-bun job and its retry, which expired without ever getting an agent; that lane expired on nearly every build in the same window, so it is the queue, not this change. Retrying that one job once the macOS queue drains is all that is left for a fully green build.

Comment thread test/cli/install/hoist.test.ts
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/bun.lock.rs Outdated
Comment thread src/install/lockfile/bun.lock.rs Outdated
Comment thread src/install/lockfile/Tree.rs
Comment thread src/install/lockfile/Tree.rs
Comment thread src/install/lockfile/bun.lock.rs
Comment thread src/install/lockfile/bun.lock.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 (the earlier Windows path-separator issue in installedPackageJsons was fixed in cb91604). Because this changes the hoister's termination condition and how bun.lock rows are bound on load — with an acknowledged layout change for non-cycle graphs where a package sits below a copy of itself — a human look from someone who owns src/install/lockfile/ would still be worthwhile.

What was reviewed

  • The process_subtree ancestor walk: starts at self.id (the parent tree, not the just-appended one), guards dependency_id against resolutions.len() so ROOT_DEP_ID is skipped, and compares PackageID — matches npm's stop condition.
  • The bundled-root check in hoist_dependency: gated on both resolutions[dependency_id] == package_id and matching name_hash, so it only fires when the dependency is the bundling package itself; package_id == invalid_package_id cannot reach it.
  • PkgMap::is_below_copy_of: probes every /-prefix of pkg_path; scoped-name half-prefixes (@scope) are absent from the map so they miss harmlessly, and the full path is excluded so a row does not match itself.
  • Tests: fresh install → --frozen-lockfile reload → --lockfile-only re-print, across both linkers, workspaces, bundled, and optional-peer shapes; the reload half fails without the bun.lock.rs change per the author's report.
Extended reasoning...

Overview

This PR fixes an infinite loop / OOM in bun install's hoisting pass when the dependency graph contains a cycle through two versions of the same package names (e.g. x@1 → y@1 → x@2 → y@2 → x@1), and a related loop when a bundled dependency has a peer back on the package bundling it. Three code changes:

  • src/install/lockfile/Tree.rs, process_subtree: before appending a new tree for a package, walk the ancestor chain; if any ancestor tree's dependency_id resolves to the same PackageID, return early. Every dependency of that package was placed while the higher copy was processed, so re-processing only re-nests the shadowing versions forever.
  • src/install/lockfile/Tree.rs, hoist_dependency: after the walk stops at a bundled hoist root, if the dependency being placed is the bundling package itself (same resolved PackageID and same name_hash as this.dependency_id), return Hoisted instead of placing it into its own node_modules.
  • src/install/lockfile/bun.lock.rs: new PkgMap::is_below_copy_of and a continue in the row-binding loop so a lockfile row that sits below another row of the same package does not rebind that package's dependencies to the shadowing versions on its path — the copy above binds them.

The rest is test fixtures (generated by create-hoist-cycle-packages.ts) and ~190 lines of tests in hoist.test.ts.

Security risks

None. The change narrows when the hoister recurses and when the loader rebinds; input is the already-resolved dependency graph / lockfile rows. No new parsing of untrusted data, no filesystem or network paths touched. is_below_copy_of slices pkg_path at / boundaries and looks the prefixes up in a map keyed by full package paths, so partial-segment matches are impossible by construction.

Level of scrutiny

High. process_subtree and hoist_dependency run on every bun install, and the row-binding loop runs on every lockfile load. The PR description explicitly notes a behaviour change for non-hanging graphs: a package nested below a copy of itself whose subtree previously converged now gets no node_modules of its own (matching npm), and its rows disappear from bun.lock on the next write. --frozen-lockfile is stated to be unaffected because it compares hoisted trees, but that and the interaction with #38848 (which touches the same bundle-root spot) are the kind of things a maintainer who owns this code should confirm.

Other factors

  • The bug-hunting system found no issues on the current head. My earlier finding (Windows backslashes in installedPackageJsons) was fixed in cb91604 and is verified in the diff.
  • The tests are strong: each shape does a fresh install, asserts the exact bun.lock tree, reinstalls from that lockfile with --frozen-lockfile in a clean dir, compares installed package.json sets, then --lockfile-only re-prints and byte-compares. The author reports all of them time out on main and still fail with only the Tree.rs half applied, so both halves are load-bearing.
  • The four remaining comment-cop flags were pushed back on by the author with reasons (invariant / cross-file coupling documentation rather than workaround justification); that's a style call for a human.
  • The existing folder:-cycle guard just above the new code in process_subtree is now largely subsumed by the new general check (both compare ancestor resolution to the target pkg_id), which a reviewer may want to note, though the folder guard also matches on name_hash and skips only the one dependency rather than the whole subtree.

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