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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user