Skip to content
Open
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
6 changes: 6 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions test/lock-recovery.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
Loading