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>
210 lines
9.2 KiB
JavaScript
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
|
|
);
|
|
}
|
|
);
|
|
});
|