Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 23 additions & 4 deletions npm/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down Expand Up @@ -285,13 +299,15 @@ 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;

for (;;) {
throwIfAborted(signal);
const nonce = randomBytes(12).toString("hex");
let mkdirError;
try {
mkdirSync(lockPath, { mode: 0o700 });
try {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -769,6 +787,7 @@ module.exports = {
downloadText,
ensureCacheDirectory,
install,
isLockContention,
openResponse,
releaseAssetLock,
sha256File,
Expand Down
166 changes: 166 additions & 0 deletions npm/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const { createHash } = require("node:crypto");
const { EventEmitter } = require("node:events");
const {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
Expand All @@ -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");
Expand Down Expand Up @@ -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",
);
});
Loading