Skip to content

install: fail a download that a non-optional peer needs, whichever dependency owns the slot - #43219

Open
robobun wants to merge 3 commits into
robobun/3f610524/required-after-optional-tarball-failurefrom
robobun/bb0da9cc/peer-required-download
Open

robobun wants to merge 3 commits into
robobun/3f610524/required-after-optional-tarball-failurefrom
robobun/bb0da9cc/peer-required-download

Conversation

@robobun

@robobun robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #43214. Stacked on #43197: the base is that PR's branch, and this PR feeds its RequiredPackages.

Problem

  • With the hoisted linker, whether a failed download of a package is an error depends on the names of the dependents. One parent has a non-optional peerDependencies entry on baz, the other has baz in optionalDependencies. On a cold cache, the install prints error: GET .../baz-1.0.0.tgz - 404 and exits 1 when the parent with the peer sorts first. It prints warn: and exits 0 when the other parent sorts first.
  • install: fail when an optional and a required dependency share a failed download #43197 asks RequiredPackages (src/install/lockfile/Tree.rs) whether a non-optional dependency resolves to the package, and leaves peers out: the hoister can bind a peer to another package than the one it resolves to, and the tree does not record that binding.

Fix

  • The tree builder records, for the install tree only, the packages that a dependency without the OPTIONAL bit is placed as or deduplicated onto. HoistDependencyResult::Hoisted now carries the package of the slot. A peer bound to an ancestor's other version marks that version. A peer whose parent lists the same name in optionalDependencies is an optional peer and does not mark, whichever dependency owns the slot.
  • Lockfile::filter returns that bit set. The hoisted install builds RequiredPackages::from_tree with it. contains answers from the bit set for every package the hoist saw and walks the lockfile only for a package appended later. The isolated linker still walks.
  • A failed download of a package that a non-optional peer needs is already an error when the peer is the only dependent. This gives the same answer when an optional dependency owns the slot.
  • Verified: test/cli/install/bun-install-retry.test.ts (five new tests, two fail on install: fail when an optional and a required dependency share a failed download #43197 alone). Also bun-install-offline, bun-install-cpu-os, isolated-install, bun-workspaces, and bun-install-lifecycle-scripts (the same PATH failures as noted in install: fail a lifecycle script of a package that a required and an optional dependency share #43218).

Background

  • The hoisted linker places each package once, in the highest node_modules folder where its name is free (Tree::hoist_dependency). A later dependency on the same package is deduplicated and gets no slot. A peer is deduplicated onto any version that satisfies its range, or onto any version the root declares.
  • Tree.dependency_id is the one dependency that owns a slot. Dependencies sort by behavior then by name, so which dependency owns a slot follows from the names.
  • RequiredPackages (install: fail when an optional and a required dependency share a failed download #43197) decides whether a failed download is an error. It walks the lockfile from the root with the linker's filters and skips peers.
Notes

Why the builder and not the walk. The walk sees the lockfile resolution of a peer. The hoister can bind it to another version (hoist_dependency: a version in range, or any version the root declares). Only the builder knows the binding, so it records it. This is the hoisted-linker answer for RequiredPackages::contains. #43197's walk stays for the isolated linker and for a package the install appends after the hoist.

Optional peers. An optional peer has the OPTIONAL bit, so it does not mark. That is the same predicate as #43197.

A required edge from an optional parent marks the package. That is the rule on main when the root does not list the package, and #43197 keeps it. An optional subtree that fails as a unit, as npm does, would be a separate change.

Fresh install. With no lockfile, root peers resolve last. #43197's download_already_failed lets the install phase download again for the required request, so the fresh install reports the error too.

Review. Self-reviewed twice. The first review asked to fold the change into #43197 instead of a standalone mechanism, which this revision does. 4 concerns from the bot review: 2 addressed (a name in both optionalDependencies and peerDependencies of one package.json, first when that package's optional entry owns the slot, then when the root's does), 2 kept as described in the two notes above.

Repro from the issue with the debug build, second install, both orders:

error: GET http://localhost:34473/baz-1.0.0.tgz - 404
exit 1

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/install/bun-install-retry.test.ts
bun test v1.4.3 (b52d51348)

test/cli/install/bun-install-retry.test.ts:
(pass) retries a manifest whose redirect target 500s once [413.00ms]
(pass) retries an authorized manifest whose cross-origin redirect target 500s once [284.23ms]
(pass) retries a tarball whose redirect target 500s once [248.50ms]
(pass) retries on 500 [345.62ms]
(pass) linker=hoisted > does not re-download a tarball that already failed with 404 [291.56ms]
(pass) linker=hoisted > does not re-download a tarball that already failed with 500 [198.75ms]
(pass) linker=hoisted > does not re-download an optional dependency's tarball that already failed [305.14ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > reports the required alias of an npm package after the optional alias's download failed [562.71ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > reports the required one after the optional one's download failed [688.67ms]
(pass) link
... (truncated)

release without fix: 1 failed, 6 skipped
bun test v1.4.3-canary.1 (7bd5ebe39)

test/cli/install/bun-install-retry.test.ts:
(pass) retries a manifest whose redirect target 500s once [8.18ms]
(pass) retries an authorized manifest whose cross-origin redirect target 500s once [6.83ms]
(pass) retries a tarball whose redirect target 500s once [5.63ms]
(pass) retries on 500 [6.95ms]
(pass) linker=hoisted > does not re-download a tarball that already failed with 404 [5.56ms]
(pass) linker=hoisted > does not re-download a tarball that already failed with 500 [4.47ms]
(pass) linker=hoisted > does not re-download an optional dependency's tarball that already failed [6.52ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > reports the required alias of an npm package after the optional alias's download failed [21.69ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > when the optional dependency is the one the linker asks through > reports the required one on a fresh install [29.10ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > stays a warning > with a fresh install > when a peer res
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/cli/install/bun-install-retry.test.ts
bun test v1.4.3 (b52d51348)

test/cli/install/bun-install-retry.test.ts:
(pass) retries a manifest whose redirect target 500s once [293.45ms]
(pass) retries an authorized manifest whose cross-origin redirect target 500s once [208.50ms]
(pass) retries a tarball whose redirect target 500s once [181.05ms]
(pass) retries on 500 [259.27ms]
(pass) linker=hoisted > does not re-download a tarball that already failed with 404 [144.71ms]
(pass) linker=hoisted > does not re-download a tarball that already failed with 500 [155.30ms]
(pass) linker=hoisted > does not re-download an optional dependency's tarball that already failed [213.35ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > reports the required alias of an npm package after the optional alias's download failed [353.57ms]
(pass) linker=hoisted > a failed download that an optional and a required dependency share > reports the required one when a callback is still queued for the failed download [426.42
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 721ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/7] cargo bun_runtime → libbun_runtime.a
�[1m�[33mwarning�[0m�[1m: binary `bun_shim_impl` should have a kebab-case name�[0m
   �[1m�[94m|�[0m
�[1m�[94m 1�[0m �[1m�[94m|�[0m /workspace/bun/build/release/rust-target/.../bun_shim_impl
   �[1m�[94m|�[0m                                              �[1m�[33m^^^^^^^^^^^^^�[0m
   �[1m�[94m|�[0m
   �[1m�[94m= �[0m�[1mnote�[0m: `cargo::non_kebab_case_bins` is set to `warn` by default
�[1m�[96mhelp�[0m: to change the binary name to `bun-shim-impl`, convert `bin.name`
  �[1m�[94m--> �[0msrc/install/windows-shim/Cargo.toml:41:8
   �[1m�[94m|�[0m
�[1m�[94m41�[0m �[91m- �[0mname = �[91m"bun_shim_impl"�[0m
�[1m�[94m41�[0m �[92m+ �[0mname = �[92m"bun-shim-impl"�[0m
   �[1m�[94m|�[0m
�[1m�[33mwarning�[0m: `bun_shim_impl` (manifest) generated 1 warning
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   C
... (truncated)
diff hotspot
src/install/hoisted_install.rs             |   9 +-
 src/install/lockfile.rs                    |  49 +++++---
 src/install/lockfile/Tree.rs               |  99 +++++++++++++----
 test/cli/install/bun-install-retry.test.ts | 173 +++++++++++++++++++++++++++++
 4 files changed, 291 insertions(+), 39 deletions(-)

gate history · 2 passed · 0 rejected · iteration 0

evidence per changed file
file                                        reads  edits  tests
src/install/hoisted_install.rs                  0      0     24
src/install/lockfile.rs                         0      0     24
src/install/lockfile/Tree.rs                    4     10     24
test/cli/install/bun-install-retry.test.ts      3      3     24

root cause · written by the author bot

The hoisted linker decided whether a failed tarball download was an error from the single dependency that happened to own the node_modules slot, so a non-optional peer owning the slot made the failure fatal while an optional dependency owning it made the same failure a warning, and the owner was chosen purely by parent name order. The fix records which package each peer is actually bound to during hoisting and marks that package as required from every non-optional edge that binds to it, rather than only from the slot owner. A peer is treated as optional when its own parent also lists the sa…

@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack


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

@robobun

robobun commented Sep 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 9:07 PM PT - Sep 17th, 2026

✅ @robobun, your commit 7fc3fd843c1eff58c2f8dced9c3244ef41d7845f passed in Build #117528! 🎉


🧪   To try this PR locally:

bunx bun-pr 43219

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

bun-43219 --bun

@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.

Beyond the inline findings, I also checked the plumbing: Bitset in PackageInstaller.rs is an alias for DynamicBitSet, so the bitset from Lockfile::filter is stored without conversion; it is sized to packages.len() at hoist time, matching the bit_length() guards in mark_required and is_required_download; and the isolated linker and auto-installer pass the same per-dependency behavior.is_required() the enqueue functions computed before, so their behavior is unchanged.

Extended reasoning...

Findings are present, so this is the brief ruled-out note only. Verified from the diff: type Bitset = DynamicBitSet at src/install/PackageInstaller.rs:43 and the DynamicBitSet as Bitset import in src/install/hoisted_install.rs:5; required_packages is DynamicBitSet::init_empty(slice.len()) in Lockfile::hoist only for Filter, and mem::take in clean() returns it intact; the three enqueue functions now take is_required as a parameter and the two non-hoisted callers compute it exactly as the removed inline code did. The substantive semantic questions (optional+peer duplicates, optional-only reachability, optional peers losing required status) are covered by the inline findings and need a human decision on intended semantics.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/install/PackageInstaller.rs — pre-existing: on a fresh install (no lockfile) or bun add, a user still gets exit 0 with the package a non-optional peer needs missing from node_modules, in both name orders. The resolve phase downloads the tarball for the optional edge first and logs a warning on 404; the install phase then knows is_required is true from is_required_download but the AlreadyFailed arm at PackageInstaller.rs:1732 only counts the tree and drops that knowledge. Fix: when an enqueue returns AlreadyFailed and the caller's is_required is true but the dedupe entry was recorded as not required, log the failure as an error (or bump summary.fail) so the install exits 1, at all three AlreadyFailed arms (1660, 1693, 1732). … [also at: src/install/PackageInstaller.rs:1623 - Pre-existing, partial fix: on a fresh install (no lockfile, cold cache) a 404 for a package that a non-optional peer needs still prints warn: and exits 0 whichever parent sorts first.]

    Extended reasoning...

    …The PR notes #43197 changes this path; until it lands the title's promise holds only when a lockfile exists.

    Repro from the issue with no bun.lockb and a cold cache: root depends on aaa (peerDependencies baz) and zzz (optionalDependencies baz), baz-1.0.0.tgz answers 404. Resolve phase: peers of non-root packages are deferred (PackageManagerEnqueue.rs:1763-1767, peer_dependencies.write_item) so zzz's optional edge resolves baz first and generate_network_task_for_tarball at PackageManagerEnqueue.rs:2469 records is_required false (behavior.is_required() of the optional edge). The 404 arrives: runTasks.rs:860 reads is_required false, marks the task failed, logs 'warn: GET ... - 404'. aaa's peer later resolves to the existing baz package, is_first_time false, so no has_created_network_task call ORs the flag. Install phase: filter() sets baz's bit (aaa's peer edge is non-optional), is_required_download returns true at…

    Verification: pre-existing (the base fails the same way by the same route; this PR fixes only the lockfile-present path and its own description concedes the fresh-install path: "a peer's tarball that fails first for an optional dependency is a warning, in both name orders. The install phase then gets AlreadyFailed"). Triggering condition: a first install (no lockfile, cold cache) where the package a…

Comment thread src/install/lockfile/Tree.rs
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 159affb30c for the sibling-group finding: a peer deduplicated onto its own parent's entry of the same name no longer marks the package, so a name in both optionalDependencies and peerDependencies stays a warning. The other two findings are answered in their threads and in the PR notes.

…ndency is bound to, peers included

The tree builder records the package that each linked dependency is placed as
or deduplicated onto, for the install tree only. A peer that the hoister binds
to an ancestor's other version marks that version. A peer deduplicated onto
its own parent's entry of the same name does not mark: that entry decides.

The hoisted install gives that bit set to RequiredPackages, which answers from
it for every package the hoist saw and walks the lockfile only for a package
appended later.
@robobun
robobun force-pushed the robobun/bb0da9cc/peer-required-download branch from fdade78 to 0fe89f1 Compare September 18, 2026 02:52
@robobun
robobun changed the base branch from main to robobun/3f610524/required-after-optional-tarball-failure September 18, 2026 02:52
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
Comment thread src/install/lockfile/Tree.rs Outdated
@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked after the self-review: this PR is now stacked on #43197 (its base is that branch) and feeds the hoisted tree builder's bit set into RequiredPackages::from_tree. The duplicate enqueue-signature and installer hunks are gone. The diff against #43197 is Tree.rs, lockfile.rs, hoisted_install.rs, and the four tests.

@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.

Beyond the inline finding, I also checked the reorder of the input_dep_range.contains / res_id == package_id checks in hoist_dependency (src/install/lockfile/Tree.rs:1177-1185): both results reach a continue arm in process_subtree, so control flow is unchanged and only the marking differs. The third hoist::<Filter> caller in src/install/prune.rs:783 discards the new HoistResult, so the bit set is allocated there but unused, which is harmless.

Extended reasoning...

A confirmed finding on the sibling-group marking rule is already posted inline, so this body only records what else was examined. The HoistDependencyResult::Hoisted split was audited at every match site (Tree.rs:951, 990-991, 1179, 1184, 1197) and both new variants map to the same continue path as the old single variant; the hoist() return-type change was checked at all three callers (resolve, filter, and prune.rs), each compiling against the new struct with no dropped behavior.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Comment thread src/install/lockfile/Tree.rs Outdated
…s an optional peer

The parent's own dependency list decides, not the dependency that owns the
slot. This replaces HoistedOntoSibling, which only saw the case where the
parent's optional entry owned the slot.

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/install/lockfile/Tree.rs — pre-existing: projects on the isolated linker (the default for a fresh workspace lockfile) still get exit 0 or exit 1 for the same failed download depending on which parent sorts first, the exact shape of #43214. RequiredPackages::new at Tree.rs:669 hands the isolated linker an empty tree bitset, so contains falls to the walk, and the walk skips every peer edge at Tree.rs:750. The store entry asks with the dep_id of whichever node was created first (isolated_install.rs:2118). Fix: record peer bindings for the isolated store as well (mark the package a non-OPTIONAL peer resolves to), so contains gives one answer per package under both linkers; the PR calls this exclusion intentional, but isolated users keep the reported bug.

    Extended reasoning...

    The base (#43197) and this PR both leave the isolated linker on the lazy walk; nothing in the diff changes isolated_install.rs, so this is pre-existing, but it is the same class the PR title claims to close.
    Shape from the issue, isolated linker: root package.json dependencies { aaa, zzz }; one of them has peerDependencies { baz: "1.0.0" }, the other optionalDependencies { baz: "1.0.0" }. Lockfile present, cold cache, registry answers 404 for baz-1.0.0.tgz.
    isolated_install.rs:688 walks root deps in sorted order and pushes nodes; line 860 reverses so the first sibling is popped first (DFS).
    If aaa holds the optional edge: aaa's subtree creates the baz node with dep_id = the optional edge (line 711-717). zzz's peer baz later auto-installs (line 821, 846-854) a second baz node; early dedupe at line 535-550 merges it onto the first node (same pkg_id, same name). The store entry for baz keeps node_dep_ids = the optional edge.
    isolated_install.rs:2103 builds RequiredPackages::new, so tree is DynamicBitSet::default() with bit_length 0. Line 2419 calls contains(dep_id = optional…

    Verification: pre-existing — acknowledged in diff: the PR description says "The isolated linker still walks."; that note is accurate about the code but does not bound the consequence (the isolated linker keeps the exact order-dependent exit code #43214 describes). Triggered on the isolated linker (the default when a V1 lockfile has workspaces, /home/claude/bun/src/install/lockfile.rs:470) when one parent…

@robobun

robobun commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator Author

On the isolated linker finding: I ran the script from #43214 with linker = "isolated" against this branch. Both orders print error: failed to download baz@1.0.0: 404 Not Found and exit 1. The isolated installer reports every failed download itself and fails the install, also when only optional dependencies need the package (#43212). So the exit code does not depend on the name order there, and this PR leaves the isolated linker alone.

This branch has not been deployed

No deployments
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.

bun install: whether a failed download of a peer dependency's package is an error depends on the name order of its parents

1 participant