install: stop hoisting a dependency cycle forever
Draft
RO
25d ago+617+617+012345678901234567890123456789-2-2-0123456789
Draft, do not merge. Three defects were reproduced on this branch. The first two were found in review and are regressions on installs that main gets right. All three exit 0, so no test or check reports them:
- A package nested below another copy of itself, with no cycle through the affected dependency, loses its own
node_modules. Root depends on a@1, b@2, c@2, e@2; a@1 -> b@1, e@1; b@1 -> a@2; a@2 -> c@1, e@3; c@1 -> a@1. main installs 11 packages and the nested a@1 gets e@1. This branch installs 10 and the nested a@1 resolves e@3.
- A self-contained workspace (
workspaces.selfContained, hoisted linker) with a dependency that depends back on the workspace. main creates apps/desktop/node_modules/desktop -> ... This branch does not.
- The lockfile this branch writes for a cycle is misread by released versions of bun. The copy that gets no
node_modules is written as an ordinary packages row in a version 2 lockfile. bun 1.4.2 loads that file, binds the package from that deepest row, and installs 2 of the 6 packages of the x/y cycle, with exit code 0, also under --frozen-lockfile. The loader change in this PR only protects versions that contain it. A repository that commits such a lockfile breaks, without an error, for everyone on an older bun. I only tested this branch reading its own lockfile and did not consider older readers.
The hang fix itself works for the reported graphs. The cut in process_subtree is too coarse and the hoist-root check in hoist_dependency is too broad. The Fix section below describes the code as it is now, not the design that will ship.
Problem
bun installnever 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-onlyare 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 onx@1. - Cause,
src/install/lockfile/Tree.rs: whenhoist_dependencywalks up from a package and the first entry with the dependency's name it meets is a different version,process_subtreenests 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 inLockfile::hoistnever drains. The only guard against this was forfolder: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_modulesas 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 ownnode_modules, the copy bundles the dependency again, and so on.
Fix
process_subtreeno longer builds anode_modulesfor 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/arboristplace-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 finitenode_moduleslayout 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 anode_modulesbelonging to the very package the dependency resolves to (same name), treats it as already hoisted: that package is resolvable from everything inside its folder. In the bundled shape this placement always led to the hang.- Since #40014 a self-contained workspace is a hoist root too, so this check also fires for a dependency inside such a workspace that depends back on the workspace. That is regression 2 above: main places a link there and terminates. An earlier version of this description said that two such shapes came out identical on main and on this branch. That comparison ran under the isolated linker, where the barrier does not apply, so it showed nothing.
- The existing
folder:guard inprocess_subtreestays.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 anode_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-lockfileaccepted 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 inprocess_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 package nested below a copy of itself no longer gets its own
node_modules, even when its dependencies would have converged. This is regression 1 above. It is not the layout npm produces: npm links the nested copy back to the first one, so that copy's dependencies still resolve correctly.--frozen-lockfiledoes not notice, since it compares hoisted trees. - 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-lockfileand compares the installed packages, then re-prints it with--lockfile-onlyand compares the text. All of them time out on main. With only theTree.rshalf 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 fromtest/cli/install/registry/packages/create-hoist-cycle-packages.ts. - #38848 changes the same stop-at-the-bundle-root spot in
hoist_dependencyfor 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::hoistturns the resolved graph into thenode_moduleslayout. EachTreeis onenode_modulesfolder:tree.dependency_idis the dependency whose package owns the folder,tree.parentthe folder that package sits in. It is built breadth first; placing a dependency that has dependencies of its own queues aFillItem, andprocess_subtreelater 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
packagesbynode_modulespath, 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-lockfilecompares that tree with the one a clean resolve produces.--lockfile-onlyalways 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'snode_modules, which the walk never climbs past.
[human-review] gate passed · iteration 0 · 25 files touched
fails on main (without fix)
ASAN without fix: 6 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/install/hoist.test.ts
bun test v1.4.3 (367d939d9)
test/cli/install/hoist.test.ts:
(pass) should handle resolving optional peer from multiple instances of same package [285.35ms]
killed 2 dangling processes
(fail) a dependency cycle through two versions of the same packages is installed (hoisted linker) [5005.64ms]
^ this test timed out after 5000ms.
Verdaccio exited with code null and signal SIGTERM
# Unhandled error between tests
-------------------------------
52 | env: bunEnv,
53 | stdout: "pipe",
54 | stderr: "pipe",
55 | });
56 | const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
57 | expect({ args, stdout, stderr, exitCode }).toMatchObject({
^
error: expect(received).toMatchObject(expected)
{
- "exitCode": 0,
+ "args": [],
+ "exitCode": 143,
"stderr": StringNotContaining "error:",
+ "stdout":
+ "bun install v1.4.3 (367d939d9)
+ "
+ ,
}
- Expected - 1
+ Received + 6
a
... (truncated)
release without fix: 6 FAILED
bun test v1.4.3-canary.1 (367d939d9)
test/cli/install/hoist.test.ts:
(pass) should handle resolving optional peer from multiple instances of same package [36.81ms]
killed 2 dangling processes
(fail) a dependency cycle through two versions of the same packages is installed (hoisted linker) [5000.77ms]
^ this test timed out after 5000ms.
Verdaccio exited with code null and signal SIGTERM
52 | env: bunEnv,
53 | stdout: "pipe",
54 | stderr: "pipe",
55 | });
56 | const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
57 | expect({ args, stdout, stderr, exitCode }).toMatchObject({
^
error: expect(received).toMatchObject(expected)
{
- "exitCode": 0,
- "stderr": StringNotContaining "error:",
+ "args": [],
+ "exitCode": 1,
+ "stderr":
+ "Resolving dependencies
+ Resolved, downloaded and extracted [6]
+ error: ConnectionRefused downloading package manifest hoist-cycle-x
+ error: hoist-cycle-x@1.0.0 failed to resolve
+ "
+ ,
+ "stdout":
+ "bun install v1.4.3-canary.1 (367d939d9)
+ "
+ ,
}
- Expected - 2
+ Received + 13
at ins
... (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/pr_gate.xml" test/cli/install/hoist.test.ts
bun test v1.4.3 (367d939d9)
test/cli/install/hoist.test.ts:
(pass) should handle resolving optional peer from multiple instances of same package [299.45ms]
(pass) a dependency cycle through two versions of the same packages is installed (hoisted linker) [654.36ms]
(pass) a dependency cycle through two versions of the same packages is installed (isolated linker) [666.05ms]
(pass) a cycle entered at both of its versions, from the root and from a workspace, is installed [479.50ms]
(pass) a bundled dependency with a peer on the package bundling it is installed (hoisted linker) [474.72ms]
(pass) a bundled dependency with a peer on the package bundling it is installed (isolated linker) [565.59ms]
(pass) a cycle closed through an optional peer is installed [636.81ms]
7 pass
0 fail
44 expect() calls
Ran 7 tests across 1 file. [6.32s]
__F:0:S:0
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 833ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/19] rustc bun_install
[2/19] rustc bun_jsc
[3/19] rustc bun_ast_jsc
[4/19] rustc bun_sys_jsc
[5/19] rustc bun_semver_jsc
[6/19] rustc bun_bundler_jsc
[7/19] rustc bun_patch_jsc
[8/19] rustc bun_css_jsc
[9/19] rustc bun_sourcemap_jsc
[10/19] rustc bun_js_parser_jsc
[11/19] rustc bun_install_jsc
[12/19] rustc bun_http_jsc
[13/19] rustc bun_sql_jsc
[14/19] rustc bun_runtime
[15/19] link bun-profile
ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-299c5323879e79af-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102508510)' is 'x86_64-unknown-linux-gnu'
ld.lld: warning: Linking two modules of different target triples: 'obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o' is 'x86_64-pc-linux-gnu' whereas '../../../../root/.bun/build-cache/webkit-299c5323879e79af-lto/lib/libJavaScriptCore.a(UnifiedSource-inspector-2.cpp.o at 102508510)' i
... (truncated)
diff hotspot
src/install/lockfile/Tree.rs | 28 +++
src/install/lockfile/bun.lock.rs | 23 +++
test/cli/install/hoist.test.ts | 191 ++++++++++++++++++++-
.../packages/create-hoist-cycle-packages.ts | 129 ++++++++++++++
.../hoist-bundled-cycle-host-1.0.0.tgz | Bin 0 -> 265 bytes
.../packages/hoist-bundled-cycle-host/package.json | 25 +++
.../hoist-bundled-cycle-plugin-1.0.0.tgz | Bin 0 -> 208 bytes
.../hoist-bundled-cycle-plugin/package.json | 22 +++
.../packages/hoist-cycle-x/hoist-cycle-x-1.0.0.tgz | Bin 0 -> 188 bytes
.../packages/hoist-cycle-x/hoist-cycle-x-2.0.0.tgz | Bin 0 -> 188 bytes
.../registry/packages/hoist-cycle-x/package.json | 35 ++++
.../packages/hoist-cycle-y/hoist-cycle-y-1.0.0.tgz | Bin 0 -> 192 bytes
.../packages/hoist-cycle-y/hoist-cycle-y-2.0.0.tgz | Bin 0 -> 192 bytes
.../registry/packages/hoist-cycle-y/package.json | 35 ++++
.../hoist-optional-peer-cycle-entry-1.0.0.tgz | Bin 0 -> 205 bytes
.../hoist-optional-peer-cycle-entry/package.json | 22 +++
.../hoist-optional-peer-cycle-x-1.0.0.tgz | Bin 0 -> 209 bytes
.../hoist-optional-peer-cycle-x-2.0.0.tgz | Bin 0 -> 209 bytes
.../hoist-optional-peer-cycle-x/package.json | 37 ++++
.../hoist-optional-peer-cycle-y-1.0.0.tgz | Bin 0 -> 209 bytes
.../hoist-optional-peer-cycle-y-2.0.0.tgz | Bin 0 -> 232 bytes
.../hoist-optional-peer-cycle-y/package.json | 40 +++++
.../hoist-optional-peer-cycle-z-1.0.0.tgz | Bin 0 -> 205 bytes
.../hoist-optional-peer-cycle-z-2.0.0.tgz | Bin 0 -> 173 bytes
.../hoist-optional-peer-cycle-z/package.json | 32 ++++
25 files changed, 617 insertions(+), 2 deletions(-)
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
src/install/lockfile/Tree.rs 14 10 19
src/install/lockfile/bun.lock.rs 9 4 19
test/cli/install/hoist.test.ts 4 8 19
…nstall/registry/packages/create-hoist-cycle-packages.ts 0 4 20
…t-bundled-cycle-host/hoist-bundled-cycle-host-1.0.0.tgz 0 0 19
…registry/packages/hoist-bundled-cycle-host/package.json 0 0 19
…ndled-cycle-plugin/hoist-bundled-cycle-plugin-1.0.0.tgz 0 0 19
…gistry/packages/hoist-bundled-cycle-plugin/package.json 0 0 19
…registry/packages/hoist-cycle-x/hoist-cycle-x-1.0.0.tgz 0 0 20
…registry/packages/hoist-cycle-x/hoist-cycle-x-2.0.0.tgz 0 0 19
…li/install/registry/packages/hoist-cycle-x/package.json 0 0 19
…registry/packages/hoist-cycle-y/hoist-cycle-y-1.0.0.tgz 0 0 19
…registry/packages/hoist-cycle-y/hoist-cycle-y-2.0.0.tgz 0 0 19
…li/install/registry/packages/hoist-cycle-y/package.json 0 0 19
…r-cycle-entry/hoist-optional-peer-cycle-entry-1.0.0.tgz 0 0 19
…y/packages/hoist-optional-peer-cycle-entry/package.json 0 0 19
(+ 9 more files)