Merge branch 'next' into fix/1400-agent-skills-stdout-flush
This commit is contained in:
@@ -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,221 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1c. Steal-safety windows (PR #1532 review — trek-e)
|
||||
//
|
||||
// The PID-liveness backport (audit M1) dropped two pieces of capability-lock.cts's
|
||||
// race-free steal machinery, reopening the #500/#905/#1230 lost-update family:
|
||||
//
|
||||
// (a) Empty-body create window — acquireStateLock creates the lock with O_EXCL and
|
||||
// writes the pid in a SEPARATE writeSync. A lock observed in that window has an
|
||||
// EMPTY body → _stateHolderVerifiedLive('') is false → the no-floor steal gate
|
||||
// robs it at age ≈ 0, mid-creation. capability-lock never steals a FRESH lock
|
||||
// (age <= LOCK_STALE_MS) regardless of body, which is what protects that window.
|
||||
//
|
||||
// (b) Double-steal — the steal is a bare fs.unlinkSync with no identity re-confirm
|
||||
// between the decision and the unlink. A racer that steals + recreates a fresh
|
||||
// lock in that gap has its replacement deleted by the first stealer's unlink →
|
||||
// two concurrent holders. capability-lock re-confirms (dev,ino) immediately
|
||||
// before an ATOMIC rename-steal so only one racer can win.
|
||||
//
|
||||
// Both are driven deterministically through the lock seams (clock + pid probe +
|
||||
// onLoopIteration + beforeSteal) — no wall-clock, no real concurrency.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('acquireStateLock steal-safety windows (PR #1532)', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stealsafety-state-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
fs.writeFileSync(statePath, '# State\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stateMod._resetLockProbes();
|
||||
stateMod._resetStateLockTestHooks();
|
||||
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('a FRESH empty-body lock (mid-creation) is NOT stolen at age ~0 — acquirer backs off', () => {
|
||||
const lockPath = statePath + '.lock';
|
||||
// Simulate the create→pid-write window of a CONCURRENT acquirer: the lockfile
|
||||
// exists (O_EXCL create succeeded) but the pid has not been written yet → empty body.
|
||||
fs.writeFileSync(lockPath, '');
|
||||
const freshTime = new Date();
|
||||
fs.utimesSync(lockPath, freshTime, freshTime); // mtime ≈ now → age ≈ 0 (fresh)
|
||||
|
||||
// The body is empty, so liveness cannot be determined from it — the probe value is
|
||||
// irrelevant. The (buggy) no-floor gate steals it regardless; the fix must wait.
|
||||
stateMod._setLockProbes({ isPidAlive: () => false });
|
||||
|
||||
// After the first encounter, clear the empty lock so the (correctly-waiting) acquirer
|
||||
// can complete instead of budgeting out — keeps the test bounded and the assertion
|
||||
// about the FIRST decision, not the eventual outcome.
|
||||
stateMod._setStateLockTestHooks({
|
||||
onLoopIteration: ({ iteration }) => {
|
||||
if (iteration >= 1) { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } }
|
||||
},
|
||||
});
|
||||
|
||||
const clock = makeFakeClock(freshTime.getTime());
|
||||
const acquired = acquireStateLock(statePath, clock);
|
||||
|
||||
assert.ok(fs.existsSync(acquired), 'lock must eventually be acquired');
|
||||
assert.ok(
|
||||
clock.sleepCalls.length >= 1,
|
||||
'a fresh empty-body lock is mid-creation and must NOT be stolen at age ~0 — ' +
|
||||
'the acquirer must back off (sleep) at least once, not unlink + steal immediately'
|
||||
);
|
||||
releaseStateLock(acquired);
|
||||
});
|
||||
|
||||
test('a dead holder whose lock is recreated by a racer mid-steal is NOT double-stolen (identity re-confirm)', () => {
|
||||
const lockPath = statePath + '.lock';
|
||||
const deadPid = 4040;
|
||||
const livePid = 5050;
|
||||
// Decision-time holder: a DEAD pid → eligible for steal.
|
||||
fs.writeFileSync(lockPath, String(deadPid));
|
||||
const t = new Date();
|
||||
fs.utimesSync(lockPath, t, t);
|
||||
|
||||
stateMod._setLockProbes({ isPidAlive: (pid) => pid === livePid });
|
||||
|
||||
// Inject a concurrent waiter that, in the gap between our steal-DECISION and our
|
||||
// steal, already stole + recreated a FRESH lock owned by a LIVE pid. A correct
|
||||
// (identity-re-confirming) acquirer must notice the lock instance changed and must
|
||||
// NOT delete the racer's live replacement.
|
||||
let injected = false;
|
||||
stateMod._setStateLockTestHooks({
|
||||
beforeSteal: () => {
|
||||
if (injected) return;
|
||||
injected = true;
|
||||
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
||||
fs.writeFileSync(lockPath, String(livePid)); // different identity + live holder
|
||||
const f = new Date();
|
||||
fs.utimesSync(lockPath, f, f);
|
||||
},
|
||||
});
|
||||
|
||||
const clock = makeFakeClock(t.getTime());
|
||||
// The racer's replacement is held by a LIVE pid → the acquirer must wait on it and
|
||||
// budget out rather than stealing it. (A double-steal would instead delete it and
|
||||
// succeed.)
|
||||
assert.throws(
|
||||
() => acquireStateLock(statePath, clock),
|
||||
(err) => err && err.lockBudgetExceeded === true,
|
||||
'acquirer must not double-steal the racer\'s live replacement — it must wait + budget out'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(lockPath, 'utf-8'), String(livePid),
|
||||
'the racer\'s freshly-recreated live lock must survive — never deleted by a stale-decision unlink'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1b. Regression #1217 — acquireStateLock ENOENT (recoverable errno) busy-spin
|
||||
//
|
||||
@@ -377,11 +593,12 @@ describe('acquireStateLock boundary coverage — recoverable-errno budget (#1217
|
||||
// before continuing, so they throw within maxWaitMs.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('acquireStateLock statSync/unlinkSync spin paths bounded (#1217)', () => {
|
||||
describe('acquireStateLock statSync/steal spin paths bounded (#1217)', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
let origStatSync;
|
||||
let origUnlinkSync;
|
||||
let origRenameSync;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-clock-spin-'));
|
||||
@@ -390,11 +607,17 @@ describe('acquireStateLock statSync/unlinkSync spin paths bounded (#1217)', () =
|
||||
fs.writeFileSync(statePath, '# State\n');
|
||||
origStatSync = fs.statSync;
|
||||
origUnlinkSync = fs.unlinkSync;
|
||||
origRenameSync = fs.renameSync;
|
||||
// Force the recorded holder (pid 99999) DEAD so the steal path is exercised
|
||||
// deterministically — these tests probe the steal's bounded-backoff, not liveness.
|
||||
stateMod._setLockProbes({ isPidAlive: () => false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.statSync = origStatSync;
|
||||
fs.unlinkSync = origUnlinkSync;
|
||||
fs.renameSync = origRenameSync;
|
||||
stateMod._resetLockProbes();
|
||||
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
@@ -434,29 +657,26 @@ describe('acquireStateLock statSync/unlinkSync spin paths bounded (#1217)', () =
|
||||
try { origUnlinkSync(lockPath); } catch { /* ok */ }
|
||||
});
|
||||
|
||||
test('persistent unlinkSync failure in stale-lock path throws budget-exceeded (not busy-spin)', () => {
|
||||
// Set up an EEXIST condition with a STALE lock (mtime well in the past)
|
||||
test('persistent renameSync failure in steal path throws budget-exceeded (not busy-spin)', () => {
|
||||
// Set up an EEXIST condition with a steal-eligible DEAD holder (pid 99999 — not us,
|
||||
// not alive). The steal is an ATOMIC rename (PR #1532); a persistent rename failure
|
||||
// (e.g. EPERM — file locked by an AV scanner) must back off + budget out, not spin.
|
||||
const lockPath = statePath + '.lock';
|
||||
fs.writeFileSync(lockPath, '99999');
|
||||
// Back-date mtime by 15 000 ms so the stale-threshold (10 000 ms) is exceeded
|
||||
const staleMs = 15000;
|
||||
const staledTime = new Date(Date.now() - staleMs);
|
||||
fs.utimesSync(lockPath, staledTime, staledTime);
|
||||
|
||||
// Make unlinkSync always fail (e.g. EPERM — file locked by AV scanner)
|
||||
const unlinkErr = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
fs.unlinkSync = (p) => {
|
||||
if (p === lockPath) throw unlinkErr;
|
||||
return origUnlinkSync(p);
|
||||
// Make renameSync always fail for the steal of our lock path.
|
||||
const renameErr = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
fs.renameSync = (from, to) => {
|
||||
if (from === lockPath) throw renameErr;
|
||||
return origRenameSync(from, to);
|
||||
};
|
||||
|
||||
// Clock where now() returns current real time so the stale check fires,
|
||||
// Clock where now() returns current real time so the steal branch fires,
|
||||
// but sleep advances a fixed 1000ms per call so budget is hit deterministically.
|
||||
const realNow = Date.now();
|
||||
let _elapsed = 0;
|
||||
const sleepCalls = [];
|
||||
const clock = {
|
||||
// Return a time far past the stale threshold so the stale branch is taken
|
||||
now() { return realNow + _elapsed; },
|
||||
sleep(ms) { sleepCalls.push(ms); _elapsed += 1000; },
|
||||
};
|
||||
@@ -464,34 +684,31 @@ describe('acquireStateLock statSync/unlinkSync spin paths bounded (#1217)', () =
|
||||
assert.throws(
|
||||
() => acquireStateLock(statePath, clock),
|
||||
/acquireStateLock.*exceeded.*30000ms budget/,
|
||||
'persistent unlinkSync failure in stale-lock path must throw budget-exceeded, not spin forever'
|
||||
'persistent renameSync failure in steal path must throw budget-exceeded, not spin forever'
|
||||
);
|
||||
|
||||
assert.ok(sleepCalls.length >= 1, `sleep must have been called at least once (got ${sleepCalls.length}); zero means busy-spin`);
|
||||
assert.ok(_elapsed >= 30000, `elapsed must reach 30 000 ms budget (got ${_elapsed}ms)`);
|
||||
|
||||
// Restore unlinkSync for cleanup
|
||||
fs.unlinkSync = origUnlinkSync;
|
||||
// Restore renameSync for cleanup
|
||||
fs.renameSync = origRenameSync;
|
||||
try { origUnlinkSync(lockPath); } catch { /* ok */ }
|
||||
});
|
||||
|
||||
test('persistent unlinkSync failure error message names stale-lock-removal cause, not statSync (#1217 diagnostic)', () => {
|
||||
// Regression guard for the misleading-error-context bug: when unlinkSync
|
||||
// fails on the stale-lock path and checkBudgetAndSleep throws at the budget
|
||||
// boundary, the outer statSync catch must NOT re-wrap it with
|
||||
// "statSync failed after EEXIST". The thrown error must contain the original
|
||||
// context "stale lock removal failed" so operators can identify the real cause.
|
||||
test('persistent renameSync failure error message names steal cause, not statSync (#1217 diagnostic)', () => {
|
||||
// Regression guard for the misleading-error-context bug: when the steal's renameSync
|
||||
// fails and checkBudgetAndSleep throws at the budget boundary, the outer statSync
|
||||
// catch must NOT re-wrap it with "statSync failed after EEXIST". The thrown error
|
||||
// must name the real cause ("stale lock steal lost to racer") so operators can
|
||||
// identify it.
|
||||
const lockPath = statePath + '.lock';
|
||||
fs.writeFileSync(lockPath, '99999');
|
||||
const staleMs = 15000;
|
||||
const staledTime = new Date(Date.now() - staleMs);
|
||||
fs.utimesSync(lockPath, staledTime, staledTime);
|
||||
|
||||
// unlinkSync always fails — the budget will be exhausted on the first sleep.
|
||||
const unlinkErr = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
fs.unlinkSync = (p) => {
|
||||
if (p === lockPath) throw unlinkErr;
|
||||
return origUnlinkSync(p);
|
||||
// renameSync always fails — the budget will be exhausted on the first sleep.
|
||||
const renameErr = Object.assign(new Error('EPERM: operation not permitted'), { code: 'EPERM' });
|
||||
fs.renameSync = (from, to) => {
|
||||
if (from === lockPath) throw renameErr;
|
||||
return origRenameSync(from, to);
|
||||
};
|
||||
|
||||
const realNow = Date.now();
|
||||
@@ -508,17 +725,17 @@ describe('acquireStateLock statSync/unlinkSync spin paths bounded (#1217)', () =
|
||||
thrownErr = e;
|
||||
}
|
||||
|
||||
assert.ok(thrownErr, 'must throw when unlinkSync persistently fails and budget is exhausted');
|
||||
assert.ok(thrownErr, 'must throw when renameSync persistently fails and budget is exhausted');
|
||||
assert.ok(
|
||||
/stale lock removal failed/.test(thrownErr.message),
|
||||
`error message must contain "stale lock removal failed" (got: ${thrownErr.message})`
|
||||
/stale lock steal lost to racer/.test(thrownErr.message),
|
||||
`error message must contain "stale lock steal lost to racer" (got: ${thrownErr.message})`
|
||||
);
|
||||
assert.ok(
|
||||
!/statSync failed after EEXIST/.test(thrownErr.message),
|
||||
`error message must NOT contain "statSync failed after EEXIST" (the misleading re-wrap) (got: ${thrownErr.message})`
|
||||
);
|
||||
|
||||
fs.unlinkSync = origUnlinkSync;
|
||||
fs.renameSync = origRenameSync;
|
||||
try { origUnlinkSync(lockPath); } catch { /* ok */ }
|
||||
});
|
||||
|
||||
@@ -649,58 +866,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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
124
tests/m8-writestatemd-scan-after-lock.test.cjs
Normal file
124
tests/m8-writestatemd-scan-after-lock.test.cjs
Normal file
@@ -0,0 +1,124 @@
|
||||
'use strict';
|
||||
// allow-test-rule: architectural-invariant (see #1531)
|
||||
// writeStateMd's "scan happens INSIDE the lock" property is a concurrency invariant.
|
||||
// A single-threaded test cannot observe the difference between scan-before-lock and
|
||||
// scan-after-lock unless something mutates the disk in the window between the two.
|
||||
// The afterAcquire test hook (fired inside writeStateMd right after the lock is
|
||||
// taken) is the deterministic seam that simulates a concurrent writer landing in
|
||||
// exactly that window — the only level at which the TOCTOU is observable.
|
||||
|
||||
/**
|
||||
* M8 — writeStateMd scans the disk (syncStateFrontmatter / PLAN-SUMMARY count)
|
||||
* BEFORE taking the lock, so a concurrent writer that commits a new PLAN/SUMMARY
|
||||
* between our scan and our lock acquisition makes writeStateMd stamp STALE
|
||||
* progress counts (a lost-update of the frontmatter progress block).
|
||||
* readModifyWriteStateMd (the atomic variant) correctly scans INSIDE its lock —
|
||||
* this non-atomic variant was the outlier.
|
||||
*
|
||||
* Deterministic repro (no wall-clock, no threads): the afterAcquire test hook
|
||||
* fires inside writeStateMd immediately after the lock is acquired and adds a
|
||||
* second PLAN file to the phase dir — simulating a concurrent writer who landed
|
||||
* in the scan→lock window. The written frontmatter's progress.total_plans then
|
||||
* reveals whether the scan ran before the hook (stale: 1) or after it (fresh: 2).
|
||||
*
|
||||
* RED (pre-fix): scan runs BEFORE acquire → before the hook → total_plans = 1.
|
||||
* GREEN (post-fix): scan runs AFTER acquire → after the hook → total_plans = 2.
|
||||
*
|
||||
* Recurring closed family this guards: #500 / #905 / #1230 (STATE.md write
|
||||
* corruption). #453 deleted the flaky race tests in favor of seams, so this exact
|
||||
* path was under-tested — the hook restores deterministic coverage.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const stateMod = require('../gsd-core/bin/lib/state.cjs');
|
||||
const { writeStateMd } = stateMod;
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MINIMAL_STATE_MD = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Status:** Planning',
|
||||
'**Current Phase:** 01',
|
||||
].join('\n') + '\n';
|
||||
|
||||
/** Parse progress.total_plans out of the STATE.md frontmatter block. */
|
||||
function readTotalPlans(statePath) {
|
||||
const written = fs.readFileSync(statePath, 'utf-8');
|
||||
const fmMatch = written.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'STATE.md must have a frontmatter block after writeStateMd');
|
||||
const m = fmMatch[1].match(/total_plans:\s*(\d+)/);
|
||||
assert.ok(m, 'frontmatter must carry a progress.total_plans line');
|
||||
return parseInt(m[1], 10);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// M8 — afterAcquire hook proves the scan runs INSIDE the lock
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('M8: writeStateMd scans disk AFTER acquiring the lock (scan-in-lock)', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
let phaseDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-m8-'));
|
||||
const planningDir = path.join(tmpDir, '.planning');
|
||||
phaseDir = path.join(planningDir, 'phases', '01-init');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
// Start with exactly ONE plan file on disk.
|
||||
fs.writeFileSync(path.join(phaseDir, '01-PLAN.md'), '# Plan 01\n');
|
||||
statePath = path.join(planningDir, 'STATE.md');
|
||||
fs.writeFileSync(statePath, MINIMAL_STATE_MD);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stateMod._resetStateLockTestHooks();
|
||||
try { fs.unlinkSync(statePath + '.lock'); } catch { /* ok */ }
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('a PLAN added in the post-acquire window is reflected in the written progress count', () => {
|
||||
// The hook simulates a concurrent writer who commits a second PLAN file in the
|
||||
// window between scan and lock. It MUST be observed only if the scan runs after
|
||||
// the lock (and therefore after this hook fires).
|
||||
let fired = 0;
|
||||
stateMod._setStateLockTestHooks({
|
||||
afterAcquire() {
|
||||
fired++;
|
||||
fs.writeFileSync(path.join(phaseDir, '02-PLAN.md'), '# Plan 02\n');
|
||||
},
|
||||
});
|
||||
|
||||
writeStateMd(statePath, MINIMAL_STATE_MD, tmpDir);
|
||||
|
||||
assert.equal(fired, 1, 'afterAcquire hook must fire exactly once inside writeStateMd');
|
||||
|
||||
const totalPlans = readTotalPlans(statePath);
|
||||
// RED pre-fix: scan ran before the hook → counts only 01-PLAN.md → 1.
|
||||
// GREEN post-fix: scan ran after the hook → counts both PLANs → 2.
|
||||
assert.equal(
|
||||
totalPlans, 2,
|
||||
'writeStateMd must scan the disk INSIDE the lock (after the concurrent ' +
|
||||
'writer landed), stamping total_plans=2 — not the stale pre-lock count of 1'
|
||||
);
|
||||
});
|
||||
|
||||
test('single-threaded callers (no hook) are byte-for-behaviour unchanged: count = 1', () => {
|
||||
// Regression guard: with no concurrent writer (hook unset), the count must be
|
||||
// exactly the on-disk truth — the fix must NOT change the uncontended result.
|
||||
writeStateMd(statePath, MINIMAL_STATE_MD, tmpDir);
|
||||
assert.equal(
|
||||
readTotalPlans(statePath), 1,
|
||||
'uncontended writeStateMd must stamp the real on-disk plan count (1)'
|
||||
);
|
||||
});
|
||||
});
|
||||
142
tests/m9-statelock-write-error-orphan.test.cjs
Normal file
142
tests/m9-statelock-write-error-orphan.test.cjs
Normal file
@@ -0,0 +1,142 @@
|
||||
'use strict';
|
||||
// allow-test-rule: architectural-invariant (see #1531)
|
||||
// acquireStateLock's "no orphan empty lock + no fd leak on a recoverable
|
||||
// writeSync/closeSync error" property is a resource-safety invariant of a private
|
||||
// function. A single-threaded test cannot otherwise force the openSync-succeeds-
|
||||
// then-writeSync-throws window. The simulateWriteError seam injects exactly that
|
||||
// one-shot failure; the onLoopIteration seam snapshots the lock file's existence
|
||||
// at the top of the retry that follows — the only level at which the orphan is
|
||||
// observable deterministically (no wall-clock, no threads).
|
||||
|
||||
/**
|
||||
* M9 — acquireStateLock leaks the fd AND strands the just-created empty lock
|
||||
* when writeSync/closeSync throws a RECOVERABLE errno (e.g. EAGAIN) after
|
||||
* openSync(O_CREAT|O_EXCL) already created the lock file. The pre-fix catch did
|
||||
* checkBudgetAndSleep + continue WITHOUT closeSync(fd) or unlinkSync(lockPath),
|
||||
* so every occurrence leaked a descriptor and left a content-less lock behind.
|
||||
*
|
||||
* capability-lock.cts:415-425 already ships the cleanup-before-bail pattern this
|
||||
* mirrors. The fix wraps the writeSync/closeSync in an inner try that
|
||||
* closeSync(fd) (guarded) + unlinkSync(lockPath) (guarded), then re-throws to the
|
||||
* existing outer catch (which keeps classifying recoverable vs fatal errnos — DRY).
|
||||
*
|
||||
* Deterministic repro (no wall-clock, no threads):
|
||||
* - simulateWriteError: 'EAGAIN' injects a ONE-SHOT writeSync failure.
|
||||
* - onLoopIteration snapshots fs.existsSync(lockPath) at the top of each retry.
|
||||
* On the retry iteration that follows the injected error:
|
||||
* RED (pre-fix): the empty lock is still stranded → lockExists === true.
|
||||
* GREEN (post-fix): cleanup unlinked it → lockExists === false.
|
||||
* And in BOTH the call still ultimately succeeds (M1's liveness steal recovers an
|
||||
* orphan) — so the orphan PRESENCE on the retry is the discriminating signal.
|
||||
*
|
||||
* A FATAL errno (e.g. ENOSPC, not in ACQUIRE_LOCK_RETRY_ERRNOS) must still
|
||||
* propagate after cleanup — covered by the fatal-propagation test below.
|
||||
*
|
||||
* Recurring closed family this guards: #500 / #905 / #1230 (STATE.md write
|
||||
* corruption); #453 deleted the flaky race tests so this path was under-tested.
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { makeFakeClock } = require('./helpers/clock.cjs');
|
||||
const stateMod = require('../gsd-core/bin/lib/state.cjs');
|
||||
const { acquireStateLock, releaseStateLock } = stateMod;
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
describe('M9: acquireStateLock cleans up fd + orphan lock on recoverable write error', () => {
|
||||
let tmpDir;
|
||||
let statePath;
|
||||
let lockPath;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-m9-'));
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
||||
statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
lockPath = statePath + '.lock';
|
||||
fs.writeFileSync(statePath, '# State\n');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stateMod._resetStateLockTestHooks();
|
||||
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('a one-shot recoverable writeSync error leaves NO stranded empty lock before the retry', () => {
|
||||
const clock = makeFakeClock(0);
|
||||
const lockExistsAtIterationTop = [];
|
||||
|
||||
stateMod._setStateLockTestHooks({
|
||||
simulateWriteError: 'EAGAIN', // one-shot: thrown by the first writeSync
|
||||
onLoopIteration() {
|
||||
lockExistsAtIterationTop.push(fs.existsSync(lockPath));
|
||||
},
|
||||
});
|
||||
|
||||
const acquired = acquireStateLock(statePath, clock);
|
||||
|
||||
// The call must still ultimately succeed and hold the lock.
|
||||
assert.equal(acquired, lockPath, 'acquireStateLock must succeed after recovering from the write error');
|
||||
assert.ok(fs.existsSync(lockPath), 'a real lock must be held when acquire returns');
|
||||
|
||||
// At least two iterations: the failing attempt, then the recovery retry.
|
||||
assert.ok(
|
||||
lockExistsAtIterationTop.length >= 2,
|
||||
'expected the injected write error to force at least one retry iteration'
|
||||
);
|
||||
|
||||
// The discriminator: on the retry that FOLLOWS the injected write error, no
|
||||
// orphan empty lock may remain. Pre-fix it is still stranded (true); post-fix
|
||||
// the inner cleanup unlinked it (false).
|
||||
assert.equal(
|
||||
lockExistsAtIterationTop[1], false,
|
||||
'the empty lock created by the failed attempt must be unlinked (cleanup-before-retry) — ' +
|
||||
'no orphan lock may be stranded after a recoverable writeSync error (M9 / capability-lock.cts:415-425)'
|
||||
);
|
||||
|
||||
releaseStateLock(acquired);
|
||||
assert.ok(!fs.existsSync(lockPath), 'lock removed after release');
|
||||
});
|
||||
|
||||
test('the held lock body is a valid pid after recovery (write actually completed on retry)', () => {
|
||||
const clock = makeFakeClock(0);
|
||||
stateMod._setStateLockTestHooks({ simulateWriteError: 'EAGAIN' });
|
||||
|
||||
const acquired = acquireStateLock(statePath, clock);
|
||||
const body = fs.readFileSync(lockPath, 'utf-8').trim();
|
||||
assert.equal(body, String(process.pid), 'recovered lock must carry the real pid (no content-less lock survives)');
|
||||
releaseStateLock(acquired);
|
||||
});
|
||||
|
||||
test('a FATAL (non-recoverable) write error still propagates after cleanup — orphan not masked', () => {
|
||||
const clock = makeFakeClock(0);
|
||||
let iterations = 0;
|
||||
|
||||
stateMod._setStateLockTestHooks({
|
||||
simulateWriteError: 'ENOSPC', // fatal: NOT in ACQUIRE_LOCK_RETRY_ERRNOS
|
||||
onLoopIteration() {
|
||||
// A fatal error must propagate on the FIRST attempt — never retried.
|
||||
iterations++;
|
||||
},
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => acquireStateLock(statePath, clock),
|
||||
(err) => err && err.code === 'ENOSPC',
|
||||
'a fatal write errno must propagate (not be masked by cleanup or retried)'
|
||||
);
|
||||
|
||||
assert.equal(iterations, 1, 'a fatal write errno must NOT be retried (single attempt then propagate)');
|
||||
|
||||
// After the throw, the empty lock created by the failed openSync must NOT be
|
||||
// left behind — cleanup runs even on the fatal path before re-throw.
|
||||
assert.ok(
|
||||
!fs.existsSync(lockPath),
|
||||
'fatal write error must still unlink the orphan lock before propagating (no stranded lock)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,177 @@ 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();
|
||||
if (typeof planningWorkspaceDirect._resetPlanningLockTestHooks === 'function') {
|
||||
planningWorkspaceDirect._resetPlanningLockTestHooks();
|
||||
}
|
||||
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('a dead holder recreated by a racer mid-steal is NOT double-stolen (identity re-confirm — PR #1532)', () => {
|
||||
const deadPid = 4040;
|
||||
const livePid = 5050;
|
||||
// Decision-time holder: a DEAD pid → eligible for steal inside the polite loop.
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: deadPid,
|
||||
cwd: tmpDir,
|
||||
acquired: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
planningWorkspaceDirect._setLockProbes({ isPidAlive: (pid) => pid === livePid });
|
||||
|
||||
// Inject a concurrent waiter that, in the gap between our steal-DECISION and our
|
||||
// steal, already stole + recreated a FRESH lock owned by a LIVE pid. A correct
|
||||
// (identity-re-confirming) acquirer must notice the instance changed and must NOT
|
||||
// delete the racer's live replacement.
|
||||
let injected = false;
|
||||
planningWorkspaceDirect._setPlanningLockTestHooks({
|
||||
beforeSteal: () => {
|
||||
if (injected) return;
|
||||
injected = true;
|
||||
try { fs.unlinkSync(lockPath); } catch { /* ok */ }
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: livePid,
|
||||
cwd: tmpDir,
|
||||
acquired: new Date().toISOString(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
let ranCriticalSection = false;
|
||||
const clock = makeFakeClock(0);
|
||||
// The racer's replacement is held by a LIVE pid → the acquirer must wait on it and
|
||||
// budget out, NOT delete it and run the critical section (which a double-steal does).
|
||||
assert.throws(
|
||||
() => withPlanningLock(tmpDir, () => { ranCriticalSection = true; return 'x'; }, clock),
|
||||
(err) => err && err.lockTimeout === true,
|
||||
'acquirer must not double-steal the racer\'s live replacement — it must wait + time out'
|
||||
);
|
||||
assert.strictEqual(ranCriticalSection, false, 'critical section must NOT run — the live replacement was not stolen');
|
||||
assert.ok(fs.existsSync(lockPath), 'the racer\'s live replacement lock must survive');
|
||||
const body = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
||||
assert.strictEqual(body.pid, livePid, 'the racer\'s freshly-recreated live lock body must be intact (never deleted by a stale-decision unlink)');
|
||||
});
|
||||
|
||||
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)');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
111
tests/project-instruction-file-parity.test.cjs
Normal file
111
tests/project-instruction-file-parity.test.cjs
Normal file
@@ -0,0 +1,111 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Bug #1529 parity / drift guard.
|
||||
*
|
||||
* The runtime → project-instruction-file mapping is shared between two
|
||||
* parallel surfaces:
|
||||
* (A) the Node surface — `getProjectInstructionFile` in runtime-name-policy.cjs,
|
||||
* consumed by profile-output.cjs (the generate-claude-md handler).
|
||||
* (B) the bash surface — `gsd-tools query project-instruction-file --runtime <r>`,
|
||||
* consumed by gsd-core/workflows/new-project.md to set $INSTRUCTION_FILE.
|
||||
*
|
||||
* Per DEFECT.GENERATIVE-FIX, any shared mapping between two surfaces MUST
|
||||
* carry a parity assertion that fails when they diverge. This test is that
|
||||
* guard: it asserts (A) and (B) return the same filename for every runtime,
|
||||
* AND that the new-project.md workflow derives $INSTRUCTION_FILE from the
|
||||
* shared query rather than a hardcoded codex-only branch (the original bug).
|
||||
*
|
||||
* Boundary coverage (per RULESET.TESTS.boundary-coverage): claude (the
|
||||
* kept-as-is case) and an unknown runtime (the AGENTS.md default) are both
|
||||
* exercised alongside every runtime family in the mapping table.
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const RUNTIME_NAME_POLICY_PATH = path.join(
|
||||
ROOT,
|
||||
'gsd-core',
|
||||
'bin',
|
||||
'lib',
|
||||
'runtime-name-policy.cjs',
|
||||
);
|
||||
const GSD_TOOLS_PATH = path.join(ROOT, 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
const NEW_PROJECT_WORKFLOW_PATH = path.join(
|
||||
ROOT,
|
||||
'gsd-core',
|
||||
'workflows',
|
||||
'new-project.md',
|
||||
);
|
||||
|
||||
const { getProjectInstructionFile } = require(RUNTIME_NAME_POLICY_PATH);
|
||||
|
||||
const RUNTIMES = [
|
||||
'claude',
|
||||
'codex',
|
||||
'opencode',
|
||||
'kilo',
|
||||
'kimi',
|
||||
'copilot',
|
||||
'antigravity',
|
||||
'gemini',
|
||||
'future-runtime-xyz',
|
||||
'',
|
||||
];
|
||||
|
||||
function queryInstructionFile(runtime) {
|
||||
const args = [
|
||||
GSD_TOOLS_PATH,
|
||||
'query',
|
||||
'project-instruction-file',
|
||||
'--runtime',
|
||||
runtime,
|
||||
];
|
||||
return execFileSync('node', args, {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, GSD_RUNTIME: '' },
|
||||
}).trim();
|
||||
}
|
||||
|
||||
describe('bug #1529: getProjectInstructionFile ↔ gsd-tools query parity', () => {
|
||||
for (const runtime of RUNTIMES) {
|
||||
const label = runtime === '' ? '<empty>' : runtime;
|
||||
test(`Node function and CLI query agree for runtime=${label}`, () => {
|
||||
const fromFunction = getProjectInstructionFile(runtime);
|
||||
const fromQuery = queryInstructionFile(runtime);
|
||||
assert.strictEqual(
|
||||
fromQuery,
|
||||
fromFunction,
|
||||
`gsd-tools query project-instruction-file --runtime ${label} returned "${fromQuery}" but getProjectInstructionFile() returned "${fromFunction}"; the two surfaces drifted.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('bug #1529: new-project.md workflow uses the shared policy query', () => {
|
||||
// allow-test-rule: structural drift guard for #1529 — the workflow's bash block MUST invoke the
|
||||
// shared `gsd_run query project-instruction-file` query rather than a hardcoded
|
||||
// codex-only `if/else` branch; there is no typed IR for "this bash block calls a
|
||||
// specific gsd-tools query instead of a hardcoded mapping".
|
||||
const workflow = fs.readFileSync(NEW_PROJECT_WORKFLOW_PATH, 'utf8');
|
||||
|
||||
test('workflow derives INSTRUCTION_FILE from the shared query', () => {
|
||||
assert.ok(
|
||||
/INSTRUCTION_FILE=\$\(gsd_run query project-instruction-file --runtime "\$RUNTIME"\)/.test(workflow),
|
||||
'new-project.md must derive INSTRUCTION_FILE via `gsd_run query project-instruction-file --runtime "$RUNTIME"` (the shared policy adapter)',
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow no longer hardcodes the codex-only branch', () => {
|
||||
assert.ok(
|
||||
!/if \[ "\$RUNTIME" = "codex" \]; then INSTRUCTION_FILE="AGENTS\.md"; else INSTRUCTION_FILE="\.claude\/CLAUDE\.md"; fi/.test(workflow),
|
||||
'new-project.md must not contain the retired codex-only `if [ "$RUNTIME" = "codex" ]; then INSTRUCTION_FILE="AGENTS.md"; else INSTRUCTION_FILE=".claude/CLAUDE.md"; fi` branch (#1529 regression guard)',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ const ROOT = path.join(__dirname, '..');
|
||||
const {
|
||||
canonicalizeRuntimeName,
|
||||
resolveRuntimeNameFromCandidates,
|
||||
getProjectInstructionFile,
|
||||
} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-name-policy.cjs'));
|
||||
|
||||
describe('runtime-name-policy canonical runtime ids', () => {
|
||||
@@ -71,3 +72,55 @@ describe('runtime-name-policy windsurf alias parity — manifest vs FALLBACK_ALI
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime-name-policy getProjectInstructionFile (#1529)', () => {
|
||||
test('claude maps to .claude/CLAUDE.md (kept-as-is boundary case)', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('claude'), '.claude/CLAUDE.md');
|
||||
});
|
||||
|
||||
test('codex maps to AGENTS.md', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('codex'), 'AGENTS.md');
|
||||
});
|
||||
|
||||
test('opencode maps to AGENTS.md (the #1529 bug surface)', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('opencode'), 'AGENTS.md');
|
||||
});
|
||||
|
||||
test('kilo maps to AGENTS.md', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('kilo'), 'AGENTS.md');
|
||||
});
|
||||
|
||||
test('kimi maps to AGENTS.md', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('kimi'), 'AGENTS.md');
|
||||
});
|
||||
|
||||
test('copilot maps to .github/copilot-instructions.md (GitHub docs read path)', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('copilot'), '.github/copilot-instructions.md');
|
||||
});
|
||||
|
||||
test('gemini maps to GEMINI.md', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('gemini'), 'GEMINI.md');
|
||||
});
|
||||
|
||||
test('antigravity maps to GEMINI.md', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('antigravity'), 'GEMINI.md');
|
||||
});
|
||||
|
||||
test('unknown runtime maps to AGENTS.md (safe cross-agent default, boundary case)', () => {
|
||||
assert.strictEqual(getProjectInstructionFile('future-runtime-xyz'), 'AGENTS.md');
|
||||
assert.strictEqual(getProjectInstructionFile(''), 'AGENTS.md');
|
||||
assert.strictEqual(getProjectInstructionFile(null), 'AGENTS.md');
|
||||
assert.strictEqual(getProjectInstructionFile(undefined), 'AGENTS.md');
|
||||
});
|
||||
|
||||
test('aliases normalize via canonicalizeRuntimeName before mapping', () => {
|
||||
// codex-cli is an alias for codex; it must resolve to the codex mapping.
|
||||
assert.strictEqual(getProjectInstructionFile('codex-cli'), 'AGENTS.md');
|
||||
// opencode-cli is an alias for opencode.
|
||||
assert.strictEqual(getProjectInstructionFile('opencode-cli'), 'AGENTS.md');
|
||||
// gemini-cli is an alias for gemini.
|
||||
assert.strictEqual(getProjectInstructionFile('gemini-cli'), 'GEMINI.md');
|
||||
// github-copilot is an alias for copilot.
|
||||
assert.strictEqual(getProjectInstructionFile('github-copilot'), '.github/copilot-instructions.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"milestone-summary.md": 11774,
|
||||
"mvp-phase.md": 13582,
|
||||
"new-milestone.md": 32422,
|
||||
"new-project.md": 61802,
|
||||
"new-project.md": 62324,
|
||||
"new-workspace.md": 11254,
|
||||
"next.md": 20094,
|
||||
"node-repair.md": 4173,
|
||||
|
||||
Reference in New Issue
Block a user