From 862f80bdfc6145ce24bad1bf528206b2c7b2c819 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 25 Feb 2026 10:32:57 +0530 Subject: [PATCH 1/2] fix(arborist): retry bin-links on Windows EPERM during linked strategy install On Windows, antivirus and search indexer can transiently lock files, causing write-file-atomic's fs.rename to fail with EPERM during the bin-linking phase. The linked strategy amplifies this by writing many store entries in parallel. Add retry with backoff (up to 5 attempts) for EPERM/EACCES/EBUSY errors in #createBinLinks, Windows only. --- workspaces/arborist/lib/arborist/rebuild.js | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/workspaces/arborist/lib/arborist/rebuild.js b/workspaces/arborist/lib/arborist/rebuild.js index 317cfc1df8a72..1872e732357ab 100644 --- a/workspaces/arborist/lib/arborist/rebuild.js +++ b/workspaces/arborist/lib/arborist/rebuild.js @@ -15,6 +15,30 @@ const { resolve } = require('node:path') const boolEnv = b => b ? '1' : '' const sortNodes = (a, b) => (a.depth - b.depth) || localeCompare(a.path, b.path) +// On Windows, antivirus/indexer can transiently lock files, causing +// EPERM/EACCES/EBUSY on the rename inside write-file-atomic (used by +// bin-links/fix-bin.js). Retry with backoff for up to ~7.5 seconds. +/* istanbul ignore next */ +const retryBinLinks = async (binLinks, node, opts, retries = 4) => { + const delay = (5 - retries) * 500 + await new Promise(r => setTimeout(r, delay)) + try { + return await binLinks({ + pkg: node.package, + path: node.path, + top: !!(node.isTop || node.globalTop), + force: opts.force, + global: !!node.globalTop, + }) + } catch (err) { + if (retries > 0 && + (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) { + return retryBinLinks(binLinks, node, opts, retries - 1) + } + throw err + } +} + const _checkBins = Symbol.for('checkBins') // defined by reify mixin @@ -387,6 +411,12 @@ module.exports = cls => class Builder extends cls { top: !!(node.isTop || node.globalTop), force: this.options.force, global: !!node.globalTop, + }).catch(/* istanbul ignore next - Windows retry for antivirus locks */ err => { + if (process.platform === 'win32' && + (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) { + return retryBinLinks(binLinks, node, this.options) + } + throw err }) await (this.#doHandleOptionalFailure From b77a47ee69593f186d4a5f957c27b87094dfc0ba Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Wed, 4 Mar 2026 11:55:34 +0530 Subject: [PATCH 2/2] Delegate retry logic to the external library - @gar/promise-retry --- DEPENDENCIES.md | 1 + package-lock.json | 1 + workspaces/arborist/lib/arborist/rebuild.js | 34 ++++----------------- workspaces/arborist/package.json | 1 + 4 files changed, 9 insertions(+), 28 deletions(-) diff --git a/DEPENDENCIES.md b/DEPENDENCIES.md index 10472884a7b2e..6ff49b76e23db 100644 --- a/DEPENDENCIES.md +++ b/DEPENDENCIES.md @@ -532,6 +532,7 @@ graph LR; npmcli-arborist-->bin-links; npmcli-arborist-->cacache; npmcli-arborist-->common-ancestor-path; + npmcli-arborist-->gar-promise-retry["@gar/promise-retry"]; npmcli-arborist-->hosted-git-info; npmcli-arborist-->isaacs-string-locale-compare["@isaacs/string-locale-compare"]; npmcli-arborist-->json-stringify-nice; diff --git a/package-lock.json b/package-lock.json index 1d41c0e575c87..3b95fb9245223 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14440,6 +14440,7 @@ "version": "9.4.0", "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", diff --git a/workspaces/arborist/lib/arborist/rebuild.js b/workspaces/arborist/lib/arborist/rebuild.js index 1872e732357ab..d4cce1ac02776 100644 --- a/workspaces/arborist/lib/arborist/rebuild.js +++ b/workspaces/arborist/lib/arborist/rebuild.js @@ -9,36 +9,13 @@ const runScript = require('@npmcli/run-script') const { callLimit: promiseCallLimit } = require('promise-call-limit') const { depth: dfwalk } = require('treeverse') const { isNodeGypPackage, defaultGypInstallScript } = require('@npmcli/node-gyp') +const { promiseRetry } = require('@gar/promise-retry') const { log, time } = require('proc-log') const { resolve } = require('node:path') const boolEnv = b => b ? '1' : '' const sortNodes = (a, b) => (a.depth - b.depth) || localeCompare(a.path, b.path) -// On Windows, antivirus/indexer can transiently lock files, causing -// EPERM/EACCES/EBUSY on the rename inside write-file-atomic (used by -// bin-links/fix-bin.js). Retry with backoff for up to ~7.5 seconds. -/* istanbul ignore next */ -const retryBinLinks = async (binLinks, node, opts, retries = 4) => { - const delay = (5 - retries) * 500 - await new Promise(r => setTimeout(r, delay)) - try { - return await binLinks({ - pkg: node.package, - path: node.path, - top: !!(node.isTop || node.globalTop), - force: opts.force, - global: !!node.globalTop, - }) - } catch (err) { - if (retries > 0 && - (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) { - return retryBinLinks(binLinks, node, opts, retries - 1) - } - throw err - } -} - const _checkBins = Symbol.for('checkBins') // defined by reify mixin @@ -405,19 +382,20 @@ module.exports = cls => class Builder extends cls { const timeEnd = time.start(`build:link:${node.location}`) - const p = binLinks({ + // On Windows, antivirus/indexer can transiently lock files, causing EPERM/EACCES/EBUSY on the rename inside write-file-atomic (used by bin-links/fix-bin.js), so, retry with backoff. + const p = promiseRetry((retry) => binLinks({ pkg: node.package, path: node.path, top: !!(node.isTop || node.globalTop), force: this.options.force, global: !!node.globalTop, - }).catch(/* istanbul ignore next - Windows retry for antivirus locks */ err => { + }).catch(/* istanbul ignore next - Windows-only transient antivirus locks */ err => { if (process.platform === 'win32' && (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EBUSY')) { - return retryBinLinks(binLinks, node, this.options) + return retry(err) } throw err - }) + }), { retries: 5, minTimeout: 500 }) await (this.#doHandleOptionalFailure ? this[_handleOptionalFailure](node, p) diff --git a/workspaces/arborist/package.json b/workspaces/arborist/package.json index 3b49201fe966b..ea0c5262103c7 100644 --- a/workspaces/arborist/package.json +++ b/workspaces/arborist/package.json @@ -3,6 +3,7 @@ "version": "9.4.0", "description": "Manage node_modules trees", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0",