-
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
With install-strategy=linked, postinstall scripts run twice for every package in the store — once for the store entry and once for the symlink pointing to it. This causes race conditions in packages like esbuild whose postinstall modifies files in-place.
Specifically, esbuild's install.js has a maybeOptimizePackage() step that hard-links the native binary over its JS wrapper (bin/esbuild). When two instances run in parallel against the same directory:
- First run:
fs.linkSync(binPath, tempPath)succeeds,fs.renameSync(tempPath, toPath)replaces the JS wrapper with the native binary - Second run:
fs.linkSync(binPath, tempPath)fails with EEXIST (caught silently), falls back tonode bin/esbuild --version - But
bin/esbuildis now a Mach-O binary (from step 1), not a JS file →SyntaxError: Invalid or unexpected token
The install fails with:
npm error code 1
npm error path .../node_modules/.store/esbuild@0.27.2-.../node_modules/esbuild
npm error esbuild@0.27.2 postinstall: `node install.js`
Debug log confirms two runs at the same path:
info run esbuild@0.27.2 postinstall node_modules/.store/esbuild@0.27.2-xxx/node_modules/esbuild node install.js
info run esbuild@0.27.2 postinstall node_modules/.store/esbuild@0.27.2-xxx/node_modules/esbuild node install.js
Expected Behavior
Postinstall scripts should run once per package — on the store entry only. The store link (symlink) should be skipped since it points to the same directory.
Steps To Reproduce
rm -rf /tmp/esbuild-test
mkdir /tmp/esbuild-test && cd /tmp/esbuild-test
cat > package.json << 'EOF'
{
"name": "esbuild-test",
"devDependencies": {
"esbuild": "0.27.2"
}
}
EOF
npm install --install-strategy=linked
# Fails with SyntaxError: Invalid or unexpected tokenRoot Cause
In rebuild.js, #runScripts has a guard intended to skip store links:
const { ..., isStoreLink } = node.target
if (this[_trashList].has(path) || isStoreLink) { return }The problem is that isStoreLink is destructured from node.target (the store entry), but isStoreLink is only set on node itself (the link). Store entries don't have isStoreLink, so it's always undefined, and the guard never triggers.
Both the store entry and its symlink end up in the build queue (via #buildQueues → #retrieveNodesByType), and both run the same postinstall script against the same directory in parallel.
Environment
- npm: latest (main branch)
- Node.js: 22.x
- OS Name: macOS (Apple Silicon)
- npm config:
install-strategy=linked