Skip to content

Windows: a locked node_modules/.bin shim aborts install with ERR_PNPM_PACKAGE_MANAGER_PRUNE_DIRECT_DEPS_REMOVE_BIN #14549

Description

@christopher-buss

pnpm version

12.3.4 (also reproduced on 12.3.2)

Which Node.js version are you using?

v26.5.1

Which operating systems have you used?

Windows 11 Pro, 10.0.26100

Describe the Bug

On Windows, an install aborts if any process holds a handle on a node_modules/.bin shim that the prune wants to delete.

Error: ERR_PNPM_PACKAGE_MANAGER_PRUNE_DIRECT_DEPS_REMOVE_BIN

  × installing dependencies
  ╰─▶ Failed to remove the bin shim at "\?\C:\...\node_modules\.bin\semver" of
      an excluded direct dependency: The process cannot access the file because
      it is being used by another process. (os error 32)

Exit code 1, and node_modules is left half-pruned: the direct link and every shim survive. Releasing the handle and re-running the identical pnpm install succeeds and completes the prune, so the failure is purely transient.

os error 32 is ERROR_SHARING_VIOLATION. The same call yields ERROR_ACCESS_DENIED (os error 5) under other share modes, which is what we originally hit in a real build: an antivirus scan collided with a prune, and the aborted install took an unrelated parallel job down with it.

Where it comes from

pnpm/crates/deps-restorer/src/prune_direct_deps.rs:257:

remove_bin(&shim_path)
    .map_err(|error| PruneDirectDepsError::RemoveBin { path: shim_path, error })?;

pnpm/crates/cmd-shim/src/link_bins.rs:1029 — remove_if_exists is a bare std::fs::remove_file that tolerates only NotFound:

fn remove_if_exists(path: &Path) -> io::Result<()> {
    match std::fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
        Err(error) => Err(error),
    }
}

So the removal has no retry and no tolerance for a locked file, even though every other removal on that path is already forgiving — the sibling remove_symlink_dir failure swallows NotFound, and remove_dep_bins' manifest read is documented as best-effort.

This looks like the third gap in an already-accepted family. #14349 (fixed by #14366) and #14407 (fixed by #14456) were the same Windows file-lock class in the hoisted and isolated linkers. #14456 added pnpm/crates/fs/src/retry.rs with a one-minute bounded backoff, and its classifier already matches ERROR_SHARING_VIOLATION (32) and ERROR_LOCK_VIOLATION (33). That crate exposes rename_with_retry and remove_dir_all_with_retry, but no file-removal equivalent, so the prune path cannot use it.

Note on v11

This is not a v12 regression. pnpm 11.21.0 fails on the same reproduction with EBUSY: resource busy or locked, unlink. The TypeScript CLI routed removeBin through @zkochan/rimraf, i.e. fs.promises.rm(..., { maxRetries: 3 }), so Node retried three times with a linear backoff before throwing. The Rust port retries zero times — the same fatal outcome, minus the one mitigation the JS path had.

Related, same shape

link_bins.rs::remove_stale_bin (ERR_PNPM_CMD_SHIM_REMOVE_STALE_BIN) clears a prior shim before writing the new one through the identical unguarded remove_file, so the write path has the same hole.

Cosmetic

The message says "of an excluded direct dependency" even when the dependency was removed from the manifest outright rather than excluded by a dependency group. This reproduction reaches it through prune_stale_modules, not the group-exclusion path.

Reproduction steps

mkdir $env:TEMP\pnpm-binlock-repro; cd $env:TEMP\pnpm-binlock-repro

'{ "name": "binlock-repro", "version": "1.0.0", "private": true,
   "dependencies": { "semver": "^7.7.0" } }' | Out-File -Encoding utf8 package.json

pnpm install
# node_modules/.bin now holds: semver, semver.cmd, semver.ps1

# Hold a deny-delete handle on the shim, as a scanner or indexer would:
$p = "$env:TEMP\pnpm-binlock-repro\node_modules\.bin\semver"
$fs = [System.IO.File]::Open($p, 'Open', 'Read', 'Read')

# Drop the dependency so the prune must remove that shim:
'{ "name": "binlock-repro", "version": "1.0.0", "private": true,
   "dependencies": {} }' | Out-File -Encoding utf8 package.json
pnpm install     # ERR_PNPM_PACKAGE_MANAGER_PRUNE_DIRECT_DEPS_REMOVE_BIN, exit 1

$fs.Close()
pnpm install     # succeeds, prune completes

One note for anyone reproducing: a .bin shim that is merely executing is still deletable — the child node process holds no handle on it, and MSYS bash opens with FILE_SHARE_DELETE. Hence the explicit deny-delete handle above.

Expected Behavior

Removing a stale .bin shim is cleanup, not a correctness requirement: the relink pass right after re-creates any shim a still-wanted dependency owns, and a leftover shim for a removed dependency is at worst cosmetic. So:

  1. Route remove_bin / remove_if_exists through the existing pnpm_fs retry helper, which already classifies exactly these errors.
  2. If it still fails after the retry budget, warn with the shim path and continue rather than failing the install.

At minimum, do not abort an install over a locked .bin shim.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state: acceptedThe required changes are defined. There is consensus on the change. Development can be startedtype: bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions