Files
msd-core/tests/perf-407-planning-lock-buffer-alloc.test.cjs
Tom Boucher a28dcec981 chore(#597): replace count-based ratchet guards with AST lint + named-set allowlists (#603)
The windows-test-parity ratchet greps test source for fs.rmSync-without-
maxRetries (and six other Windows-portability anti-patterns), failing when an
integer offender COUNT exceeds a frozen baseline (rmSync: 95). A count ratchet
is a Goodhart metric: fixing one offender and adding another keeps the count
constant, so a new defect slips through green. Replace it — and every other
count ratchet in the repo — with a layered, masking-proof design.

Behavioral seam test
- tests/helpers-cleanup.test.cjs proves helpers.cleanup() carries the Windows
  EBUSY retry budget. cleanup() delegates retries to Node's fs.rmSync via
  maxRetries (it owns no loop), so the test asserts the option contract
  (recursive/force/maxRetries>0/retryDelay>0) + real-FS removal + the cwd-guard,
  rather than a loop that does not exist. The EBUSY risk is now tested ONCE at
  the helper, not approximated textually at every call site.

Write-time ESLint rule (AST-accurate, replaces the grep)
- eslint-rules/no-raw-rmsync-in-tests.cjs (error in tests/**/*.test.cjs) bans
  raw fs.rmSync, steering to cleanup(). Catches member, computed (fs['rmSync']),
  destructured and aliased forms; escape hatch is inline
  `// eslint-disable-next-line local/no-raw-rmsync-in-tests -- <reason>` only.
- Migrated 336 raw fs.rmSync teardown calls across ~116 test files to cleanup().
  ~18 genuinely load-bearing sites (mid-test SUT/fault-injection removals,
  error-swallowing or name-colliding local teardown helpers) keep the raw call
  with an inline eslint-disable + reason.

Shared anti-ratchet primitive
- scripts/lib/allowlist-ratchet.cjs:
  - assertWithinAllowlist: fails on NOVEL ids (new offender introduced) AND on
    STALE ids (a known offender was fixed but not pruned) — identity, not count,
    and a ratchet DOWN toward zero.
  - assertTightCeiling: a size/length budget whose ceiling must stay within a
    grace band of the high-water mark, so budgets may only tighten, never creep.

Ratchets converted onto the primitive
- windows-test-parity-guard.test.cjs: rmSync rule deleted (now ESLint-enforced);
  the remaining six patterns moved from integer baselines to named-set
  allowlists with ratchet-down.
- scripts/lint-test-file-count.{cjs,allowlist.json}: per-module integer counts →
  named filename sets (closes the swap-a-file-keep-the-count blind spot); a
  module dropping under cap now FAILS to force pruning its allowlist entry.
- enh-2790 skill-count `<= 63` → named skill allowlist (ratchets toward ~58).

Size budgets hardened (tighten-only)
- agent-size / workflow-size / feat-3039 help-tiered: ceilings lowered to the
  current high-water mark and an assertTightCeiling anti-creep check added per
  tier. Fixed external-contract limits (description ≤100 chars, agent ≤100 KB)
  are intentionally left as-is — they are not grandfathered creeping budgets.

No user-facing behavior change (tests + tooling only); no USER_FACING_PREFIXES
touched, so no changeset fragment is required.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 22:43:49 -04:00

210 lines
9.2 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, '..', 'get-shit-done', 'bin', 'lib', 'planning-workspace.cjs'
);
const CLOCK_CJS_PATH = path.join(
__dirname, '..', 'get-shit-done', 'bin', 'lib', 'clock.cjs'
);
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function makeTempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-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;
beforeEach(() => {
tmpDir = makeTempDir();
lockPath = path.join(tmpDir, '.planning', '.lock');
});
afterEach(() => {
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];
withPlanningLock = require(PLANNING_WORKSPACE_CJS_PATH).withPlanningLock;
} finally {
spy.restore();
}
const sabCountAtLoad = spy.getCount();
// ── 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
);
}
);
});