Repository navigation
fix(npm): Windows cache-lock contention aborts a concurrent first-run install with EPERM #133
Description
Summary
@fullstack-ai-infra/mem-mcp's installer can abort a concurrent first run on
Windows with a hard EPERM instead of waiting for the process that already
holds the cache lock.
The per-asset lock is taken with mkdir, which is atomic on every platform. The
retry loop in acquireAssetLock recognizes only one error code as "someone else
has it":
} catch (err) {
if (err.code !== "EEXIST") throw err; // npm/install.js:309 on main
}On Windows a contended mkdir does not reliably surface EEXIST. Node maps
the sharing/permission violation to EPERM (or EACCES), so ordinary contention
escapes as a fatal error, install() fails, and the user sees a failed install
even though nothing is wrong. POSIX hosts never see this because mkdir returns
EEXIST there.
Evidence (e2-source, upgraded to e3-reproduced by the linked PR)
Deterministic failure captured on the repository's own Windows gate,
npm wrapper compatibility (node24-windows):
✖ independent processes contend on one lock and download one binary (417.5845ms)
Error: worker 7700 failed: code=1 signal=null
Error: EPERM: operation not permitted, mkdir
'C:\Users\RUNNER~1\AppData\Local\Temp\mem-mcp-install-test-GY8wZU\cache\.mem-mcp-windows-amd64.exe.lock'
at mkdirSync (node:fs:1411:26)
at acquireAssetLock (D:\a\mem\mem\npm\install.js:296:7)
at async install (D:\a\mem\mem\npm\install.js:627:10)
- Run: https://github.com/bytefolk/mem/actions/runs/33359259126/job/99387306881
- This is the only occurrence recorded by the repository's Windows job so far.
That is expected for a timing-dependent race and is not evidence against the
diagnosis: the failing source line and the contention test are both onmain
today (verified at731a468), so no branch introduces the defect. - Reproduced deterministically off-Windows by injecting a single simulated
WindowsEPERMat the first lockmkdir: the reproduction is red on the
pre-fixnpm/install.jsand green on the fixed one, which isolates the error
classification as the cause rather than the runner. - The failing test is
test("independent processes contend on one lock and download one binary"), which is exactly the concurrent-first-run scenario a
real user hits when twomem-mcpprocesses start on a cold cache.
Impact
- A cold-cache parallel start (two agents or shells launching
mem-mcpat once)
can fail one of them on Windows with an unhelpfulEPERM. - Because
npm wrapper compatibility (node24-windows)is a required check that
every branch runs against this same installer, the race can redden any PR in
the repository and makes "green CI" a weaker signal. - No data corruption: the failure happens before any binary is published, and the
temp/lock cleanup paths are not implicated.
Requirements
- REQ-001: A lock acquisition that fails with the error codes Windows uses for a
contendedmkdirwaits for the holder and then proceeds, on Windows. - REQ-002: A permission failure that is not contention still fails closed and
immediately. It must not be swallowed into the 120-second wait path, so a
genuinely unwritable cache directory remains a clear error rather than a hang. - REQ-003: Non-Windows behavior is unchanged:
EEXISTremains contention, and
EPERM/EACCESremain fatal there. - REQ-004: The win32 branch must be provable on the host that runs the suite, so
the classification is not gated on having a Windows machine available.
Acceptance criteria
- AC-001:
isLockContentionclassifiesEEXISTon any platform and
EPERM/EACCESonly onwin32; unrelated codes and a code-lessErrorare
not contention. Unit-tested as a table, so it runs on every job. - AC-002: A regression test drives the contention path through the real
acquireAssetLockand is red on the pre-fix source and green after it. It
injects one simulated WindowsEPERMat the first lockmkdirand asserts the
call retries, takes the lock, writes its ownowner.json, and releases cleanly. - AC-003: A fail-closed test proves a permission error that is not contention
rejects instead of waiting out the deadline. - AC-004:
npm testpasses innpm/, and the previously flaking
npm wrapper compatibility (node24-windows)job is green on the exact head. - AC-005:
[Unreleased]inCHANGELOG.mdrecords the user-visible fix.
Non-goals and forbidden shortcuts
- No change to lock semantics: still one lock per asset, still
mkdir-atomic,
same stale/orphan reclaim thresholds, same 120-second deadline. - No
graceful-fs-style retry shim, no new dependency, no widening of the
contention set beyondEPERM/EACCESonwin32. - Not switching the lock primitive (for example to
open(..., 'wx')). The
mkdirdesign is sound; only the error classification is wrong. - No
try/except: passaround lock failures anywhere.
Lifecycle, status, priority
- priority: P2 — correctness bug on a supported platform plus a repository-wide
CI flake, with a retry workaround available to users. - Blockers: none.
- Open decisions: none. The classification set is fixed by Node's documented
error mapping, and REQ-002 keeps the fail-closed property. - Labels applied by the author:
type:bug,area:infra,severity:s2-medium,
evidence:e3-reproduced,status:ready. A maintainer should confirm triage.
Plan
Implement isLockContention(err, currentPlatform) in npm/install.js, resolve
osPlatform in acquireAssetLock the same way install() already does, thread
it into reclaimStaleLock, add the three tests above, and keep the diff to
npm/install.js, npm/install.test.js, and CHANGELOG.md.
Refs #122, #120, #113. The v0.1.1 npm publication in #122 ships this installer,
so landing it before the v0.1.1 tag is cut would put the fix in the first
public npm artifact.
Activity
PeterGuy326 commentedon Aug 31, 2026
Owner lifecycle clarification: the canonical Issue is R1 / in-progress. Its existing REQ/AC and non-goals are unchanged. The published v0.1.1 release is not moved or rewritten.
PeterGuy326 commentedon Aug 31, 2026
Implementation candidate evidence (not a merge ledger)
6f9b55cd9f091164297a5f44ad27708ec7914ad7, basecc727db0bc72655f299166de1f60756f5c686cc7.npm wrapper compatibility (node24-windows); run family: https://github.com/fullstack-ai-infra/mem/actions/runs/33425962843.@Bindy-lbbwas requested once for a current-head CODEOWNER review. The author and last pusher will not self-approve.This records a review-ready candidate only. It does not authorize merge, tag, npm publication, release, or Issue closure.
fix(npm): bound Windows cache-lock contention retries (#137)
waterbro-8 commentedon Sep 6, 2026
Closed by the assignee after AC-by-AC execution against
mainVerified on
7a194f1eba4167d54bd46cf84cdbe86e00532319(the squash of #137, currentmaintip), Node 24.19.0, Linux. Every number below was produced by running the suite on that tree, not by reading the diff.isLockContention(err, currentPlatform = platform())atnpm/install.js:247-250returnstrueforEEXISTon any platform and forEPERM/EACCESonly underwin32. The table atnpm/install.test.js:770is a plain synchronous test with 9 assertions coveringEEXIST×{linux,darwin,win32},EPERM/EACCESonwin32,EPERMon linux,EACCESon darwin,ENOENTon win32, and a code-lessError. It lives ininstall.test.js, which everynpm wrapperjob runs.npm/install.test.js:783drives the realacquireAssetLockin a child process, injects exactly one simulated WindowsEPERMat the first lockmkdir(anarmedlatch), and asserts the retry succeeds, the lock is taken, theowner.jsonnonce/pid belong to this process, and the lock directory is gone afterward. Baseline onmain:install.test.js30 tests / 30 pass / 0 fail. Revertinginstall.js:325to the pre-fix line this issue quotes (if (err.code !== "EEXIST") throw err;) turns 2 red — AC-002's own case plusa Windows lock inspection permission error fails promptly(30 / 28 pass / 2 fail).install.test.js:948(persistentEPERMwith no lock present) andinstall.test.js:981(EACCESwhile inspecting the lock). Each asserts the propagatederror.codeand that the wall time stayed under 1000 ms against a 10 swaitTimeoutMs, so a real permission failure cannot be swallowed into the wait path.install.test.js:1024covers the unrelated-code case propagating immediately. Second mutation control: forcing thewin32arm ofisLockContentiontoreturn falseturns exactly 3 red — the AC-001 table, AC-002, and:981— the same three recorded before the merge.npm testonmain: 42 tests / 41 pass / 0 fail / 1 skip (the skip is the pre-existing Windows-only.cmdshim case, not this fix).npm wrapper compatibility (node24-windows)issuccesson the exact landing headab8279a9c, onmain@7a194f1, and on 5 of 5 CI runs since the merge, all atrun_attempt=1with no reds — so the timing-dependent contention test that this issue captured failing has had five consecutive real-Windows passes, not one lucky pass.CHANGELOG.md→[Unreleased]→### Fixedrecords the user-visible change in full, including theEPERM/EACCESclassification and the preserved prompt-failure property.Non-goals and forbidden shortcuts, checked rather than assumed. The lock semantics are untouched:
LOCK_WAIT_TIMEOUT_MS = 120 * 1000,LOCK_STALE_MS = 10 * 60 * 1000,LOCK_POLL_MS = 50(install.js:39-42), the primitive is still a singlemkdirSync(lockPath, …)call site,npm/package.jsondeclares zero dependencies so nograceful-fs-style shim arrived, and #137's diff is confined to the three files this issue's Plan named (CHANGELOG.md,npm/install.js,npm/install.test.js).What I did not do. No concurrent install was run by hand on a Windows desktop; the Windows-side evidence is the repository's own
windows-latestjob plus the injected-EPERMcases. The two CI runs since the merge that were not all-green failed onWeb → Audit dependenciesand onGo— the npm advisory endpoint timeout/503 shape seen repeatedly in this repo, unrelated to the installer — and I did not chase either one here.What closing this does not mean. It records that the defect is fixed on
mainand that the acceptance criteria above hold. It is not a release action: the published0.1.1artifact predates this fix andnpm/package.jsonstill carries0.1.1, so the installer users actually get from npm today still has the old classification. That belongs to a separately authorized release.waterbro-8 commentedon Sep 8, 2026
The "this Windows box never throws EPERM from
mkdir" explanation is contradicted by this repo's own CI log — and the real-Windows contention slot is still openComment, not a review, not a vote, and not an argument for reopening #133.
waterbro-8, assignee and author of the closure comment above.Where this text comes from
I drafted it on 2026-09-06 as a reply to a four-round Windows report about #168's acceptance, circulated in chat. Two of that draft's four sections are now moot and are not reproduced below: the
web/audit-retry.mjsfail-open finding was fixed by its author in65da42bc272before I sent anything, and the "PR body has no tracking record" item resolved itself. What survives is the part that belongs on this issue rather than in chat, and I re-measured all of it today againstmain@2986fe38175f.1. What the report got right, now re-measured today on
mainnpm/install.jsis unchanged between7a194f1eba4and today'smain(blob55dcf41ca997df8e1ade15e2d265e5f9a436a11d, md57b8da775be88e66507580f385de402c5), so I re-ran the mutation there rather than quoting an old number. Oninstall.test.jsalone (blob49aa4e54de266b22e4848152e8c512103a48d4ab), Linux, Node 24.19.0:tests / pass / failisLockContention(install.js:247-250) reverted toreturn false, nothing else touchedThe three red are
EEXIST is contention everywhere while permission errors are contention only on win32(the all-platform table at:770),a contended Windows lock reported as EPERM waits for a proven lock(:783) anda Windows lock inspection permission error fails promptly(:981). That is the same three-set the report produced on7a194f1eba4and the same three I measured on 09-04 on the #137 merge treeab8279a9c3a, so AC-001..AC-003's load-bearing claim now has three runs on three trees behind it.install.jswas restored and its md5 re-checked after the experiment.:948(persistentEPERMwith no lock present) staying green under that mutation is correct, not a coverage gap: forcing the arm toreturn falsemakes the immediate throw that case asserts true.2. The root cause offered for "the mutation changed nothing in my runs" is false
The claim was that on that machine — NTFS under a WSL2 host —
fs.mkdirSyncon an existing directory always yieldsEEXISTand neverEPERM/EACCES, so thewin32arm cannot be reached there, possibly needing a specific Windows version, an ACL change, or an antivirus to trigger. This repo's own CI holds the counter-example, and I re-retrieved it today:99387306881,npm wrapper compatibility (node24-windows)33359259126/ attempt 1failure, completed2026-08-31T05:05:39Zwindows-2025(a GitHub-hosted image)Test cache, concurrency, and wrapper lifecycleindependent processes contend on one lock and download one binaryLine 215, with the runner's
2026-08-31T05:05:34.98xxxxxxZtimestamp prefix dropped from each line (every other byte is as logged; line 214 immediately above it isError: worker 7700 failed: code=1 signal=null):The stack frame is self-consistent: at head
46499b2ada, line 296 ofnpm/install.jsismkdirSync(lockPath, { mode: 0o700 });. So this is native Windows on a clean GitHub-hosted image, path inAppData\Local\Tempon NTFS — no special version, no modified ACL, no third-party lock holder.A caveat for anyone who goes looking:
GET /actions/runs/33359259126/jobstoday lists nine jobs, allsuccess, because that run was re-run. The failure lives inattempts/1. Readactions/jobs/<job_id>(it returnsrun_attemptandconclusiondirectly) rather than the run's current job list, or a real piece of evidence reads as a fabrication.3. The narrowing that the evidence actually supports
"Not reachable by my method", not "not reachable". Two processes calling
mkdirat the same instant getEEXIST; theEPERMabove needs the lock directory to be delete-pending — someone has just removed it while a handle inside it is still open. #133's own body described this as a timing-dependent race with one recorded occurrence, which is consistent with both readings.4. The slot #133's closure left open is still open, and here is a way to fill it that needs no race
acquireAssetLock(:297-339) prints nothing while it is contending — every log line in that flow comes from the caller after the lock is held. So "proc 1 loggedinstalled verified binary at …and proc 2 loggedverified existing binary atthe same path" is produced identically by (a) proc 2 colliding with the lock and being retried, and (b) proc 2 simply arriving after proc 1 finished, never colliding. stderr cannot separate them, which is what the report correctly observed.You do not need to win a race to exercise that arm. Pre-hold the lock instead, on the same
MEM_MCP_CACHE_DIRthe installer will use:<cache>/.mem-mcp-windows-amd64.exe.lock/containing anowner.jsonwhosehostnameis not the test machine's;Expected on the shipped code: the first
mkdiris a contention error, the loop polls, and at theLOCK_WAIT_TIMEOUT_MS = 120 sdefault it throwsTimed out waiting for mem-mcp cache lock: <path>(:334). Expected on thereturn falsemutant: it aborts immediately with the rawEPERM/EACCES. "Waits the full budget" versus "aborts at once" is the measurement, and it does not depend on luck.I ran that shape today on Linux against the exact shipped blob:
node /tmp/lock133.cjs <path to npm/install.js>, where the harnessrequire()s the installer's exportedacquireAssetLock(module.exportsat:782) and calls it againstnpm/install.jsfetched at2986fe38175f, withwaitTimeoutMsshortened to 5000 ms andpollMsto 100 ms — the defaults are 120 s and 50 ms. So these are the shipped code path's numbers, not a reimplementation's; the harness itself is session-local scratch, and the four steps above are the repeatable part.owner.json, mtime +1 hTimed out waiting for mem-mcp cache lockafter 5030 msowner.jsonat all, mtime +1 hThat table is a Linux run, so it exercises the wait/reclaim machinery and not the
currentPlatform === "win32"arm —EEXISTis contention everywhere. But it does settle one loose end from the report: the observation "I pre-created.lockwith a future mtime and the install still succeeded" cannot be produced by this code.ageis clamped at:270(Math.max(0, Date.now() - info.mtimeMs)), so a future mtime makesagezero and no reclaim branch can fire: a hostname mismatch returnsnullfromownerProcessIsAlive(:221) and lands onage >= staleMs, a same-host dead pid lands onage >= orphanGraceMs. Both are false at age 0. If an install completed in seconds with a lock supposedly in place, then the lock it looked at is not the lock that was created — most likely a differentMEM_MCP_CACHE_DIRbetween the two steps, or an asset-name mismatch in the.<asset>.lockfilename. Worth re-running with thelockPathfrom the thrown message pinned against the directory you created, before either of us calls it a defect. If it does reproduce with the paths equal, that is a separate bug and deserves its own issue rather than a comment on this closed one.5. What I am not claiming
EPERM. What the closure comment recorded as not-done is a hand-run pair on a real Windows box by the person doing the acceptance, and that slot is still empty. §4 is an offer of how to fill it without depending on a race going your way.install.test.jsonly (node --test install.test.js), notnpm test, and they are a Linux run. Neither reaches thewin32arm natively; that is the whole point of §2.main; what is open is one evidence slot and one root-cause sentence.