fix: add deadman ceiling to withPlanningLock (M1/M2 R4-FIX asymmetry)
The M1/M2 lock fix (4903ee04) was asymmetric: acquireStateLock got a 60s deadman ceiling (recovers a lock once its age crosses an absolute bound ABOVE the wait budget) but withPlanningLock did not. The .lock body carries no startTime, so _planningHolderVerifiedLive can only check pid liveness — it cannot detect pid reuse. A false-alive holder (original holder crashed, pid recycled by an unrelated live process) would therefore make withPlanningLock throw on every call with no self-heal until the reused pid happens to die. Mirror acquireStateLock: in the EEXIST branch, steal a verified-live holder anyway once the lock ages past deadmanCeilingMs (60000 > lockTimeout 10000). mtime age is measured from lock creation, so a stuck lock self-heals on a subsequent call. Adds a clock+probe-seam regression test (false-alive holder past the ceiling IS stolen). planning-workspace 13/13, clock-seam 34/34, locking suites green; lint clean. Claude-Session: https://claude.ai/code/session_01R88n7Q54bAaVHFkDbbH1yz
This commit is contained in:
@@ -289,4 +289,26 @@ describe('withPlanningLock PID-liveness staleness + EEXIST safety (audit M1+M2)'
|
||||
assert.ok(caught, 'helper must surface a failure rather than silently force-stealing a live lock');
|
||||
assert.notStrictEqual(caught.code, 'EEXIST', 'a raw EEXIST must never escape the lock helper (M2)');
|
||||
});
|
||||
|
||||
test('R4-FIX: false-alive pid-reuse holder aged past the deadman ceiling IS stolen (self-heal)', () => {
|
||||
const reusedPid = 7373;
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: reusedPid,
|
||||
cwd: tmpDir,
|
||||
acquired: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// Probe says the recorded pid is ALIVE — simulating pid-reuse: the original holder
|
||||
// crashed but its pid was recycled by an unrelated live process. The .lock body has
|
||||
// no startTime, so liveness alone cannot distinguish this from a genuine live holder.
|
||||
planningWorkspaceDirect._setLockProbes({ isPidAlive: (pid) => pid === reusedPid });
|
||||
|
||||
// Lock mtime ≈ now (real); seed the fake clock ABOVE the 60 000 ms deadman ceiling so
|
||||
// age = clock.now() - mtimeMs ≫ ceiling → the lock must be recovered despite "alive".
|
||||
// Without the ceiling, withPlanningLock would throw on every call with no self-heal.
|
||||
const clock = makeFakeClock(Date.now() + 120000);
|
||||
const result = withPlanningLock(tmpDir, () => 'self-healed', clock);
|
||||
assert.strictEqual(result, 'self-healed', 'a false-alive lock past the deadman ceiling must be stolen (no infinite block)');
|
||||
assert.ok(!fs.existsSync(lockPath), 'lock must be released after the critical section completes');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user