test: pin hoisted layouts where a package is installed again below a copy of itself
Closed
RO
25d ago+726+726+012345678901234567890123456789-3-3-0123456789
Problem
- Three hoisted-install behaviours that main gets right have no test. A change to how the tree handles dependency cycles (#38976, now a draft) broke all three while CI stayed green: each broken install exited 0 and round-tripped through bun.lock.
- Two are graphs where a package is installed a second time below another copy of itself. That copy still needs its own
node_modules, because the versions nested in between shadow some of its dependencies. - The third is the self-contained workspace guarantee in
docs/pm/workspaces.mdx: when a dependency of such a workspace depends back on it, main links the workspace into its ownnode_modules.
Fix
- Test-only.
test/cli/install/hoist.test.tsgets six cases: the two graphs under both linkers, and the self-contained workspace for a dependency and for a peer dependency: the plugin is placed inside the workspace, and the workspace is linked into its ownnode_modules. - Each case checks the bun.lock rows, that every installed copy resolves each dependency to the exact version it declares, that
--frozen-lockfilein a clean directory installs the same copies, and that--lockfile-onlyreprints the same bytes. - Verified: all pass with
bun bd test test/cli/install/hoist.test.tson main. With a debug build of #38976's branch the six new cases all fail.
Background
- The hoister nests a dependency inside its dependent when the nearest
node_modulesholding that name has a different version. If the nested version depends back on the first one, the first one is installed again further down and its dependencies are laid out again from there. - bun.lock keys
packagesbynode_modulespath, and loading it re-hoists. That is why the reload is checked in a clean directory and through--lockfile-only, which always rewrites the file.
Notes
- First graph: the project 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. The seconda@1sits belowa@2'se@3, so it needs ane@1of its own. main installs 11 packages. The second graph makes the second copy nest three more packages before the layout ends. - The load check catches a wrong edge on its own: on the #38976 build it reports
below-self-a@1.0.0: below-self-e: wanted 1.0.0, got 3.0.0. Every fixture package exportsedges(), which resolves its dependencies from its own location. Fixtures come fromregistry/packages/create-hoist-below-self-packages.ts. - The cases run concurrently and carry no timeout of their own: CI already passes 90 s per test (270 s on ASAN). Each child is spawned with a
SIGKILLtimeout of 30 s (90 s on ASAN) that only matters if an install never returns, and the test assertssignalCode === null. The runner only cleans up the children of sequential tests, so without it an install that stops terminating would outlive its test. - The project itself resolves its dependencies too, through an
index.jswith the sameedges()body. Under the isolated linker nothing else goes through the links in the project'snode_modules. A copy that cannot be loaded is reported with the error, also when it declares no dependencies. - Every directory the file creates is removed in
afterAll, including the first, older test's.createTestDirreturns a plain path, so nothing removed them before. - Every child process gets its own
BUN_INSTALL_CACHE_DIR. CI exports one per test file, and on Windows concurrent installs of the same packages into one cache fail withFailed to install 1 package(5 of 8 runs on Windows x64 before this, 0 of 12 after).bun-update.test.tsdoes the same. - This does not fix the
bun installhang on version cycles that #38976 is about. It records what a fix must not change.
[auto-merge] gate passed · iteration 0 · 29 files touched
passes on PR (with fix)
Test-only change.
Debug/ASAN (expected pass):
$ bun bd test 'test/cli/install/hoist.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test 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 [241.15ms]
(pass) a self-contained workspace > is linked into its own node_modules for a package with a dependency on it [3038.76ms]
(pass) a package installed again below another copy of itself > has its dependencies laid out again, and the layout ends where one is found (isolated linker) [3151.78ms]
(pass) a package installed again below another copy of itself > has its dependencies laid out again, and the layout ends where one is found (hoisted linker) [3221.55ms]
(pass) a package installed again below another copy of itself > gets the dependency that the versions nested in between shadow (isolated linker) [3247.33ms]
(pass) a package installed again below another copy of itself > gets the dependency that the versions nested in between shadow (hoisted linker) [3286.09ms]
(pass) a self-contained workspace > is linked into its own node_modules for a package with a peer dependency on it [2496.03ms]
7 pass
0 fail
69 expect() calls
Ran 7 tests across 1 file. [8.49s]
Exit: 0
diff hotspot
test/cli/install/hoist.test.ts | 312 ++++++++++++++++++++-
.../packages/below-self-a/below-self-a-1.0.0.tgz | Bin 0 -> 359 bytes
.../packages/below-self-a/below-self-a-2.0.0.tgz | Bin 0 -> 364 bytes
.../registry/packages/below-self-a/package.json | 37 +++
.../below-self-again-a-1.0.0.tgz | Bin 0 -> 360 bytes
.../below-self-again-a-2.0.0.tgz | Bin 0 -> 370 bytes
.../packages/below-self-again-a/package.json | 36 +++
.../below-self-again-b-1.0.0.tgz | Bin 0 -> 363 bytes
.../below-self-again-b-2.0.0.tgz | Bin 0 -> 343 bytes
.../below-self-again-b-3.0.0.tgz | Bin 0 -> 343 bytes
.../packages/below-self-again-b/package.json | 42 +++
.../below-self-again-c-1.0.0.tgz | Bin 0 -> 360 bytes
.../below-self-again-c-2.0.0.tgz | Bin 0 -> 343 bytes
.../packages/below-self-again-c/package.json | 32 +++
.../packages/below-self-b/below-self-b-1.0.0.tgz | Bin 0 -> 357 bytes
.../packages/below-self-b/below-self-b-2.0.0.tgz | Bin 0 -> 337 bytes
.../registry/packages/below-self-b/package.json | 32 +++
.../packages/below-self-c/below-self-c-1.0.0.tgz | Bin 0 -> 354 bytes
.../packages/below-self-c/below-self-c-2.0.0.tgz | Bin 0 -> 337 bytes
.../registry/packages/below-self-c/package.json | 32 +++
.../packages/below-self-e/below-self-e-1.0.0.tgz | Bin 0 -> 336 bytes
.../packages/below-self-e/below-self-e-2.0.0.tgz | Bin 0 -> 336 bytes
.../packages/below-self-e/below-self-e-3.0.0.tgz | Bin 0 -> 337 bytes
.../registry/packages/below-self-e/package.json | 39 +++
.../packages/create-hoist-below-self-packages.ts | 123 ++++++++
.../self-contained-peer-plugin/package.json | 22 ++
.../self-contained-peer-plugin-1.0.0.tgz | Bin 0 -> 368 bytes
.../packages/self-contained-plugin/package.json | 22 ++
.../self-contained-plugin-1.0.0.tgz
... (truncated)
gate history · 4 passed · 0 rejected · iteration 0
evidence per changed file
file reads edits tests
test/cli/install/hoist.test.ts 11 11 48
…l/registry/packages/below-self-a/below-self-a-1.0.0.tgz 0 0 48
…l/registry/packages/below-self-a/below-self-a-2.0.0.tgz 0 0 48
…cli/install/registry/packages/below-self-a/package.json 0 0 48
…ackages/below-self-again-a/below-self-again-a-1.0.0.tgz 0 0 48
…ackages/below-self-again-a/below-self-again-a-2.0.0.tgz 0 0 48
…stall/registry/packages/below-self-again-a/package.json 0 0 48
…ackages/below-self-again-b/below-self-again-b-1.0.0.tgz 0 0 48
…ackages/below-self-again-b/below-self-again-b-2.0.0.tgz 0 0 48
…ackages/below-self-again-b/below-self-again-b-3.0.0.tgz 0 0 48
…stall/registry/packages/below-self-again-b/package.json 0 0 48
…ackages/below-self-again-c/below-self-again-c-1.0.0.tgz 0 0 48
…ackages/below-self-again-c/below-self-again-c-2.0.0.tgz 0 0 48
…stall/registry/packages/below-self-again-c/package.json 0 0 48
…l/registry/packages/below-self-b/below-self-b-1.0.0.tgz 0 0 48
…l/registry/packages/below-self-b/below-self-b-2.0.0.tgz 0 0 48
(+ 13 more files)