-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Description
Is there an existing issue for this?
- I have searched the existing issues
This issue exists in the latest npm version
- I am using the latest npm
Current Behavior
We are testing install-strategy=linked on the Gutenberg monorepo (~200 workspace packages).
Running npm install --install-strategy=linked a second time in a workspace monorepo that uses legacy-peer-deps=true and has workspace-to-workspace peer dependencies fails with:
npm error code EEXIST
npm error syscall symlink
npm error path ../../packages/theme
npm error dest /path/to/project/node_modules/@wordpress/theme
npm error errno -17
npm error EEXIST: file already exists, symlink '../../packages/theme' -> '/path/to/project/node_modules/@wordpress/theme'
Stack trace points to concurrent Promise.allSettled inside #reifyPackages:
Error: EEXIST: file already exists, symlink ...
at async symlink (node:internal/fs/promises:1008:10)
at async #extractOrLink (arborist/lib/arborist/reify.js:646:7)
at async Promise.allSettled (...)
at async #reifyPackages (arborist/lib/arborist/reify.js:309:11)
Introduced by commit 26fa40eeafdbbb616d48fe254c92544cb13fba60
("fix: fix workspace-filtered install with linked strategy").
Expected Behavior
npm install --install-strategy=linked should be idempotent — running it a second time on a project where workspace symlinks already exist should succeed without error.
Steps To Reproduce
Prerequisites: legacy-peer-deps=true in .npmrc and workspaces that have peer dependencies on other workspaces.
-
Create a workspace monorepo where some workspaces peer-depend on others:
my-project/ ├── .npmrc # legacy-peer-deps=true ├── package.json # { "workspaces": ["packages/*"] } ├── packages/ │ ├── ws-a/ │ │ └── package.json # { "name": "ws-a", "peerDependencies": { "ws-b": "*" }, "dependencies": { "abbrev": "1.0.0" } } │ └── ws-b/ │ └── package.json # { "name": "ws-b", "version": "1.0.0" } -
Run first install — succeeds:
npm install --install-strategy=linked
-
Run second install — fails with EEXIST:
npm install --install-strategy=linked
Note: The race condition requires sufficient concurrency to trigger reliably. Projects with many workspaces (e.g. Gutenberg with ~200 workspaces) hit it consistently. A minimal reproduction needs ~20 workspaces with cross peer deps.
Full reproduction script (copy-paste into terminal)
DIR=/tmp/npm-eexist-repro
rm -rf "$DIR"
mkdir -p "$DIR/packages/ws-"{0..19}
cat > "$DIR/package.json" <<'EOF'
{
"name": "repro-root",
"private": true,
"workspaces": ["packages/*"]
}
EOF
cat > "$DIR/.npmrc" <<'EOF'
legacy-peer-deps=true
EOF
for i in $(seq 0 19); do
next=$(( (i + 1) % 20 ))
cat > "$DIR/packages/ws-$i/package.json" <<EOF
{
"name": "ws-$i",
"version": "1.0.0",
"dependencies": { "abbrev": "*" },
"peerDependencies": { "ws-$next": "*" }
}
EOF
done
cd "$DIR"
echo "==> First install"
npm install --install-strategy=linked
echo "==> Second install (hits EEXIST)"
npm install --install-strategy=linkedRoot Cause
In isolated-reifier.js, assignCommonProperties resolves legacy peer dependencies via node.resolve(peerName) (line 159). For workspace dependencies, node.resolve() returns the Link node (e.g. at node_modules/@wordpress/theme) rather than its target (the fsChild at packages/theme).
The normal edge-based path correctly unwraps links via e.to.target (line 150), but the legacyPeerDeps fallback pushes the raw resolved node directly:
// line 149-150 — edges correctly unwrap via .target
const nonOptionalDeps = edges.filter(e => !e.optional).map(e => e.to.target)
// line 159-161 — legacyPeerDeps pushes Link node directly (BUG)
const resolved = node.resolve(peerName)
if (resolved && resolved !== node && !resolved.inert) {
nonOptionalDeps.push(resolved) // ← Link, not target!
}This causes workspaceProxy to be called with the Link node (location="node_modules/@wordpress/theme") instead of the fsChild (location="packages/theme"). Since the memoize key is the node object itself and the Link is a different object from the fsChild, a second workspace proxy is created with localLocation="node_modules/@wordpress/theme".
When processEdges processes this second proxy, it computes:
nmFolder = join("node_modules/@wordpress/theme", "node_modules")
Store links for the workspace's external deps (e.g. react) are then placed at node_modules/@wordpress/theme/node_modules/react instead of packages/theme/node_modules/react. During reification, these store links' mkdir(dirname(...), {recursive: true}) creates node_modules/@wordpress/theme as a directory, racing with the workspace symlink's rm() + symlink() at the same path — causing EEXIST.
Fix
In assignCommonProperties, unwrap Link nodes when resolving legacy peer deps, consistent with the edge-based path:
const resolved = node.resolve(peerName)
if (resolved && resolved !== node && !resolved.inert) {
- nonOptionalDeps.push(resolved)
+ nonOptionalDeps.push(resolved.target || resolved)
}Environment
- npm: 11.x (latest, built from
latestbranch) - Node.js: v22
- OS Name: macOS (Darwin 25.3.0)
- System Model Name: (any)