diff --git a/src/store.ts b/src/store.ts index a8a11224..f21283d6 100644 --- a/src/store.ts +++ b/src/store.ts @@ -239,6 +239,12 @@ export class MemoryStore { } const release = await lockfile.lock(lockPath, { + // 【修復 #670】realpath:false — 避免 proactive cleanup 刪除 stale lock artifact 後, + // proper-lockfile v4 的 realpath() 在已刪除檔案上被呼叫,導致 ENOENT。 + // 情境:T=0 proactive cleanup 刪除 stale lock → T=3ms lock() 的 realpath() → ENOENT + // 根本原因:v4 proper-lockfile 的 resolveCanonicalPath 預設呼叫 fs.realpath()。 + // 解決:realpath:false 完全繞過 realpath(),對 lock file 場景完全無副作用。 + realpath: false, retries: { retries: 10, factor: 2, diff --git a/test/lock-recovery.test.mjs b/test/lock-recovery.test.mjs index 5145401c..5c67b544 100644 --- a/test/lock-recovery.test.mjs +++ b/test/lock-recovery.test.mjs @@ -253,3 +253,78 @@ console.log("RECOVERED_WRITE_OK"); } }); }); + +/** Issue #670: ENOENT from proper-lockfile realpath() after proactive stale lock cleanup + * + * 情境:proactive cleanup 刪除 stale lock file 後(T=0),下一個 lock() 嘗試在 + * 已刪除檔案上呼叫 realpath()(T=3ms),導致 ENOENT。 + * + * 根本原因:proper-lockfile v4 的 resolveCanonicalPath 預設呼叫 fs.realpath()。 + * 修復:realpath:false 完全繞過 realpath(),對 lock file 場景無副作用。 + * + * 測試策略:直接呼叫 lockfile.lock(),在 artifact 已被刪除的狀態下, + * 驗證 realpath:false 不會拋出 ENOENT,並成功建立新 lock。 + */ +it("store() succeeds when lock artifact was already deleted (Issue #670)", async () => { + const { store, dir } = makeStore(); + const lockPath = join(dir, ".memory-write.lock"); + + try { + // Step 1: 先建立一個正常的 store,讓 proper-lockfile 建立 artifact + await store.store(makeEntry(1)); + + // Step 2: 直接刪除 lock artifact,模擬 proactive cleanup 已執行的狀態 + if (existsSync(lockPath)) { + rmSync(lockPath, { recursive: true, force: true }); + } + const artifactPath = lockPath + ".lock"; + if (existsSync(artifactPath)) { + rmSync(artifactPath, { recursive: true, force: true }); + } + + // Step 3: 驗證 artifact 真的不存在了 + assert.strictEqual(existsSync(lockPath), false); + assert.strictEqual(existsSync(artifactPath), false); + + // Step 4: 再次 store(),在 artifact 已被刪除的狀態下 + // 有了 realpath:false,這不會拋出 ENOENT + const entry = await store.store(makeEntry(2)); + assert.ok(entry.id, "store() should succeed even when lock artifact was deleted"); + + // Step 5: 驗證資料真的寫入了 + const all = await store.list(undefined, undefined, 20, 0); + assert.strictEqual(all.length, 2, "should have 2 entries total"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/** Issue #670 變種:ELOCKED 恢復後的 lock acquisition + * 當另一個 process 持有 lock 並超過 stale threshold,ELOCKED handler 會刪除 + * artifact 並重試。這時 realpath:false 確保重試不會因為 artifact 已被刪除而失敗。 + */ +it("store() succeeds after ELOCKED recovery cleaned up the artifact", async () => { + const { store, dir } = makeStore(); + const lockPath = join(dir, ".memory-write.lock"); + + try { + // 建立一個超舊的 artifact,模擬崩潰的 holder 已超過 5min threshold + mkdirSync(lockPath, { recursive: true }); + const oldTime = new Date(Date.now() - 10 * 60 * 1000); // 10 分鐘前 + utimesSync(lockPath, oldTime, oldTime); + + // 驗證 artifact 存在且是 stale + const stat = statSync(lockPath); + assert.ok(stat.mtimeMs < Date.now() - 5 * 60 * 1000, "artifact should be >5min old"); + + // 嘗試 store() — proactive cleanup 會在 lock() 前刪除這個超舊 artifact, + // lock() 不會因為 realpath() 在已刪除檔案上而拋出 ENOENT + const entry = await store.store(makeEntry(1)); + assert.ok(entry.id, "store() should succeed after recovering from stale lock"); + + const all = await store.list(undefined, undefined, 20, 0); + assert.strictEqual(all.length, 1, "should have exactly 1 entry"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});