fix: prevent a pinned locked peer provider from leaking to sibling nodes#12320
Conversation
Code Review by Qodo
1. Unnecessary parentPkgs cloning
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThis PR fixes a nondeterministic lockfile issue in the peer dependency resolver by preventing locked peer provider pinning from leaking into sibling resolutions. The fix includes a defensive clone operation, a regression test covering two processing orders, and changeset metadata. ChangesPeer Context Leak Prevention
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
PR Summary by QodoFix locked peer provider pinning leaking into sibling resolution WalkthroughsDescription• Prevent locked-peer provider pinning from mutating shared parent scope for childless nodes. • Eliminate nondeterministic peer suffix resolution that flaked pnpm dedupe --check in CI. • Add regression test covering both sibling insertion orders to ensure deterministic output. Diagramgraph TD
A["Dependencies tree"] --> B["resolvePeersOfNode()"] --> C{"lockedPeerContext present?"}
C -- "yes" --> D["Copy parentPkgs if aliased"] --> E["Pin locked peer provider"] --> F["Resolve siblings' peers"] --> G["Deterministic dep paths"]
C -- "no" --> F --> G
High-Level AssessmentThe following are alternative approaches to this PR: 1. Always clone parentPkgs for every node
2. Make parentPkgs immutable (persistent map / overlay chain)
3. Record pinned providers in a separate per-node structure
Recommendation: The PR’s approach (copy-on-alias only when entering the pinning path) is the best tradeoff: it fixes the leak with minimal behavior change and minimal overhead. Broader immutability or always-clone strategies could further harden the logic but would significantly increase runtime cost or refactor scope. File ChangesBug fix (1)
Tests (1)
Documentation (1)
|
|
Code review by qodo was updated up to the latest commit 107e935 |
|
Code review by qodo was updated up to the latest commit facb729 |
When the locked-peer-context pinning introduced in #12083 runs for a node that has no child dependencies, parentPkgs aliases the parent's object, so writing the pinned provider into it exposed the provider to every sibling resolved afterwards. Sibling order follows resolution completion order, so optional peers of siblings resolved nondeterministically and "pnpm dedupe --check" failed intermittently in CI. Copy parentPkgs before pinning so the pin stays scoped to the node and its own subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
facb729 to
07290e3
Compare
|
Code review by qodo was updated up to the latest commit 09051c4 |
Problem
pnpm dedupe --checkfails intermittently in CI on a large workspace, while runningpnpm dedupelocally on the same commit reports no changes. The diff that dedupe prints when it fails shows a dependency instance flipping an optional peer in its suffix between runs, for examplenext@16.2.6(@opentelemetry/api@1.9.1)...versusnext@16.2.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.1).... Both variants legitimately exist in the lockfile (the flipping package itself never appears in the added/removed package list, only its dependents do); what changes between runs is which variant a dependent gets linked against.The failures correlate with cache state: runs that download even a few packages (cold CI cache) can fail, runs that reuse everything (warm local cache) pass. That pointed at resolution order, since the dependency tree's child-map insertion order follows async resolution completion order.
Root cause
The locked-peer-context pinning introduced in #12083 (
1c73e8303c) mutatesparentPkgswhen it reuses a provider recorded in the lockfile:resolvePeers.ts#L514:parentPkgs[peerName] = lockedPeerThat write is intended to be scoped to the node being resolved. It isn't, because a node with no child dependencies never copies
parentPkgs— it aliases its parent's object:resolvePeers.ts#L451-L456Childless nodes are common in exactly the pinning scenario: peer dependencies are stripped from a snapshot's
dependencieswhen building the tree, so any package whose dependencies are all peers has zero children. When such a node pins a provider, the provider lands in the parent'sparentPkgsand becomes visible to every sibling resolved after it. A sibling with an optional peer of that name then resolves the peer it was never supposed to see, and gets a different dep path.Whether a given sibling runs before or after the leaking node depends on the iteration order of the children map, which is insertion order, which is network completion order. Hence: deterministic-looking locally, flaky in CI.
Reproduction
The added unit test (
installing/deps-resolver/test/resolvePeers.ts, "a provider pinned for a childless consumer does not leak to the consumer's siblings") resolves the same tree twice, with the childless pinning consumer and a sibling (victim, which has an optional peer on the pinned provider) in both insertion orders. Before the fix, the consumer-first order producesvictim/1.0.0(peer/2.0.0)while the victim-first order producesvictim/1.0.0— the output depends on sibling order. After the fix both orders producevictim/1.0.0.Fix
Copy
parentPkgsbefore the pinning loop when it aliases the parent's object, so the pin stays scoped to the node and its own subtree.Verification
@pnpm/installing.deps-resolversuite passes (109 tests).installing/deps-installer/test/install/peerDependencies.tspass, andpnpm/test/install/preferLockedPeerContexts.tspasses against a freshly rebuilt bundle.@pnpm/installing.dedupe.checktests pass.No pacquet change is needed: its lockfile-reuse path rebuilds children from the manifest and lets the peer pass derive each instance's context (see the pacquet section of
1c73e8303c), so it has no pinning write to leak. This fix brings the TypeScript side back in line with what pacquet already produces.Written by an agent (Claude Code, claude-fable-5).
🤖 Generated with Claude Code
Summary by CodeRabbit
pnpm dedupe --checkcaused by nondeterministic lockfile generation.