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:
- Route
remove_bin / remove_if_exists through the existing pnpm_fs retry helper, which already classifies exactly these errors.
- 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.
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/.binshim that the prune wants to delete.Exit code 1, and
node_modulesis left half-pruned: the direct link and every shim survive. Releasing the handle and re-running the identicalpnpm installsucceeds and completes the prune, so the failure is purely transient.os error 32isERROR_SHARING_VIOLATION. The same call yieldsERROR_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:pnpm/crates/cmd-shim/src/link_bins.rs:1029—remove_if_existsis a barestd::fs::remove_filethat tolerates onlyNotFound: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_dirfailure swallowsNotFound, andremove_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.rswith a one-minute bounded backoff, and its classifier already matchesERROR_SHARING_VIOLATION(32) andERROR_LOCK_VIOLATION(33). That crate exposesrename_with_retryandremove_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 routedremoveBinthrough@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 unguardedremove_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
One note for anyone reproducing: a
.binshim that is merely executing is still deletable — the childnodeprocess holds no handle on it, and MSYS bash opens withFILE_SHARE_DELETE. Hence the explicit deny-delete handle above.Expected Behavior
Removing a stale
.binshim 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:remove_bin/remove_if_existsthrough the existingpnpm_fsretry helper, which already classifies exactly these errors.At minimum, do not abort an install over a locked
.binshim.