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..f6470a4 100644 --- a/npm/install.js +++ b/npm/install.js @@ -241,12 +241,26 @@ 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, mkdirError) { let info; try { info = lstatSync(lockPath); } catch (err) { - if (err.code === "ENOENT") return true; + // 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()) { @@ -285,6 +299,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; @@ -292,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 { @@ -306,10 +322,11 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { } return { lockPath, nonce }; } catch (err) { - if (err.code !== "EEXIST") throw err; + if (!isLockContention(err, osPlatform)) throw err; + mkdirError = err; } - if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs)) { + if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError)) { continue; } if (Date.now() >= deadline) { @@ -625,6 +642,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 +787,7 @@ module.exports = { downloadText, ensureCacheDirectory, install, + isLockContention, openResponse, releaseAssetLock, sha256File, diff --git a/npm/install.test.js b/npm/install.test.js index c22126d..da1b233 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,165 @@ 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 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 + // 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 { 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")) { + 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"))}); + setTimeout(() => fs.rmSync(lockPath, { recursive: true, force: true }), 25).unref(); + 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 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 + // fail-closed half of the classification on every platform. + const root = testDirectory(t); + 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", + ); +});