Skip to content

[BUG] EPERM on Windows with install-strategy=linked: fs.rename fails in write-file-atomic #9021

@manzoorwanijk

Description

@manzoorwanijk

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.3

This 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 install

With 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:

  1. 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.

  2. No retry in write-file-atomic: The package uses bare require('fs') (line 7) and has no retry logic for rename. By contrast, graceful-fs already patches fs.rename on Windows with exponential backoff (up to 60s) for EACCES/EPERM/EBUSY errors (polyfills.js lines 96-120). Since write-file-atomic uses bare fs, it doesn't get this retry and fails immediately.

    graceful-fs Windows rename retry logic
    if (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)
    }
  3. Hard link locking across entries: Files in .store are 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.rename race condition in write-file-atomic. Originally surfaced via Jest parallel workers (facebook/jest#4444).
  • npm/cli#8072 -- EPERM during npm install on 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 install tries to mkdir on 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.

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