From 1b3a2d2a6d5ce9290cfcd7ea0a0d691850564527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Mon, 31 Aug 2026 15:28:29 +0800 Subject: [PATCH 1/3] fix(npm): treat Windows cache-lock contention as retryable The per-asset cache lock is taken with `mkdir`, which reports a contended directory as EPERM or EACCES on Windows instead of EEXIST. acquireAssetLock recognised only EEXIST as contention, so a concurrent first run aborted with a hard permission error instead of waiting for the lock holder. Classify EPERM/EACCES as contention on win32 only, resolve osPlatform in the lock helpers the way install() already does so the win32 branch is provable off Windows, and leave a non-contention permission error failing closed. Fixes #133 --- CHANGELOG.md | 9 +++++ npm/install.js | 18 ++++++++-- npm/install.test.js | 81 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a23d92b..66e3146 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ## [Unreleased] +### Fixed + +- The npm installer no longer aborts a concurrent first run on Windows. The + per-asset cache lock is taken with `mkdir` and treated only `EEXIST` as + contention, but a contended `mkdir` on Windows raises `EPERM` or `EACCES`, so + a process waiting for the lock holder failed outright instead of retrying. + Both codes now count as contention on Windows, and a permission error that is + not contention still fails closed. + ## [0.1.1] - 2026-08-31 ### Changed diff --git a/npm/install.js b/npm/install.js index f293109..5ab74c7 100644 --- a/npm/install.js +++ b/npm/install.js @@ -241,12 +241,21 @@ function ownedArtifactPaths(cacheDir, asset, nonce) { ]; } -function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs) { +// Windows reports EPERM/EACCES rather than EEXIST when another process already +// owns the lock directory or is mid-create on it, so either code is ordinary +// contention there and must not be mistaken for a hard permission failure. +function isLockContention(err, currentPlatform = platform()) { + if (err.code === "EEXIST") return true; + return currentPlatform === "win32" && (err.code === "EPERM" || err.code === "EACCES"); +} + +function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, osPlatform) { let info; try { info = lstatSync(lockPath); } catch (err) { if (err.code === "ENOENT") return true; + if (isLockContention(err, osPlatform)) return false; throw err; } if (info.isSymbolicLink() || !info.isDirectory()) { @@ -285,6 +294,7 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { const staleMs = options.staleMs ?? LOCK_STALE_MS; const orphanGraceMs = options.orphanGraceMs ?? LOCK_ORPHAN_GRACE_MS; const pollMs = options.pollMs ?? LOCK_POLL_MS; + const osPlatform = options.osPlatform || platform(); const signal = options.signal; const lockPath = path.join(cacheDir, `.${asset}.lock`); const deadline = Date.now() + waitTimeoutMs; @@ -306,10 +316,10 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { } return { lockPath, nonce }; } catch (err) { - if (err.code !== "EEXIST") throw err; + if (!isLockContention(err, osPlatform)) throw err; } - if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs)) { + if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, osPlatform)) { continue; } if (Date.now() >= deadline) { @@ -625,6 +635,7 @@ async function install(options = {}) { throwIfAborted(signal); ensureCacheDirectory(cacheDir); lock = await acquireAssetLock(cacheDir, asset, { + osPlatform, waitTimeoutMs: options.lockWaitTimeoutMs, staleMs: options.lockStaleMs, orphanGraceMs: options.lockOrphanGraceMs, @@ -769,6 +780,7 @@ module.exports = { downloadText, ensureCacheDirectory, install, + isLockContention, openResponse, releaseAssetLock, sha256File, diff --git a/npm/install.test.js b/npm/install.test.js index c22126d..5a64753 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -6,6 +6,7 @@ const { createHash } = require("node:crypto"); const { EventEmitter } = require("node:events"); const { chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -21,12 +22,15 @@ const { PassThrough } = require("node:stream"); const test = require("node:test"); const { assetFor } = require("./platforms"); const { + acquireAssetLock, cacheDirectory, cacheRootFor, checksumForAsset, downloadText, install, + isLockContention, openResponse, + releaseAssetLock, } = require("./install"); const ASSET = assetFor("linux", "x64"); @@ -763,3 +767,80 @@ test("a logger failure after atomic publish preserves only the verified final", assert.deepEqual(readFileSync(binPath), bytes); assert.deepEqual(readdirSync(cacheDir), [ASSET]); }); + +test("EEXIST is contention everywhere while a permission error is contention only on win32", () => { + const withCode = (code) => Object.assign(new Error(code), { code }); + assert.equal(isLockContention(withCode("EEXIST"), "linux"), true); + assert.equal(isLockContention(withCode("EEXIST"), "darwin"), true); + assert.equal(isLockContention(withCode("EEXIST"), "win32"), true); + assert.equal(isLockContention(withCode("EPERM"), "win32"), true); + assert.equal(isLockContention(withCode("EACCES"), "win32"), true); + assert.equal(isLockContention(withCode("EPERM"), "linux"), false); + assert.equal(isLockContention(withCode("EACCES"), "darwin"), false); + assert.equal(isLockContention(withCode("ENOENT"), "win32"), false); + assert.equal(isLockContention(new Error("no code"), "win32"), false); +}); + +test("a contended lock reported as EPERM is retried instead of aborting the install", async (t) => { + // Windows raises EPERM, not EEXIST, when a competing process already holds the + // lock directory. install.js destructures mkdirSync at load time, so the patch + // has to be in place before the module is first required, which a child process + // gives us without disturbing the other tests in this file. + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const realMkdirSync = fs.mkdirSync; + let armed = true; + fs.mkdirSync = function (target, ...rest) { + if (armed && String(target).endsWith(".lock")) { + armed = false; + throw Object.assign(new Error("simulated Windows contention"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + const { acquireAssetLock, releaseAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + const cacheDir = ${JSON.stringify(cacheDir)}; + // The win32 gate is what turns this error into contention, so it is injected + // rather than taken from the host that happens to run the suite. + acquireAssetLock(cacheDir, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 5000, + }) + .then((lock) => { + const owner = JSON.parse(fs.readFileSync(join(lock.lockPath, "owner.json"), "utf8")); + if (owner.pid !== process.pid) throw new Error("lock is not owned by this process"); + releaseAssetLock(lock); + }) + .catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; + }); + `); + assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); +}); + +test("a permission failure that is not contention still fails closed", async (t) => { + if (typeof process.getuid === "function" && process.getuid() === 0) { + t.skip("root ignores the read-only cache directory mode"); + return; + } + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + chmodSync(cacheDir, 0o500); + try { + await assert.rejects( + acquireAssetLock(cacheDir, ASSET, { osPlatform: "linux", pollMs: 1, waitTimeoutMs: 250 }), + (error) => error.code === "EACCES" || error.code === "EPERM", + ); + } finally { + chmodSync(cacheDir, 0o700); + } +}); From cb9c5cfe75c29df6913a32d34432d2d1fa005451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Mon, 31 Aug 2026 16:15:34 +0800 Subject: [PATCH 2/3] test(npm): make the fail-closed lock probe platform independent The first version of this test chmodded the cache directory to 0o500 and expected a permission error. NTFS ignores the POSIX mode, so mkdir succeeded, acquireAssetLock took the lock, and the Windows job rejected the test rather than the code. The fix itself passed unchanged on real Windows. Use ENOENT from a lock mkdir under a missing parent, which both POSIX and Windows raise, and bound the elapsed time so a future change that swallows a non-contention error into the retry loop fails fast instead of hanging. --- npm/install.test.js | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/npm/install.test.js b/npm/install.test.js index 5a64753..9776750 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -826,21 +826,21 @@ test("a contended lock reported as EPERM is retried instead of aborting the inst assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); }); -test("a permission failure that is not contention still fails closed", async (t) => { - if (typeof process.getuid === "function" && process.getuid() === 0) { - t.skip("root ignores the read-only cache directory mode"); - return; - } +test("a non-contention error propagates immediately instead of entering the wait loop", async (t) => { + // ENOENT is raised by a lock mkdir under a missing parent on both POSIX and + // Windows, unlike a read-only mode which NTFS ignores, so this pins the + // fail-closed half of the classification on every platform. const root = testDirectory(t); - const cacheDir = join(root, "cache"); - mkdirSync(cacheDir, { recursive: true }); - chmodSync(cacheDir, 0o500); - try { - await assert.rejects( - acquireAssetLock(cacheDir, ASSET, { osPlatform: "linux", pollMs: 1, waitTimeoutMs: 250 }), - (error) => error.code === "EACCES" || error.code === "EPERM", - ); - } finally { - chmodSync(cacheDir, 0o700); - } + const missing = join(root, "no-such-parent", "cache"); + const startedAt = Date.now(); + await assert.rejects( + acquireAssetLock(missing, ASSET, { osPlatform: "linux", pollMs: 1, waitTimeoutMs: 10_000 }), + (error) => error.code === "ENOENT", + ); + // Comfortably under the deadline, so a regression that turns this into + // contention fails here rather than by hanging for ten seconds. + assert.ok( + Date.now() - startedAt < 5000, + "expected an immediate rejection, not a wait", + ); }); From 30e70a712aca646e8f84d7e36879bba148151363 Mon Sep 17 00:00:00 2001 From: PeterGuy326 <47820304+PeterGuy326@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:25:52 +0800 Subject: [PATCH 3/3] fix(npm): fail closed on unproven Windows lock contention --- npm/install.js | 15 ++++++-- npm/install.test.js | 93 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/npm/install.js b/npm/install.js index 5ab74c7..f6470a4 100644 --- a/npm/install.js +++ b/npm/install.js @@ -249,13 +249,18 @@ function isLockContention(err, currentPlatform = platform()) { return currentPlatform === "win32" && (err.code === "EPERM" || err.code === "EACCES"); } -function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, osPlatform) { +function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError) { let info; try { info = lstatSync(lockPath); } catch (err) { - if (err.code === "ENOENT") return true; - if (isLockContention(err, osPlatform)) return false; + // An EEXIST result followed by ENOENT means the competing owner released + // the lock before we inspected it, so retrying is safe. A Windows EPERM or + // EACCES is only provisional contention: without a lock to inspect it is a + // real mkdir failure and must not turn into an unbounded retry loop. + if (err.code === "ENOENT" && mkdirError.code === "EEXIST") return true; + if (err.code === "ENOENT") throw mkdirError; + // Never convert an inspection permission error into a lock timeout. throw err; } if (info.isSymbolicLink() || !info.isDirectory()) { @@ -302,6 +307,7 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { for (;;) { throwIfAborted(signal); const nonce = randomBytes(12).toString("hex"); + let mkdirError; try { mkdirSync(lockPath, { mode: 0o700 }); try { @@ -317,9 +323,10 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { return { lockPath, nonce }; } catch (err) { if (!isLockContention(err, osPlatform)) throw err; + mkdirError = err; } - if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, osPlatform)) { + if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError)) { continue; } if (Date.now() >= deadline) { diff --git a/npm/install.test.js b/npm/install.test.js index 9776750..da1b233 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -781,7 +781,7 @@ test("EEXIST is contention everywhere while a permission error is contention onl assert.equal(isLockContention(new Error("no code"), "win32"), false); }); -test("a contended lock reported as EPERM is retried instead of aborting the install", async (t) => { +test("a contended Windows lock reported as EPERM waits for a proven lock", async (t) => { // Windows raises EPERM, not EEXIST, when a competing process already holds the // lock directory. install.js destructures mkdirSync at load time, so the patch // has to be in place before the module is first required, which a child process @@ -791,8 +791,19 @@ test("a contended lock reported as EPERM is retried instead of aborting the inst mkdirSync(cacheDir, { recursive: true }); await runWorker(t, ` const fs = require("node:fs"); + const { hostname } = require("node:os"); const { join } = require("node:path"); const realMkdirSync = fs.mkdirSync; + const cacheDir = ${JSON.stringify(cacheDir)}; + const lockPath = join(cacheDir, ".${ASSET}.lock"); + // Model the Windows-only EPERM result while another process has a real + // lock directory. Without this directory, EPERM is a permission failure, + // not evidence of contention. + realMkdirSync(lockPath, { mode: 0o700 }); + fs.writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ pid: process.pid, hostname: hostname(), nonce: "a".repeat(24) }) + "\\n", + ); let armed = true; fs.mkdirSync = function (target, ...rest) { if (armed && String(target).endsWith(".lock")) { @@ -805,9 +816,7 @@ test("a contended lock reported as EPERM is retried instead of aborting the inst return realMkdirSync.call(this, target, ...rest); }; const { acquireAssetLock, releaseAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); - const cacheDir = ${JSON.stringify(cacheDir)}; - // The win32 gate is what turns this error into contention, so it is injected - // rather than taken from the host that happens to run the suite. + setTimeout(() => fs.rmSync(lockPath, { recursive: true, force: true }), 25).unref(); acquireAssetLock(cacheDir, ${JSON.stringify(ASSET)}, { osPlatform: "win32", pollMs: 1, @@ -826,6 +835,82 @@ test("a contended lock reported as EPERM is retried instead of aborting the inst assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); }); +test("a persistent Windows EPERM without a lock fails promptly instead of retrying", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("simulated Windows permission failure"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + const { acquireAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + const startedAt = Date.now(); + acquireAssetLock(${JSON.stringify(cacheDir)}, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 10_000, + }) + .then(() => { + throw new Error("expected a permission failure"); + }) + .catch((error) => { + if (error.code !== "EPERM") throw error; + if (Date.now() - startedAt >= 1_000) throw new Error("permission error entered the retry loop"); + }); + `); +}); + +test("a Windows lock inspection permission error fails promptly", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + const realLstatSync = fs.lstatSync; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("simulated Windows contention"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + fs.lstatSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("simulated inspection permission failure"), { + code: "EACCES", + syscall: "lstat", + }); + } + return realLstatSync.call(this, target, ...rest); + }; + const { acquireAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + const startedAt = Date.now(); + acquireAssetLock(${JSON.stringify(cacheDir)}, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 10_000, + }) + .then(() => { + throw new Error("expected an inspection failure"); + }) + .catch((error) => { + if (error.code !== "EACCES") throw error; + if (Date.now() - startedAt >= 1_000) throw new Error("inspection error entered the retry loop"); + }); + `); +}); + test("a non-contention error propagates immediately instead of entering the wait loop", async (t) => { // ENOENT is raised by a lock mkdir under a missing parent on both POSIX and // Windows, unlike a read-only mode which NTFS ignores, so this pins the