Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
143 lines
6.2 KiB
JavaScript
143 lines
6.2 KiB
JavaScript
'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('../msd-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(), 'msd-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)'
|
|
);
|
|
});
|
|
});
|