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.
286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for #3772 — acquireStateLock silently returned false-success
|
|
* on non-EEXIST openSync errors (EMFILE / EINTR / ENOSPC under load).
|
|
*
|
|
* Extended in #3776 to cover Docker overlay-fs and NFS transient errno codes,
|
|
* and in #3057 (B2) to cover the steal-decision fault path for an unreadable
|
|
* lock body (merged in from tests/state-lock-body-unreadable.test.cjs, which
|
|
* this file absorbed — same acquireStateLock surface, see lint-test-file-count).
|
|
*
|
|
* Every test in this file actually CALLS acquireStateLock (never regexes the
|
|
* built .cjs source) and injects errno faults via `withFaultyFs` on the exact
|
|
* fs.openSync call the lock-create path makes (src/state.cts, the
|
|
* `fs.openSync(lockPath, O_CREAT|O_EXCL|O_WRONLY)` line inside
|
|
* acquireStateLock's retry loop) — never chmod/subprocess tricks.
|
|
*
|
|
* Contract under test:
|
|
* C1. A fatal non-EEXIST error (EACCES) propagates/throws — not swallowed
|
|
* as EEXIST contention and not retried.
|
|
* C2. Success path (openSync succeeds) → returns lockPath and writes this
|
|
* process's pid into the lock body.
|
|
* C4/C7. ACQUIRE_LOCK_RETRY_ERRNOS codes (EAGAIN/EINTR/EINVAL/EIO/ENOENT/
|
|
* ESTALE/EPERM/EBUSY) are retried — the open eventually succeeds and
|
|
* exactly one contention-style backoff (clock.sleep) occurs first.
|
|
* C5/C6. Fatal codes (EMFILE/ENOSPC/EROFS) and unknown codes propagate
|
|
* immediately — zero backoff, the error is thrown on the first attempt.
|
|
* (C8 — "uses a Set, not an inline literal" — is an implementation-shape
|
|
* assertion with no independent runtime signature; it is subsumed by C4c-f
|
|
* above, since a regression to the old inline EPERM||EBUSY check would fail
|
|
* those newer-errno retry assertions.)
|
|
*/
|
|
|
|
const { describe, test } = 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 { withFaultyFs } = require('./helpers/faulty-deps.cjs');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
const { acquireStateLock, releaseStateLock } = require('../msd-core/bin/lib/state.cjs');
|
|
|
|
const originalOpenSync = fs.openSync;
|
|
const originalReadFileSync = fs.readFileSync;
|
|
|
|
/** Fresh temp project dir with a STATE.md, for a single test. */
|
|
function makeTempState() {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-lock-non-eexist-'));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
fs.writeFileSync(statePath, '# State\n');
|
|
return { tmpDir, statePath };
|
|
}
|
|
|
|
/** Back-date `lockPath`'s mtime by `ageMs` (real fs time, not fake-clock). */
|
|
function backdateMtime(lockPath, ageMs) {
|
|
const staled = new Date(Date.now() - ageMs);
|
|
fs.utimesSync(lockPath, staled, staled);
|
|
}
|
|
|
|
/**
|
|
* Build a `t`-taking test body that faults fs.openSync for `lockPath` ONCE
|
|
* with `code`, then lets the retried open succeed for real — proving the
|
|
* errno is retried (not thrown) and exactly one backoff occurs.
|
|
*/
|
|
function assertOpenSyncErrorIsRetried(code) {
|
|
return (t) => {
|
|
const { tmpDir, statePath } = makeTempState();
|
|
t.after(() => cleanup(tmpDir));
|
|
const lockPath = statePath + '.lock';
|
|
const clock = makeFakeClock(0);
|
|
let calls = 0;
|
|
const acquired = withFaultyFs(
|
|
{
|
|
openSync: (p, ...rest) => {
|
|
if (String(p) === lockPath) {
|
|
calls++;
|
|
if (calls === 1) {
|
|
throw Object.assign(new Error(code + ': injected transient error'), { code });
|
|
}
|
|
}
|
|
return originalOpenSync(p, ...rest);
|
|
},
|
|
},
|
|
() => acquireStateLock(statePath, clock),
|
|
);
|
|
t.after(() => releaseStateLock(acquired));
|
|
assert.equal(
|
|
acquired, lockPath,
|
|
code + ' must be retried and the retried open must succeed, not be thrown immediately',
|
|
);
|
|
assert.equal(
|
|
clock.sleepCalls.length, 1,
|
|
code + ' must trigger exactly one contention-style backoff before the retried open succeeds',
|
|
);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a `t`-taking test body that faults fs.openSync for `lockPath` on
|
|
* EVERY call with `code` — proving the errno propagates on the first attempt
|
|
* with zero backoff (fatal, not retried).
|
|
*/
|
|
function assertOpenSyncErrorIsFatal(code) {
|
|
return (t) => {
|
|
const { tmpDir, statePath } = makeTempState();
|
|
t.after(() => cleanup(tmpDir));
|
|
const lockPath = statePath + '.lock';
|
|
const clock = makeFakeClock(0);
|
|
assert.throws(
|
|
() => withFaultyFs(
|
|
{
|
|
openSync: (p, ...rest) => {
|
|
if (String(p) === lockPath) {
|
|
throw Object.assign(new Error(code + ': injected fatal error'), { code });
|
|
}
|
|
return originalOpenSync(p, ...rest);
|
|
},
|
|
},
|
|
() => acquireStateLock(statePath, clock),
|
|
),
|
|
(err) => err.code === code,
|
|
code + ' must propagate to the caller rather than being retried or swallowed',
|
|
);
|
|
assert.equal(
|
|
clock.sleepCalls.length, 0,
|
|
code + ' must not trigger a contention/backoff retry before throwing',
|
|
);
|
|
};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// C1. Non-EEXIST fatal error → must throw, not be swallowed as contention
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('acquireStateLock: non-EEXIST openSync errors (#3772)', () => {
|
|
test(
|
|
'C1: a fatal non-EEXIST error (EACCES) propagates — not swallowed as EEXIST contention',
|
|
assertOpenSyncErrorIsFatal('EACCES'),
|
|
);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// C2. Success path → returns lockPath and writes this process's pid
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('acquireStateLock: success path still returns lockPath', () => {
|
|
test('C2: openSync succeeding returns the lock path and writes this process pid', (t) => {
|
|
const { tmpDir, statePath } = makeTempState();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const acquired = acquireStateLock(statePath);
|
|
t.after(() => releaseStateLock(acquired));
|
|
|
|
assert.equal(acquired, statePath + '.lock', 'acquireStateLock must return the lock path on success');
|
|
assert.ok(fs.existsSync(acquired), 'the lock file must exist on disk after a successful acquire');
|
|
assert.equal(
|
|
fs.readFileSync(acquired, 'utf8'), String(process.pid),
|
|
'the lock body must contain this process pid on the success path',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// C4 / C7. Transient errno codes are retried (#3776 / #3773 regression guard)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('acquireStateLock: transient errno codes are retried, not thrown (#3776)', () => {
|
|
test('C4a: EAGAIN is retried (resource temporarily unavailable)', assertOpenSyncErrorIsRetried('EAGAIN'));
|
|
test('C4b: EINTR is retried (syscall interrupted)', assertOpenSyncErrorIsRetried('EINTR'));
|
|
test('C4c: EINVAL is retried (Docker overlay-fs transient)', assertOpenSyncErrorIsRetried('EINVAL'));
|
|
test('C4d: EIO is retried (Docker overlay-fs / NFS transient)', assertOpenSyncErrorIsRetried('EIO'));
|
|
test('C4e: ENOENT is retried (Docker overlay-fs parent dir transient)', assertOpenSyncErrorIsRetried('ENOENT'));
|
|
test('C4f: ESTALE is retried (NFS stale file handle)', assertOpenSyncErrorIsRetried('ESTALE'));
|
|
});
|
|
|
|
describe('acquireStateLock: EPERM/EBUSY still retried (regression guard, #3773)', () => {
|
|
test('C7a: EPERM is retried (Windows / macOS AV scanner)', assertOpenSyncErrorIsRetried('EPERM'));
|
|
test('C7b: EBUSY is retried (Windows file in use)', assertOpenSyncErrorIsRetried('EBUSY'));
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// C5 / C6. Fatal and unknown errno codes propagate immediately, never retried
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('acquireStateLock: fatal errno codes propagate without retry (#3776)', () => {
|
|
test('C5a: EMFILE propagates immediately (fd limit exhausted — fatal)', assertOpenSyncErrorIsFatal('EMFILE'));
|
|
test('C5b: ENOSPC propagates immediately (disk full — fatal)', assertOpenSyncErrorIsFatal('ENOSPC'));
|
|
test('C5c: EROFS propagates immediately (read-only fs — fatal)', assertOpenSyncErrorIsFatal('EROFS'));
|
|
// EACCES is covered by C1 above (the canonical non-EEXIST-fatal case).
|
|
});
|
|
|
|
describe('acquireStateLock: unknown errno codes not retried (conservative default, #3776)', () => {
|
|
test(
|
|
'C6: an unrecognized errno (ESOMETHING) propagates rather than being retried',
|
|
assertOpenSyncErrorIsFatal('ESOMETHING'),
|
|
);
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3057 B2 — an unreadable STATE.md lock body must not get the same
|
|
// fresh-create-floor stealable treatment as a genuinely empty one.
|
|
//
|
|
// `_stateLockBodyPid` used to collapse two different situations to the same
|
|
// `null`: a lock body that reads back empty/garbage (the create→write
|
|
// window — expected, benign) and a lock body that could not be READ at all
|
|
// (an I/O fault — permission error, transient NFS/overlay-fs hiccup, etc.).
|
|
// Both got the SAME 1-second (`freshCreateFloorMs`) steal-eligibility
|
|
// window, so a transient read fault could rob an actively-held lock exactly
|
|
// as fast as a lock that is merely mid-creation.
|
|
//
|
|
// The fix (`_stateLockBodyStatus`, state.cts) makes the steal decision
|
|
// four-way: an unreadable body is now held to the SAME conservative
|
|
// `deadmanCeilingMs` ceiling as a verified-live holder, not the short
|
|
// fresh-create floor.
|
|
//
|
|
// These two tests are a pair by construction: identical lock age (past the
|
|
// fresh-create floor, nowhere near the deadman ceiling), identical clock
|
|
// rig — the ONLY variable is whether the body read throws (fault-injected
|
|
// via `withFaultyFs`, never chmod/subprocess) or genuinely reads back empty.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#3057 B2: acquireStateLock steal decision — unreadable lock body vs. genuinely empty', () => {
|
|
test('FAILURE path: an unreadable lock body is NOT stolen at the fresh-create-floor age — the acquire budget is exhausted instead', (t) => {
|
|
const { tmpDir, statePath } = makeTempState();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const lockPath = statePath + '.lock';
|
|
// Content is irrelevant — the fault-injected read throws before it is ever parsed.
|
|
fs.writeFileSync(lockPath, '12345');
|
|
t.after(() => { try { fs.unlinkSync(lockPath); } catch { /* already gone */ } });
|
|
|
|
// Age the lock past freshCreateFloorMs (1000ms) but nowhere near
|
|
// deadmanCeilingMs (60000ms) — this is EXACTLY the age at which a
|
|
// genuinely-empty body would already be stolen (see the paired test below).
|
|
backdateMtime(lockPath, 5000);
|
|
|
|
const baseClock = makeFakeClock(Date.now() + 100); // ageMs ≈ 5100ms at start
|
|
// Jump the virtual clock past the 30 000ms acquire budget on the very
|
|
// first retry sleep, so the test proves "never stolen within budget"
|
|
// deterministically without hundreds of synchronous retry iterations.
|
|
const fastClock = {
|
|
now: baseClock.now.bind(baseClock),
|
|
sleep(ms) {
|
|
baseClock.sleep(ms);
|
|
baseClock.advance(31000);
|
|
},
|
|
};
|
|
|
|
assert.throws(
|
|
() => withFaultyFs(
|
|
{
|
|
readFileSync: (p, ...rest) => {
|
|
if (String(p) === lockPath) {
|
|
throw Object.assign(new Error('EIO: i/o error, read'), { code: 'EIO' });
|
|
}
|
|
return originalReadFileSync(p, ...rest);
|
|
},
|
|
},
|
|
() => acquireStateLock(statePath, fastClock),
|
|
),
|
|
/acquireStateLock.*exceeded.*30000ms budget/,
|
|
'an unreadable lock body past the fresh-create-floor age must NOT be stolen — it must hit the acquire-budget timeout',
|
|
);
|
|
});
|
|
|
|
test('BENIGN path: a genuinely empty lock body at the SAME age IS stolen (fresh-create-floor path unaffected by the fix)', (t) => {
|
|
const { tmpDir, statePath } = makeTempState();
|
|
t.after(() => cleanup(tmpDir));
|
|
|
|
const lockPath = statePath + '.lock';
|
|
fs.writeFileSync(lockPath, ''); // genuinely empty — mid-creation window, not an I/O fault
|
|
|
|
backdateMtime(lockPath, 5000); // identical age to the FAILURE test above
|
|
|
|
const clock = makeFakeClock(Date.now() + 100); // ageMs ≈ 5100ms, identical rig to the FAILURE test above
|
|
|
|
const acquired = acquireStateLock(statePath, clock);
|
|
t.after(() => releaseStateLock(acquired));
|
|
assert.ok(fs.existsSync(acquired),
|
|
'a genuinely empty lock body past the fresh-create-floor age must still be stolen and re-acquired');
|
|
});
|
|
});
|