Skip to content

[BUG] EEXIST on workspace symlinks with --install-strategy=linked and legacy-peer-deps #9050

@manzoorwanijk

Description

@manzoorwanijk

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.

  1. 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" }
    
  2. Run first install — succeeds:

    npm install --install-strategy=linked
  3. 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=linked

Root 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 latest branch)
  • Node.js: v22
  • OS Name: macOS (Darwin 25.3.0)
  • System Model Name: (any)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions