Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ The project publishes 0.x prerelease versions; a stable release line is not yet
the headers off a running nginx, since neither failure mode is visible by
reading the configuration.

### Fixed

- The npm installer no longer aborts a concurrent first run on Windows. The
per-asset cache lock previously treated only `EEXIST` as contention, but a
contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process
waiting for the lock holder failed outright instead of retrying. The retry
path now proves a lock can be inspected before treating those Windows errors
as contention, preserves prompt failure for unrelated permission errors, and
observes its deadline when a competing lock disappears during inspection.

## [0.1.1] - 2026-08-31

### Changed
Expand Down
39 changes: 30 additions & 9 deletions npm/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,25 @@ 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 a competing owner released
// the lock before inspection. It is safe to retry, but it must take the
// normal deadline/delay path rather than spin synchronously. A Windows
// EPERM/EACCES without a lock to inspect remains a real permission failure.
if (err.code === "ENOENT" && mkdirError.code === "EEXIST") return;
if (err.code === "ENOENT") throw mkdirError;
throw err;
}
if (info.isSymbolicLink() || !info.isDirectory()) {
Expand All @@ -261,13 +274,15 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs) {
: alive === true
? false
: age >= staleMs;
if (!reclaimable) return false;
if (!reclaimable) return;

const quarantine = `${lockPath}.stale.${process.pid}.${randomBytes(12).toString("hex")}`;
try {
renameSync(lockPath, quarantine);
} catch (err) {
if (err.code === "ENOENT" || err.code === "EEXIST") return true;
// Another contender changed the lock after we inspected it. Retrying is
// safe, but uses the normal poll path so repeated races cannot busy-loop.
if (err.code === "ENOENT" || err.code === "EEXIST") return;
throw err;
}

Expand All @@ -277,21 +292,22 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs) {
}
}
removeOwnedPath(quarantine);
return true;
}

async function acquireAssetLock(cacheDir, asset, options = {}) {
const waitTimeoutMs = options.waitTimeoutMs ?? LOCK_WAIT_TIMEOUT_MS;
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,12 +322,15 @@ 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)) {
continue;
}
// All failed acquisitions, including a successfully reclaimed stale lock,
// pass through the same deadline and abort-aware poll. This prevents a
// repeated create/release race from bypassing the wait budget in a tight
// synchronous loop.
reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError);
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for mem-mcp cache lock: ${lockPath}`);
}
Expand Down Expand Up @@ -625,6 +644,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 +789,7 @@ module.exports = {
downloadText,
ensureCacheDirectory,
install,
isLockContention,
openResponse,
releaseAssetLock,
sha256File,
Expand Down
Loading