test: pin hoisted layouts where a package is installed again below a copy of itself

Closed
RO
25d ago
+726-3

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 own node_modules.

Fix

  • Test-only. test/cli/install/hoist.test.ts gets 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 own node_modules.
  • Each case checks the bun.lock rows, that every installed copy resolves each dependency to the exact version it declares, that --frozen-lockfile in a clean directory installs the same copies, and that --lockfile-only reprints the same bytes.
  • Verified: all pass with bun bd test test/cli/install/hoist.test.ts on 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_modules holding 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 packages by node_modules path, 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 second a@1 sits below a@2's e@3, so it needs an e@1 of 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 exports edges(), which resolves its dependencies from its own location. Fixtures come from registry/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 SIGKILL timeout of 30 s (90 s on ASAN) that only matters if an install never returns, and the test asserts signalCode === 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.js with the same edges() body. Under the isolated linker nothing else goes through the links in the project's node_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. createTestDir returns 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 with Failed to install 1 package (5 of 8 runs on Windows x64 before this, 0 of 12 after). bun-update.test.ts does the same.
  • This does not fix the bun install hang 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)