fix(sdk): treat empty lock file as live in isLockProcessDead

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 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-20 22:40:02 -04:00
parent 556c1c0492
commit 58643ff70b

View File

@@ -127,7 +127,11 @@ async function isLockProcessDead(lockPath: string): Promise<boolean | null> {
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;