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.
231 lines
10 KiB
JavaScript
231 lines
10 KiB
JavaScript
/**
|
|
* Regression test for perf #407 — withPlanningLock allocates a fresh
|
|
* SharedArrayBuffer on every retry iteration.
|
|
*
|
|
* The fix: hoist the sleep buffer allocation to once before the retry loop.
|
|
* The buffer is never mutated and never escapes — Atomics.wait(buf,0,0,delay)
|
|
* always sees 0 whether the buffer is fresh or reused, so the behavior is
|
|
* identical.
|
|
*
|
|
* Observable invariant (POST-FIX): exactly ONE SharedArrayBuffer is allocated
|
|
* per withPlanningLock call, regardless of retry count.
|
|
*
|
|
* RED (pre-fix): sabCount >= 2 when >= 1 retry occurs.
|
|
* GREEN (post-fix): sabCount === 1.
|
|
*
|
|
* Strategy: deterministic clock-seam approach (no real workers, no wall-clock).
|
|
* 1. Spy on the SharedArrayBuffer constructor BEFORE requiring the module
|
|
* (clock.cjs allocates its module-level _realSleepBuf at load time).
|
|
* 2. Pre-create the lock file so the first acquire attempt sees EEXIST.
|
|
* 3. Inject a fake clock whose sleep() side-effect unlinks the lock file
|
|
* after the first call — so attempt #1 sees contention → clock.sleep()
|
|
* (which releases the lock) → attempt #2 acquires.
|
|
* 4. Assert: fn ran, sleep was called >= 1 time (retry path exercised),
|
|
* and sabCount === 1 (the hoist invariant).
|
|
*
|
|
* This approach is fully synchronous and deterministic: no Atomics.wait, no
|
|
* setTimeout, no worker scheduling races, no wall-clock dependence.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Constants
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const PLANNING_WORKSPACE_CJS_PATH = path.join(
|
|
__dirname, '..', 'msd-core', 'bin', 'lib', 'planning-workspace.cjs'
|
|
);
|
|
|
|
const CLOCK_CJS_PATH = path.join(
|
|
__dirname, '..', 'msd-core', 'bin', 'lib', 'clock.cjs'
|
|
);
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Helpers
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function makeTempDir() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-407-'));
|
|
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
|
|
return dir;
|
|
}
|
|
|
|
function removeTempDir(dir) {
|
|
try { cleanup(dir); } catch { /* ignore */ }
|
|
}
|
|
|
|
/**
|
|
* Install a counting spy on the global SharedArrayBuffer constructor.
|
|
* Returns { getCount(), restore() }.
|
|
* Must be installed BEFORE requiring any module that allocates SABs at load time.
|
|
*/
|
|
function spySAB() {
|
|
const RealSAB = global.SharedArrayBuffer;
|
|
let count = 0;
|
|
|
|
function SpySAB(...args) {
|
|
count++;
|
|
return new RealSAB(...args);
|
|
}
|
|
SpySAB.prototype = RealSAB.prototype;
|
|
SpySAB.BYTES_PER_ELEMENT = RealSAB.BYTES_PER_ELEMENT;
|
|
|
|
global.SharedArrayBuffer = SpySAB;
|
|
|
|
return {
|
|
getCount() { return count; },
|
|
restore() { global.SharedArrayBuffer = RealSAB; },
|
|
};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Test
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('perf #407: withPlanningLock hoists sleep buffer — exactly one SAB per call', () => {
|
|
let tmpDir;
|
|
let lockPath;
|
|
// Keep a reference to the module so _setLockProbes/_resetLockProbes are
|
|
// accessible across beforeEach/afterEach boundaries.
|
|
let mod;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = makeTempDir();
|
|
lockPath = path.join(tmpDir, '.planning', '.lock');
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Reset the liveness probe to the real implementation so other tests
|
|
// (or subsequent runs) are not affected by our deterministic override.
|
|
if (mod) {
|
|
mod._resetLockProbes();
|
|
mod = null;
|
|
}
|
|
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
|
removeTempDir(tmpDir);
|
|
// Purge module cache so each test gets a fresh require (and fresh SAB spy window).
|
|
delete require.cache[PLANNING_WORKSPACE_CJS_PATH];
|
|
delete require.cache[CLOCK_CJS_PATH];
|
|
});
|
|
|
|
test(
|
|
'sabCount === 1 after a call that undergoes >= 1 retry (post-fix assertion)',
|
|
() => {
|
|
// ── Step 1: install SAB spy before requiring the module ────────────────
|
|
// clock.cjs allocates its module-level _realSleepBuf at require time.
|
|
// Spying before the require catches that allocation.
|
|
const spy = spySAB();
|
|
let withPlanningLock;
|
|
try {
|
|
// Purge any previously cached versions so the spy catches module-level allocs.
|
|
delete require.cache[PLANNING_WORKSPACE_CJS_PATH];
|
|
delete require.cache[CLOCK_CJS_PATH];
|
|
mod = require(PLANNING_WORKSPACE_CJS_PATH);
|
|
withPlanningLock = mod.withPlanningLock;
|
|
} finally {
|
|
spy.restore();
|
|
}
|
|
const sabCountAtLoad = spy.getCount();
|
|
|
|
// ── Inject deterministic liveness probe ───────────────────────────────
|
|
// PR #1532 replaced mtime-staleness with PID-liveness (process.kill(pid,0))
|
|
// to decide whether a contending lock holder should be waited on (live) or
|
|
// immediately stolen (dead). The test plants pid: process.pid + 1, which is
|
|
// environment-dependent: on some runners that pid is alive, on others it is
|
|
// not, making the retry/sleep path non-deterministic and causing CI flakiness
|
|
// (issue #1531). The _setLockProbes seam lets us pin the decision: treating
|
|
// the planted pid as LIVE deterministically forces the SUT into the retry path
|
|
// on every runner, which is exactly what the test intends to exercise.
|
|
mod._setLockProbes({ isPidAlive: (pid) => pid === process.pid + 1 });
|
|
|
|
// ── Step 2: pre-create the lock file (simulates a contending process) ──
|
|
// writing a valid lock JSON so withPlanningLock's stale-check doesn't
|
|
// delete it immediately (mtime is NOW, well within the 30s stale window).
|
|
fs.writeFileSync(lockPath, JSON.stringify({
|
|
pid: process.pid + 1, // fake pid — not this process
|
|
cwd: tmpDir,
|
|
acquired: new Date().toISOString(),
|
|
}));
|
|
|
|
// ── Step 3: build a fake clock that releases contention on first sleep ─
|
|
// Mechanism:
|
|
// - now() starts at 0; withPlanningLock checks `clock.now() - start < 10000`.
|
|
// - sleep() is called when EEXIST is seen and the lock is not stale.
|
|
// On the first sleep call we unlink the lock file so the next
|
|
// fs.writeFileSync(..., { flag: 'wx' }) attempt succeeds.
|
|
// - sleep() advances virtual time by the amount slept so elapsed-time
|
|
// checks work correctly (stale lock = > 30 000 ms — we advance by 100
|
|
// per sleep so we never trip that threshold accidentally).
|
|
let sleepCallCount = 0;
|
|
const fakeClock = {
|
|
now() { return sleepCallCount * 100; }, // advances with each sleep
|
|
sleep(_ms) {
|
|
sleepCallCount++;
|
|
if (sleepCallCount === 1) {
|
|
// Release the contention: unlink the lock so the next attempt wins.
|
|
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
|
}
|
|
},
|
|
};
|
|
|
|
// ── Step 4: call withPlanningLock — must retry exactly once ───────────
|
|
let fnRan = false;
|
|
let callErr = null;
|
|
try {
|
|
withPlanningLock(tmpDir, () => { fnRan = true; }, fakeClock);
|
|
} catch (e) {
|
|
callErr = e;
|
|
}
|
|
|
|
// ── Assertions ─────────────────────────────────────────────────────────
|
|
|
|
assert.ok(
|
|
callErr === null,
|
|
'withPlanningLock must succeed once the lock is released — error: ' +
|
|
(callErr && callErr.message)
|
|
);
|
|
|
|
assert.ok(fnRan, 'the callback fn must have run');
|
|
|
|
// PROOF OF RETRY-PATH COVERAGE (Contract 4 of test-rigor):
|
|
// sleepCallCount >= 1 proves the SUT entered the retry path.
|
|
// Without this witness, a no-retry success (if the lock was never seen)
|
|
// would also yield sabCount === 1 under both pre-fix and post-fix code,
|
|
// giving a false-pass against the bug.
|
|
assert.ok(
|
|
sleepCallCount >= 1,
|
|
'fake clock sleep() must have been called at least once — the SUT must ' +
|
|
'have entered the retry path. Got sleepCallCount: ' + sleepCallCount
|
|
);
|
|
|
|
// THE KEY INVARIANT:
|
|
// POST-FIX: sabCount === 1 (buffer allocated once, at module load, before any retry loop)
|
|
// PRE-FIX: sabCount would be >= 2 (new buffer on every iteration)
|
|
//
|
|
// sabCountAtLoad counts ALL SABs allocated during require() of the module
|
|
// (clock.cjs allocates one module-level _realSleepBuf). Combined with
|
|
// sleepCallCount >= 1 above, sabCountAtLoad === 1 proves the buffer is
|
|
// hoisted (post-fix).
|
|
//
|
|
// Note: with a fake clock injected, withPlanningLock itself never calls
|
|
// realClock.sleep(), so no SABs are allocated during the lock call itself.
|
|
// The spy window covers require() time only — which is exactly where the
|
|
// module-level allocation happens post-fix (clock.cjs line: `new SharedArrayBuffer(4)`).
|
|
assert.strictEqual(
|
|
sabCountAtLoad,
|
|
1,
|
|
'post-fix: exactly one SharedArrayBuffer must be allocated when the module is ' +
|
|
'loaded (buffer hoisted to module level in clock.cjs). Got: ' + sabCountAtLoad
|
|
);
|
|
}
|
|
);
|
|
});
|