Files
msd-core/tests/planning-lock-mkdir-failure-1884.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
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.
2026-10-06 01:47:40 +02:00

140 lines
6.8 KiB
JavaScript

const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { cleanup } = require('./helpers.cjs');
const { makeFakeClock } = require('./helpers/clock.cjs');
// Built lib is the test target (same surface the rest of the suite imports).
const planningWorkspaceDirect = require('../msd-core/bin/lib/planning-workspace.cjs');
const { withPlanningLock } = planningWorkspaceDirect;
// ─────────────────────────────────────────────────────────────────────────────
// Epic #1879 / F16 (#1884): withPlanningLock swallows platformEnsureDir failure.
//
// A mkdir failure (EACCES/ENOSPC/EROFS) creating `.planning/` was swallowed by a
// `catch { /* ok */ }`; the subsequent lock write then threw ENOENT (parent
// missing), and ENOENT is in PLANNING_LOCK_RETRY_ERRNOS (for the Docker
// overlay-fs race), so the loop spun the full 10s budget and reported a PHANTOM
// "held by a live process" contention pointing at a nonexistent holder.
//
// Fix: stop swallowing — surface the real FS errno immediately. These tests pin
// the new contract: a corrupt/permission mkdir is surfaced fast, not folded into
// a lock-contention retry.
//
// IO failure is forced via t.mock.method(fs, 'mkdirSync', ...), which auto-restores
// per-test (never chmod 0o000 — root bypasses mode bits, leaking coverage). The fake
// clock proves fail-fast with no wall-clock assertion.
// ─────────────────────────────────────────────────────────────────────────────
const realMkdirSync = fs.mkdirSync;
describe('withPlanningLock surfaces mkdir failure fast (epic #1879 / F16, #1884)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-planning-mkdir-fail-'));
// Deliberately do NOT pre-create tmpDir/.planning — the scenario under test
// is "the dir cannot be created."
});
afterEach(() => {
if (typeof planningWorkspaceDirect._resetLockProbes === 'function') {
planningWorkspaceDirect._resetLockProbes();
}
if (typeof planningWorkspaceDirect._resetPlanningLockTestHooks === 'function') {
planningWorkspaceDirect._resetPlanningLockTestHooks();
}
cleanup(tmpDir);
});
function failMkdirFor(t, targetDir, code) {
// Mock the shared fs.mkdirSync that platformEnsureDir calls dynamically.
// Re-throw the requested errno only for the planning dir; pass through everything
// else so unrelated test machinery keeps working. t.mock auto-restores after the test.
t.mock.method(fs, 'mkdirSync', (p, opts) => {
if (typeof p === 'string' && (p === targetDir || p.startsWith(targetDir + path.sep))) {
const err = new Error(code + ': permission denied, mkdir \'' + p + '\'');
err.code = code;
throw err;
}
return realMkdirSync.call(fs, p, opts);
});
}
test('EACCES creating .planning/ is surfaced immediately, not folded into a phantom lock timeout', (t) => {
const planningPath = path.join(tmpDir, '.planning');
failMkdirFor(t, planningPath, 'EACCES');
const clock = makeFakeClock(0);
let ran = false;
assert.throws(
() => withPlanningLock(tmpDir, () => { ran = true; return 'should-not-run'; }, clock),
(err) => err && err.code === 'EACCES',
'mkdir EACCES must surface as EACCES, not be swallowed into a phantom lock contention'
);
assert.strictEqual(ran, false, 'critical section must not run when the planning dir cannot be created');
assert.ok(clock.nowValue < 10000,
'must fail fast — clock must NOT spin to the 10s lockTimeout budget (nowValue=' + clock.nowValue + 'ms)');
});
test('ENOSPC creating .planning/ is surfaced immediately', (t) => {
failMkdirFor(t, path.join(tmpDir, '.planning'), 'ENOSPC');
const clock = makeFakeClock(0);
assert.throws(
() => withPlanningLock(tmpDir, () => 'x', clock),
(err) => err && err.code === 'ENOSPC',
'mkdir ENOSPC must surface as ENOSPC immediately'
);
assert.ok(clock.nowValue < 10000, 'must fail fast on ENOSPC (nowValue=' + clock.nowValue + 'ms)');
});
test('the thrown mkdir-failure error is NOT branded as a lock timeout (no phantom contention)', (t) => {
failMkdirFor(t, path.join(tmpDir, '.planning'), 'EACCES');
const clock = makeFakeClock(0);
let captured;
try {
withPlanningLock(tmpDir, () => 'x', clock);
} catch (err) {
captured = err;
}
assert.ok(captured, 'withPlanningLock must throw on mkdir EACCES');
assert.notStrictEqual(captured && captured.lockTimeout, true,
'a mkdir failure must NOT carry lockTimeout=true (that brands a phantom contention)');
});
test('normal path is unaffected: creatable .planning/ still runs the critical section', () => {
// No monkeypatch — .planning/ is creatable in the writable tmpDir.
const clock = makeFakeClock(0);
const result = withPlanningLock(tmpDir, () => 'ran-ok', clock);
assert.strictEqual(result, 'ran-ok', 'fn() must run and return when the planning dir is creatable');
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', '.lock')) === false,
'lock is released after the critical section');
});
test('a transient ENOENT on the lock WRITE (not mkdir) is still retried, not surfaced immediately', (t) => {
// Distinguishes this fix's target (mkdir failure, surfaced immediately) from the
// Docker overlay-fs race PLANNING_LOCK_RETRY_ERRNOS exists for (a transient ENOENT
// on the lock FILE write, with .planning/ already present) — that race must still
// retry and eventually succeed, not regress into fail-fast.
let writeAttempts = 0;
const realWriteFileSync = fs.writeFileSync;
t.mock.method(fs, 'writeFileSync', (p, data, opts) => {
if (typeof p === 'string' && p.endsWith(path.join('.planning', '.lock')) && writeAttempts === 0) {
writeAttempts++;
const err = new Error('ENOENT: transient overlay-fs race, open \'' + p + '\'');
err.code = 'ENOENT';
throw err;
}
return realWriteFileSync.call(fs, p, data, opts);
});
const clock = makeFakeClock(0);
const result = withPlanningLock(tmpDir, () => 'survived-the-race', clock);
assert.strictEqual(result, 'survived-the-race', 'the critical section must still run after the transient race resolves');
assert.strictEqual(writeAttempts, 1, 'the race must have been hit exactly once (sanity check on the mock)');
assert.ok(clock.nowValue > 0, 'a genuine retry must have consumed at least one sleep cycle (nowValue=' + clock.nowValue + 'ms)');
});
});