-
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
See Related Issues below. This is a known class of problem on Windows, but install-strategy=linked makes it significantly more likely to trigger.
This issue exists in the latest npm version
- I am using the latest npm
Reproducible with both npm 10.9.x and npm 11.x.
Current Behavior
We are testing install-strategy=linked on the Gutenberg monorepo (~200 workspace packages). On Windows, npm install fails with EPERM: operation not permitted during the reify phase. The rename happens inside write-file-atomic, which writes to a temp file and then calls fs.rename() to move it into place. On Windows, transient file locks from antivirus (Windows Defender) or the search indexer cause this rename to fail.
npm error code EPERM
npm error syscall rename
npm error path D:\a\gutenberg\gutenberg\node_modules\.store\jscodeshift@0.14.0-6sY5dqfkSNugpVGmHmy3mQ\node_modules\jscodeshift\bin\jscodeshift.js.2477615846
npm error dest D:\a\gutenberg\gutenberg\node_modules\.store\jscodeshift@0.14.0-6sY5dqfkSNugpVGmHmy3mQ\node_modules\jscodeshift\bin\jscodeshift.js
npm error errno -4048
npm error [Error: EPERM: operation not permitted, rename '...\jscodeshift.js.2477615846' -> '...\jscodeshift.js'] {
npm error errno: -4048,
npm error code: 'EPERM',
npm error syscall: 'rename',
npm error }
npm error The operation was rejected by your operating system.
Cleanup also fails with a similar EPERM:
npm warn cleanup Failed to remove some directories [
npm warn cleanup [
npm warn cleanup 'D:\\a\\gutenberg\\gutenberg\\node_modules',
npm warn cleanup [Error: EPERM: operation not permitted, rmdir '...\.store\@react-native\debugger-frontend@0.73.3-...\node_modules\@react-native\debugger-frontend'] {
npm warn cleanup errno: -4048,
npm warn cleanup code: 'EPERM',
npm warn cleanup syscall: 'rmdir',
npm warn cleanup }
npm warn cleanup ]
Expected Behavior
npm install should complete successfully on Windows with install-strategy=linked, handling transient file locks gracefully with retries.
Steps To Reproduce
This needs a Windows environment with real-time antivirus enabled (the default on GitHub Actions windows-2025 runners).
Standalone (PowerShell)
# Requires Node.js 20+ on Windows
mkdir test-linked
cd test-linked
npm init -y
# Enable linked strategy
"install-strategy=linked" | Out-File -Encoding utf8 .npmrc
# Install packages with many transitive deps and bin files with hashbangs
npm install jscodeshift@0.14.0 @react-native/debugger-frontend@0.73.3This may not fail every time with a small project. The race condition is more likely with large monorepos that write many .store entries in parallel. In our case, it happens with the Gutenberg monorepo (~200 workspace packages).
GitHub Actions
name: Reproduce EPERM
on: workflow_dispatch
jobs:
test:
runs-on: windows-2025
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: npm installWith an .npmrc containing install-strategy=linked and a sufficiently large dependency tree.
Failed CI run: https://github.com/WordPress/gutenberg/actions/runs/22299881844/job/64504879968?pr=75814
Environment
- npm: 10.9.x (also reproducible with npm 11.x)
- Node.js: v20.x
- OS Name: Windows Server 2025 (GitHub Actions
windows-2025) - npm config:
install-strategy=linked
Root Cause
The linked strategy stores packages in node_modules/.store/<pkg>@<version>-<hash>/node_modules/<pkg>/ and hard-links files from the npm cache. During reify, bin-links/fix-bin.js rewrites hashbang lines in bin files using write-file-atomic, which does a temp-file write followed by fs.rename().
On Windows, this rename fails because:
-
Transient file locks from antivirus: Windows Defender real-time scanning opens files as they're written. When the rename fires immediately after the write, the target may still be held open by the scanner. The linked strategy writes many files in parallel into
.store/, making it more likely that the scanner is still processing a file when the rename happens. -
No retry in
write-file-atomic: The package uses barerequire('fs')(line 7) and has no retry logic forrename. By contrast,graceful-fsalready patchesfs.renameon Windows with exponential backoff (up to 60s) for EACCES/EPERM/EBUSY errors (polyfills.jslines 96-120). Sincewrite-file-atomicuses barefs, it doesn't get this retry and fails immediately.graceful-fsWindows rename retry logicif (platform === "win32") { fs.rename = (function (fs$rename) { function rename (from, to, cb) { var start = Date.now() var backoff = 0; fs$rename(from, to, function CB (er) { if (er && (er.code === "EACCES" || er.code === "EPERM" || er.code === "EBUSY") && Date.now() - start < 60000) { setTimeout(function() { fs.stat(to, function (stater, st) { if (stater && stater.code === "ENOENT") fs$rename(from, to, CB); else cb(er) }) }, backoff) if (backoff < 100) backoff += 10; return; } if (cb) cb(er) }) } // ... })(fs.rename) }
-
Hard link locking across entries: Files in
.storeare hard-linked from the npm cache. On Windows, if any hard link to the same underlying inode is open (e.g., the cache copy being scanned), renaming the target fails with EPERM.
The hoisted layout writes packages directly into node_modules/, while the linked layout writes everything into .store/ first and then creates symlinks, resulting in more parallel file operations in a single directory tree and a larger window for antivirus lock conflicts.
Suggested Fixes
There are two places this could be addressed:
1. In npm (arborist): retry binLinks on Windows
The rebuild.js step in @npmcli/arborist could wrap the binLinks() call with retry logic for transient Windows errors. This handles the problem at the npm level without waiting on upstream changes to write-file-atomic.
// workspaces/arborist/lib/arborist/rebuild.js
async #binLinksWithRetry (node, retries = 5) {
try {
return await binLinks({
pkg: node.package,
path: node.path,
top: !!(node.isTop || node.globalTop),
force: this.options.force,
global: !!node.globalTop,
})
} catch (err) {
if (process.platform === 'win32' &&
retries > 0 &&
(err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) {
const delay = (6 - retries) * 500
await new Promise(r => setTimeout(r, delay))
return this.#binLinksWithRetry(node, retries - 1)
}
throw err
}
}We tested this approach in our fork and it resolves the issue on Windows CI.
2. In write-file-atomic: use graceful-fs
The underlying issue is that write-file-atomic uses bare require('fs') and misses the Windows rename retry that graceful-fs provides. Swapping it in would fix this for all consumers:
- const fs = require('fs')
+ const fs = require('graceful-fs')graceful-fs is already a transitive dependency across the npm ecosystem, so this wouldn't add new weight. This would be a separate issue/PR on the write-file-atomic repo.
Related Issues
This problem has been reported in various forms:
- npm/write-file-atomic#28 -- EPERM on Windows when multiple processes write the same file. Open since 2017, documents the same
fs.renamerace condition inwrite-file-atomic. Originally surfaced via Jest parallel workers (facebook/jest#4444). - npm/cli#8072 -- EPERM during
npm installon Windows with npm 10.x. Users report it works on macOS and with older npm 9.x, suggesting a regression in how npm 10 handles file operations on Windows. - npm/cli#6412 -- EPERM when
npm installtries tomkdiron a Windows drive root. A different trigger but the same underlying Windows file permission behavior.
The linked strategy amplifies the existing problem because it concentrates all file writes into .store/ with heavy parallelism, increasing the chance that antivirus scanning overlaps with rename operations.