From 58643ff70b71b8729c39c53be73671eeee075f6e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 22:40:02 -0400 Subject: [PATCH] fix(sdk): treat empty lock file as live in isLockProcessDead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Between `open(lockPath, O_CREAT | O_EXCL)` and `fd.writeFile(pid)` there is an async await gap. If a second process reads the lock file during that window it sees empty content, which parseInt returns as NaN. The previous code returned `true` (dead) for non-finite PID values, causing the second process to unlink the live lock and steal it — both processes then entered readModifyWriteStateMd simultaneously, producing a lost-update TOCTOU. Fix: return `null` (unknown) instead of `true` when the lock file contains no parseable PID. The caller already treats `null` as "not confirmed dead" and falls through to the normal retry + timeout path, giving the first process time to finish writing its PID. The CJS acquireStateLock in state.cjs never had this race because it uses synchronous fs.openSync / fs.writeSync / fs.closeSync with no async gap. Fixes intermittent failure of: #1925 TOCTOU: state commands use readModifyWriteStateMd → state add-blocker: both concurrent calls append different blockers (observed: macos-latest / Node 22 and windows-latest / Node 22 on main) Co-Authored-By: Claude Sonnet 4.6 --- sdk/src/query/state-mutation.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/src/query/state-mutation.ts b/sdk/src/query/state-mutation.ts index d9355bc33..70890199a 100644 --- a/sdk/src/query/state-mutation.ts +++ b/sdk/src/query/state-mutation.ts @@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise { try { const raw = await readFile(lockPath, 'utf-8'); const pid = parseInt(raw.trim(), 10); - if (!Number.isFinite(pid) || pid <= 0) return true; + // An empty or unparseable lock file means the writer opened the file with + // O_EXCL but hasn't finished writing the PID yet (async window between + // `open` and `writeFile`). Treat this as "unknown / still alive" — do NOT + // steal the lock, let the normal retry + timeout path handle it. + if (!Number.isFinite(pid) || pid <= 0) return null; try { process.kill(pid, 0); return false;