diff --git a/src/planning-workspace.cts b/src/planning-workspace.cts index cf8bd8a10..b1ca382f4 100644 --- a/src/planning-workspace.cts +++ b/src/planning-workspace.cts @@ -37,6 +37,53 @@ process.on('exit', () => { } }); +// --------------------------------------------------------------------------- +// Lock liveness probe (test seam) — audit M1 +// +// mtime is a leaky proxy for "the holder is alive". The prior withPlanningLock +// timeout fallback unconditionally unlinked WHATEVER lock existed — even a fresh, +// live holder's — and re-acquired it, force-stealing a live writer's critical +// section. We backport capability-lock.cts's pid-liveness gate: a dead holder is +// stolen promptly inside the polite loop; a live holder is waited on. The +// indirection lets unit tests inject a deterministic isPidAlive without real pids. +// --------------------------------------------------------------------------- + +/** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */ +function _realIsPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; // signalable → alive + } catch (err) { + // EPERM = process exists but we cannot signal it (still ALIVE). ESRCH = gone. + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +const _planningLockProbes: { isPidAlive: (pid: number) => boolean } = { isPidAlive: _realIsPidAlive }; + +function _planningLockIsPidAlive(pid: number): boolean { + return _planningLockProbes.isPidAlive(pid); +} + +/** + * Is the holder recorded in the .lock body VERIFIED-LIVE? The body is JSON + * { pid, cwd, acquired }. Returns true ONLY when the body parses AND the recorded + * pid signals alive. A garbage / pid-less / unreadable body (or a dead pid) is NOT + * verified-live, so the lock stays stealable — corrupt locks never block forever, + * and a live holder is never force-stolen. + */ +function _planningHolderVerifiedLive(lockPath: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + } catch { + return false; // unreadable / unparseable body → cannot verify → not verified-live + } + const pid = (parsed as { pid?: unknown } | null)?.pid; + if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false; + return _planningLockIsPidAlive(pid); +} + // Transient errno codes that indicate a temporary filesystem condition under // concurrent O_EXCL races — Docker overlay-fs (ENOENT/EINVAL/EIO), NFS // (ESTALE), and OS-level interrupt/retry signals (EAGAIN/EINTR). These are @@ -160,16 +207,18 @@ function withPlanningLock(cwd: string, fn: () => T, clock?: Clock): T { continue; } if (nodeErr.code === 'EEXIST') { - // Lock exists — check if stale (>30s old) + // Liveness-gated steal (audit M1). Steal the lock PROMPTLY only when its + // recorded holder is NOT verified-live (crashed/dead pid or garbage body). + // A verified-live holder is waited on — never force-stolen — because nuking + // a slow-but-live writer's lock corrupts the .planning/ critical section. try { - const stat = fs.statSync(lockPath); - if (clock.now() - stat.mtimeMs > 30000) { + if (!_planningHolderVerifiedLive(lockPath)) { fs.unlinkSync(lockPath); - continue; // retry + continue; // dead/garbage holder — retry immediately to grab the freed lock } } catch { continue; } - // Wait and retry (cross-platform, no shell dependency) + // Live holder — wait and retry (cross-platform, no shell dependency). clock.sleep(100); continue; } @@ -177,10 +226,18 @@ function withPlanningLock(cwd: string, fn: () => T, clock?: Clock): T { } } - // Timeout — stale-lock recovery, then re-acquire atomically before entering critical section. - try { fs.unlinkSync(lockPath); } catch { /* ok */ } - acquireLock(); - return runWithHeldLock(); + // Timeout against a holder still present at budget exhaustion. The polite loop + // already stole any DEAD holder; reaching here means the holder is verified-live + // (or a pid-reuse alias we must not corrupt). Do NOT force-steal — the prior + // unconditional `unlinkSync(lockPath); acquireLock()` here (audit M1) robbed live + // writers, and its re-acquire sat OUTSIDE any try so a concurrent re-create raced + // a raw EEXIST out of the helper (audit M2). Surface a clear timeout error instead. + const timeoutErr = new Error( + 'withPlanningLock: ' + lockPath + ' held by a live process for ' + + (clock.now() - start) + 'ms (exceeded ' + lockTimeout + 'ms budget)' + ); + (timeoutErr as unknown as Record).lockTimeout = true; + throw timeoutErr; } function createPlanningWorkspace(cwd: string, opts: WorkstreamAdapterOpts = {}): { @@ -269,4 +326,12 @@ export = { getActiveWorkstream, setActiveWorkstream, findContextMdIn, + // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated + // steal decision is exercised without real pids. Mirrors capability-lock.cts. + _setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void { + if (typeof probes.isPidAlive === 'function') _planningLockProbes.isPidAlive = probes.isPidAlive; + }, + _resetLockProbes(): void { + _planningLockProbes.isPidAlive = _realIsPidAlive; + }, }; diff --git a/src/state.cts b/src/state.cts index 14d819ffa..45f40fba2 100644 --- a/src/state.cts +++ b/src/state.cts @@ -152,6 +152,54 @@ process.on('exit', () => { } }); +// --------------------------------------------------------------------------- +// Lock liveness probe (test seam) — audit M1 +// +// mtime is a LEAKY proxy for "the holder is still alive": a live-but-slow writer +// whose critical section runs past staleThresholdMs ages out and a waiter would +// steal its lock → two writers in STATE.md's read-modify-write window → lost +// update / corruption (the recurring #500/#905/#1230 family). The real signal — +// process.kill(pid, 0) — is already used by capability-lock.cts. We backport it +// here. The indirection lets unit tests inject a deterministic isPidAlive without +// real pids (mirrors capability-lock's _lockProbes / _setLockProbes seam). +// --------------------------------------------------------------------------- + +/** Is `pid` a live process? process.kill(pid, 0) succeeds for a live (signalable) process. */ +function _realIsPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; // signalable → alive + } catch (err) { + // EPERM = process exists but we cannot signal it (still ALIVE). ESRCH = gone. + return (err as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +const _stateLockProbes: { isPidAlive: (pid: number) => boolean } = { isPidAlive: _realIsPidAlive }; + +function _stateLockIsPidAlive(pid: number): boolean { + return _stateLockProbes.isPidAlive(pid); +} + +/** + * Is the holder recorded in the lock body VERIFIED-LIVE? The STATE.md lock body is + * a bare pid (written at acquire time). Returns true ONLY when the body parses to a + * positive integer pid AND that pid signals alive. A garbage / non-numeric / legacy + * body (or a dead pid) is NOT verified-live, so the lock stays stealable — corrupt + * locks never block forever, and a live holder is never stolen. + */ +function _stateHolderVerifiedLive(lockPath: string): boolean { + let body: string; + try { + body = fs.readFileSync(lockPath, 'utf-8'); + } catch { + return false; // unreadable body → cannot verify → not verified-live (stealable under ceiling) + } + const pid = parseInt(body.trim(), 10); + if (!Number.isInteger(pid) || pid <= 0 || String(pid) !== body.trim()) return false; + return _stateLockIsPidAlive(pid); +} + // Hoisted to module scope — compiled once, not per call (#320). Stateless (/i, used with .match). const byPhaseTablePattern = /(\|\s*Phase\s*\|\s*Plans\s*\|\s*Total\s*\|\s*Avg\/Plan\s*\|[ \t]*\n\|(?:[- :\t]+\|)+[ \t]*\n)((?:[ \t]*\|[^\n]*\n)*)(?=\n|$)/i; @@ -1587,8 +1635,14 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string { if (clock === undefined) clock = realClock; const lockPath = statePath + '.lock'; const retryDelay = 200; // ms - const staleThresholdMs = 10000; const maxWaitMs = 30000; + // Deadman ceiling (audit M1) — set ABOVE maxWaitMs so a holder that reads as + // VERIFIED-LIVE is NEVER stolen within the wait budget; only a crashed (dead + // pid) or unparseable-body lock is stolen, and a pid-reuse holder (reads alive + // but is unrelated) is recovered once age crosses this absolute ceiling rather + // than blocking forever. The prior mtime-only `staleThresholdMs = 10000` gate + // was BELOW maxWaitMs, so a live-but-slow holder >10 s was robbed mid-write. + const deadmanCeilingMs = 60000; const startedAt = clock.now(); // Shared helper: check the time budget then back off with jitter before the @@ -1625,12 +1679,18 @@ function acquireStateLock(statePath: string, clock?: StateLockClock): string { continue; } if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err; // propagate — silent bypass causes lost updates - // Only unlink a lock we did not place when it has crossed the staleness - // threshold (crashed holder). Nuking a fresh lock held by a slow-but-live - // writer causes lost updates (#3711 regression). + // Liveness-gated steal (audit M1). Only unlink a lock we did not place when + // either (a) its recorded holder is NOT verified-live — a crashed/dead pid or + // a garbage/legacy body — so it is stolen PROMPTLY regardless of age, or + // (b) its age has crossed the absolute deadman ceiling (set above maxWaitMs) + // — the pid-reuse backstop. A VERIFIED-LIVE holder under the ceiling is NEVER + // stolen, even if older than the old mtime-only threshold: nuking a slow-but- + // live writer's lock causes lost updates (#3711 / #500/#905/#1230 family). try { const stat = fs.statSync(lockPath); - if ((clock).now() - stat.mtimeMs > staleThresholdMs) { + const ageMs = clock.now() - stat.mtimeMs; + const holderLive = _stateHolderVerifiedLive(lockPath); + if (!holderLive || ageMs > deadmanCeilingMs) { let removed = false; try { fs.unlinkSync(lockPath); removed = true; } catch { /* swallow: bounded below */ } if (removed) { @@ -2891,4 +2951,12 @@ export = { cmdStateMilestoneSwitch, cmdSignalWaiting, cmdSignalResume, + // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated + // steal decision is exercised without real pids. Mirrors capability-lock.cts. + _setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void { + if (typeof probes.isPidAlive === 'function') _stateLockProbes.isPidAlive = probes.isPidAlive; + }, + _resetLockProbes(): void { + _stateLockProbes.isPidAlive = _realIsPidAlive; + }, }; diff --git a/tests/clock-seam.test.cjs b/tests/clock-seam.test.cjs index e87a45181..334cb70e9 100644 --- a/tests/clock-seam.test.cjs +++ b/tests/clock-seam.test.cjs @@ -36,7 +36,8 @@ const path = require('node:path'); const os = require('node:os'); const { makeFakeClock } = require('./helpers/clock.cjs'); -const { acquireStateLock, releaseStateLock, readModifyWriteStateMd } = require('../gsd-core/bin/lib/state.cjs'); +const stateMod = require('../gsd-core/bin/lib/state.cjs'); +const { acquireStateLock, releaseStateLock, readModifyWriteStateMd } = stateMod; const { withPlanningLock } = require('../gsd-core/bin/lib/planning-workspace.cjs'); const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); @@ -123,6 +124,105 @@ describe('acquireStateLock clock seam', () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +// 1a. acquireStateLock PID-liveness staleness (audit M1) +// +// mtime is a leaky proxy for "holder is alive": a live-but-slow holder whose +// critical section runs past staleThresholdMs ages out and gets its lock stolen +// by a waiter → two writers in STATE.md's critical section → lost update. +// The fix gates the steal on a real liveness signal (process.kill(pid,0), +// injected via the _setLockProbes seam) and orders the deadman ceiling ABOVE the +// wait budget so a verified-live holder is NEVER stolen within budget. A dead +// holder is stolen promptly regardless of age. A garbage/legacy body is treated +// as not-verified-live so corrupt locks stay recoverable under the deadman ceiling. +// ───────────────────────────────────────────────────────────────────────────── + +describe('acquireStateLock PID-liveness staleness (audit M1)', () => { + let tmpDir; + let statePath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-liveness-state-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + statePath = path.join(tmpDir, '.planning', 'STATE.md'); + fs.writeFileSync(statePath, '# State\n'); + }); + + afterEach(() => { + stateMod._resetLockProbes(); + try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ } + cleanup(tmpDir); + }); + + test('exports _setLockProbes / _resetLockProbes seams', () => { + assert.ok(typeof stateMod._setLockProbes === 'function', '_setLockProbes seam must be exported'); + assert.ok(typeof stateMod._resetLockProbes === 'function', '_resetLockProbes seam must be exported'); + }); + + test('live holder is NOT stolen even when aged past the stale threshold (waiter budgets out)', () => { + const lockPath = statePath + '.lock'; + const livePid = 4242; + fs.writeFileSync(lockPath, String(livePid)); + + // Holder pid reads as ALIVE via the injected probe (deterministic, no real pid). + stateMod._setLockProbes({ isPidAlive: (pid) => pid === livePid }); + + // Drive the clock so the lock is aged WELL past the 10 000 ms stale threshold + // (stale < age) but the waiter only ever budgets out at maxWaitMs (30 000 ms). + // sleep advances time; once the 30 000 ms budget is exhausted it must throw, + // and it must NOT have unlinked the live holder's lock. + const clock = makeFakeClock(60000); // age = now - mtime ≫ 10 000 ms + assert.throws( + () => acquireStateLock(statePath, clock), + /acquireStateLock.*exceeded.*30000ms budget/, + 'a verified-live holder must never be stolen within the wait budget — waiter must time out instead' + ); + + // The live holder's lock body must be intact (never unlinked + re-created). + assert.ok(fs.existsSync(lockPath), 'live holder lock must still exist (not stolen)'); + assert.strictEqual(fs.readFileSync(lockPath, 'utf-8'), String(livePid), 'live holder lock body must be unchanged'); + + fs.unlinkSync(lockPath); + }); + + test('dead holder is stolen promptly without waiting out the full budget', () => { + const lockPath = statePath + '.lock'; + const deadPid = 777; + fs.writeFileSync(lockPath, String(deadPid)); + + // Holder pid reads as DEAD via the injected probe → eligible for immediate steal. + stateMod._setLockProbes({ isPidAlive: () => false }); + + // Fresh, NON-aged lock (mtime ≈ now). Without liveness the old mtime-only gate + // would refuse to steal a <10 000 ms lock and force a long wait; with liveness + // a dead holder is stolen immediately regardless of age. + const clock = makeFakeClock(Date.now()); + const acquired = acquireStateLock(statePath, clock); + assert.ok(fs.existsSync(acquired), 'dead holder lock must be stolen and re-acquired'); + assert.strictEqual( + clock.sleepCalls.length, 0, + 'a dead holder must be stolen promptly — no wait/backoff sleeps before acquisition' + ); + releaseStateLock(acquired); + }); + + test('garbage/legacy lock body → not-verified-live → recoverable under the deadman ceiling, never an infinite block', () => { + const lockPath = statePath + '.lock'; + fs.writeFileSync(lockPath, 'not-a-pid\x00garbage'); // unreadable / non-numeric body + + // Probe would say "alive" for ANY pid — proves the steal does not depend on a + // bogus parse succeeding: an unparseable body is treated as not-verified-live. + stateMod._setLockProbes({ isPidAlive: () => true }); + + // Age the body past the deadman ceiling (above maxWaitMs) so the corrupt lock + // is recoverable rather than blocking forever. + const clock = makeFakeClock(Date.now() + 120000); + const acquired = acquireStateLock(statePath, clock); + assert.ok(fs.existsSync(acquired), 'corrupt/legacy lock must be recoverable (stolen under the deadman ceiling)'); + releaseStateLock(acquired); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── // 1b. Regression #1217 — acquireStateLock ENOENT (recoverable errno) busy-spin // @@ -649,58 +749,38 @@ describe('withPlanningLock clock seam', () => { assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.lock')), 'lock must be released even when fn() throws'); }); - test('timeout fires when clock exceeds lockTimeout (10 000 ms)', () => { + test('timeout fires (sleep seam exercised) when a LIVE holder is contended past lockTimeout', () => { + // Audit M1 rewrite: the prior version asserted the now-REMOVED force-steal + // fallback (timeout → unconditional unlink + re-acquire). That fallback robbed + // live writers; the fix replaces it with a clear timeout throw. This test now + // pins the new contract: a verified-LIVE holder held past lockTimeout makes the + // waiter exercise the clock.sleep seam and then throw — never force-stolen. const lockPath = path.join(tmpDir, '.planning', '.lock'); - fs.writeFileSync(lockPath, String(process.pid)); // simulate held lock + const livePid = 9191; + fs.writeFileSync(lockPath, JSON.stringify({ pid: livePid, cwd: tmpDir, acquired: new Date().toISOString() })); + + // Holder reads as ALIVE via the injected probe → waited on, never stolen. + require('../gsd-core/bin/lib/planning-workspace.cjs')._setLockProbes({ isPidAlive: (pid) => pid === livePid }); - // Clock that advances past lockTimeout on every sleep call so the while - // condition trips immediately after the first retry. let nowValue = 0; - - // withPlanningLock exits the while loop (timeout), deletes the lock, then - // calls runWithHeldLock() which tries writeFileSync with { flag: 'wx' }. - // Since our lock file is still there (we placed it), runWithHeldLock throws EEXIST. - // That exception propagates — so we get an error (either EEXIST or the - // function succeeds on the post-timeout acquisition attempt depending on timing). - // What we need to assert: the clock.sleep was invoked (timeout path was reached). - // - // Because withPlanningLock removes the lock file at timeout and re-acquires, - // and we placed the lock file ourselves (not via withPlanningLock), the re-acquire - // will SUCCEED (wx open on an absent file). So the function returns normally. - // Remove our self-placed lock so withPlanningLock can take it over. - fs.unlinkSync(lockPath); - - // Now seed the lock AFTER withPlanningLock starts by using a wrapper that - // creates the lock file on the first sleep call. - let seeded = false; - nowValue = 0; const clock2 = { now() { return nowValue; }, - sleep(ms) { - if (!seeded) { - seeded = true; - // The test: verify withPlanningLock calls clock.sleep when contended - // (confirms the seam is wired, not that Atomics.wait is called). - } - nowValue += ms + 11000; - }, + sleep(ms) { nowValue += ms + 11000; }, // advance past lockTimeout on first sleep }; - // Re-seed the lock (simulating a competing process) - fs.writeFileSync(lockPath, '12345'); // non-existent PID; stale check uses mtime - - // Set mtime to now so the stale check (>30s) does NOT fire - const now = new Date(); - fs.utimesSync(lockPath, now, now); - - // With the lock fresh and held, withPlanningLock will enter the retry loop - // and call clock2.sleep at least once. After advancing past lockTimeout, - // it exits the while loop and tries to recover by unlinking and re-acquiring. - const result = withPlanningLock(tmpDir, () => 'recovered', clock2); - assert.strictEqual(result, 'recovered', 'must succeed after timeout recovery path'); - // clock2.sleep was called, confirming the seam was exercised - // (the sleep method must have advanced nowValue past lockTimeout) - assert.ok(nowValue > 10000, 'clock must have advanced past lockTimeout via sleep calls'); + try { + assert.throws( + () => withPlanningLock(tmpDir, () => 'should-not-run', clock2), + /exceeded.*10000ms budget/, + 'a live holder held past lockTimeout must throw a clear timeout error (not force-steal)' + ); + // The sleep seam must have been exercised (timeout path reached). + assert.ok(nowValue > 10000, 'clock must have advanced past lockTimeout via the sleep seam'); + // The live holder's lock must be intact (never unlinked). + assert.ok(fs.existsSync(lockPath), 'live holder lock must survive the timeout (not force-stolen)'); + } finally { + require('../gsd-core/bin/lib/planning-workspace.cjs')._resetLockProbes(); + } }); }); diff --git a/tests/planning-workspace.test.cjs b/tests/planning-workspace.test.cjs index 6933ec51c..a996deec2 100644 --- a/tests/planning-workspace.test.cjs +++ b/tests/planning-workspace.test.cjs @@ -4,6 +4,9 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { cleanup } = require('./helpers.cjs'); +const { makeFakeClock } = require('./helpers/clock.cjs'); + +const planningWorkspaceDirect = require('../gsd-core/bin/lib/planning-workspace.cjs'); const { createPlanningWorkspace, @@ -13,9 +16,7 @@ const { withPlanningLock, getActiveWorkstream, setActiveWorkstream, -} = require('../gsd-core/bin/lib/planning-workspace.cjs'); - -const planningWorkspaceDirect = require('../gsd-core/bin/lib/planning-workspace.cjs'); +} = planningWorkspaceDirect; describe('planning-workspace: planningDir/planningPaths parity', () => { const cwd = '/fake/repo'; @@ -185,3 +186,107 @@ describe('planning-workspace direct: functions expose matching behavior', () => } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// withPlanningLock PID-liveness staleness + EEXIST safety (audit M1 + M2) +// +// M1: the prior timeout fallback unconditionally unlinked WHATEVER lock existed — +// even a fresh, live holder's — then re-acquired. A legitimate op taking +// longer than lockTimeout (10 000 ms) got its lock force-stolen. The fix gates +// stealing on a real liveness signal (injected via _setLockProbes): a dead +// holder is stolen promptly inside the polite loop; a LIVE holder is waited on +// and, on genuine timeout, the waiter throws a clear timeout error rather than +// corrupting the live holder's critical section. +// +// M2: the timeout-fallback re-acquire (acquireLock with { flag: 'wx' }) sat OUTSIDE +// any try/catch — if another process re-created the lock between the unlink and +// the wx write, a raw EEXIST escaped the helper and crashed the command. The +// fix removes the unconditional force-steal so no raw EEXIST can escape. +// ───────────────────────────────────────────────────────────────────────────── + +describe('withPlanningLock PID-liveness staleness + EEXIST safety (audit M1+M2)', () => { + let tmpDir; + let lockPath; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-liveness-planning-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + lockPath = path.join(tmpDir, '.planning', '.lock'); + }); + + afterEach(() => { + planningWorkspaceDirect._resetLockProbes(); + try { fs.unlinkSync(lockPath); } catch { /* ok */ } + cleanup(tmpDir); + }); + + test('exports _setLockProbes / _resetLockProbes seams', () => { + assert.ok(typeof planningWorkspaceDirect._setLockProbes === 'function', '_setLockProbes seam must be exported'); + assert.ok(typeof planningWorkspaceDirect._resetLockProbes === 'function', '_resetLockProbes seam must be exported'); + }); + + test('live holder held past lockTimeout is NOT force-stolen — waiter throws a clear timeout error', () => { + const livePid = 5151; + fs.writeFileSync(lockPath, JSON.stringify({ + pid: livePid, + cwd: tmpDir, + acquired: new Date().toISOString(), + })); + + // Holder pid reads as ALIVE → must never be force-stolen. + planningWorkspaceDirect._setLockProbes({ isPidAlive: (pid) => pid === livePid }); + + let ranCriticalSection = false; + // Fake clock whose sleep advances past lockTimeout (10 000 ms) so the polite + // loop budgets out; the live holder must survive and the waiter must throw. + const clock = makeFakeClock(0); + assert.throws( + () => withPlanningLock(tmpDir, () => { ranCriticalSection = true; return 'stolen'; }, clock), + /lock/i, + 'a live holder must never be force-stolen on timeout — the waiter must throw a clear timeout error' + ); + + assert.strictEqual(ranCriticalSection, false, 'critical section must NOT run against a live holder (no force-steal)'); + assert.ok(fs.existsSync(lockPath), 'live holder lock must still exist (not unlinked)'); + const body = JSON.parse(fs.readFileSync(lockPath, 'utf-8')); + assert.strictEqual(body.pid, livePid, 'live holder lock body must be unchanged'); + }); + + test('dead holder is stolen promptly inside the polite loop (no full timeout wait)', () => { + const deadPid = 888; + fs.writeFileSync(lockPath, JSON.stringify({ + pid: deadPid, + cwd: tmpDir, + acquired: new Date().toISOString(), + })); + + // Holder pid reads as DEAD → eligible for prompt steal inside the loop. + planningWorkspaceDirect._setLockProbes({ isPidAlive: () => false }); + + const clock = makeFakeClock(0); + const result = withPlanningLock(tmpDir, () => 'acquired', clock); + assert.strictEqual(result, 'acquired', 'dead holder lock must be stolen and the critical section must run'); + assert.ok(!fs.existsSync(lockPath), 'lock must be released after the critical section completes'); + }); + + test('M2: no raw EEXIST escapes the helper on the timeout path against a live holder', () => { + const livePid = 6262; + fs.writeFileSync(lockPath, JSON.stringify({ + pid: livePid, + cwd: tmpDir, + acquired: new Date().toISOString(), + })); + + planningWorkspaceDirect._setLockProbes({ isPidAlive: (pid) => pid === livePid }); + + const clock = makeFakeClock(0); + let caught; + try { + withPlanningLock(tmpDir, () => 'x', clock); + } catch (err) { + caught = err; + } + 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)'); + }); +});