Files
get-shit-done/tests/locking-bugs-1909-1916-1925-1927.test.cjs
Tom Boucher 4dd35f6b69 fix(state): correct TOCTOU races, busy-wait, lock cleanup, and config locking (#1944)
cmdStateUpdateProgress, cmdStateAddDecision, cmdStateAddBlocker,
cmdStateResolveBlocker, cmdStateRecordSession, and cmdStateBeginPhase from
bare readFileSync+writeStateMd to readModifyWriteStateMd, eliminating the
TOCTOU window where two concurrent callers read the same content and the
second write clobbers the first.

Atomics.wait(), matching the pattern already used in withPlanningLock in
core.cjs.

and core.cjs and register a process.on('exit') handler to unlink them on
process exit. The exit event fires even when process.exit(1) is called
inside a locked region, eliminating stale lock files after errors.

read-modify-write body of setConfigValue in a planning lock, preventing
concurrent config-set calls from losing each other's writes.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:39:29 -04:00

378 lines
15 KiB
JavaScript

/**
* Regression tests for locking bugs #1909, #1916, #1925, #1927.
*
* These tests are written FIRST (TDD) — they must fail before the fixes are applied
* and pass after.
*
* #1909 — CPU-burning busy-wait in acquireStateLock
* #1916 — Lock files persist after process.exit()
* #1925 — TOCTOU races in 8 state commands (read outside lock)
* #1927 — config.json has no locking in setConfigValue
*/
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 { execFileSync, execSync } = require('child_process');
const { promisify } = require('util');
const { exec } = require('child_process');
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
const execAsync = promisify(exec);
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function writeStateMd(tmpDir, content) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
content,
'utf-8'
);
}
function readStateMd(tmpDir) {
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
}
function writeConfig(tmpDir, obj) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8');
}
function readConfig(tmpDir) {
const configPath = path.join(tmpDir, '.planning', 'config.json');
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
}
// ─────────────────────────────────────────────────────────────────────────────
// #1909 — CPU-burning busy-wait in acquireStateLock
// Verify the implementation uses Atomics.wait (not a while-loop spin).
// ─────────────────────────────────────────────────────────────────────────────
describe('#1909 acquireStateLock: no CPU-burning busy-wait', () => {
test('acquireStateLock source code uses Atomics.wait, not a spin-loop', () => {
const stateSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'state.cjs'),
'utf-8'
);
// The bug: spin-loop pattern in acquireStateLock
// The fix: use Atomics.wait() for cross-platform sleep, matching withPlanningLock in core.cjs
const spinLoopPattern = /while\s*\(Date\.now\(\)\s*-\s*start\s*<\s*\w+\)\s*\{\s*(?:\/\*[^*]*\*\/)?\s*\}/;
// Find the acquireStateLock function text
const fnStart = stateSrc.indexOf('function acquireStateLock(');
assert.ok(fnStart !== -1, 'acquireStateLock function must exist');
// Extract ~50 lines after the function start to cover the retry logic
const fnSnippet = stateSrc.slice(fnStart, fnStart + 2000);
assert.ok(
!spinLoopPattern.test(fnSnippet),
'acquireStateLock must not use a CPU-burning spin-loop (while Date.now()-start < delay)'
);
assert.ok(
fnSnippet.includes('Atomics.wait'),
'acquireStateLock must use Atomics.wait() for sleeping, matching withPlanningLock in core.cjs'
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1916 — Lock files persist after process.exit()
// Verify that the STATE.md.lock file is removed even when process.exit() is called
// while the lock is held (e.g., via error() inside a locked region).
// ─────────────────────────────────────────────────────────────────────────────
describe('#1916 lock cleanup on process.exit()', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('STATE.md.lock is removed after a command exits with an error', () => {
// Intentionally trigger an error path: state update with missing STATE.md leaves
// no lock behind (the read-before-lock path returns gracefully, but let's verify
// a command that holds the lock can't accidentally leave the file).
writeStateMd(tmpDir, [
'# Project State',
'',
'**Status:** Planning',
'**Current Phase:** 01',
].join('\n') + '\n');
// Run a state update — even if it fails, the lock must not remain
runGsdTools('state update Status "In progress"', tmpDir);
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
assert.ok(
!fs.existsSync(lockPath),
'STATE.md.lock must not persist after any state command terminates'
);
});
test('STATE.md.lock module-level cleanup set is present in source', () => {
// Verify the fix: module-level Set tracks held locks and process.on('exit') cleans them up.
const stateSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'state.cjs'),
'utf-8'
);
assert.ok(
stateSrc.includes("process.on('exit'"),
"state.cjs must register process.on('exit', ...) to clean up held lock files"
);
});
test('core.cjs .planning/.lock is removed after a command exits with an error', () => {
// The withPlanningLock in core.cjs also needs exit cleanup.
const coreSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'core.cjs'),
'utf-8'
);
assert.ok(
coreSrc.includes("process.on('exit'"),
"core.cjs must register process.on('exit', ...) to clean up held planning lock files"
);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1925 — TOCTOU races in 8 state commands
// Each of the 8 commands reads STATE.md outside the lock, then calls writeStateMd
// (which only locks the write). Two concurrent callers reading the same content
// means the second write clobbers the first.
//
// Fix: migrate all 8 to use readModifyWriteStateMd().
// Test: call the same command twice concurrently on SEPARATE fields and verify
// both updates survive.
// ─────────────────────────────────────────────────────────────────────────────
describe('#1925 TOCTOU: state commands use readModifyWriteStateMd', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('state update: both concurrent updates to different fields survive', async () => {
writeStateMd(tmpDir, [
'# Project State',
'',
'**Status:** Planning',
'**Current Phase:** 01',
'**Current Plan:** 01-01',
'**Last Activity:** 2024-01-01',
].join('\n') + '\n');
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" state update Status Executing --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" state update "Current Phase" 02 --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const content = readStateMd(tmpDir);
assert.ok(
content.includes('Executing') && content.includes('02'),
'Both concurrent state update commands must survive (TOCTOU bug: second write clobbers first).\n' +
'Content:\n' + content
);
});
test('state add-decision: both concurrent calls append different decisions', async () => {
writeStateMd(tmpDir, [
'# Project State',
'',
'**Current Phase:** 01',
'**Status:** Planning',
'',
'### Decisions',
'None yet.',
].join('\n') + '\n');
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" state add-decision --phase 01 --summary "Use TypeScript" --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" state add-decision --phase 01 --summary "Use PostgreSQL" --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const content = readStateMd(tmpDir);
assert.ok(
content.includes('Use TypeScript') && content.includes('Use PostgreSQL'),
'Both concurrent add-decision calls must survive.\n' +
'Content:\n' + content
);
});
test('state add-blocker: both concurrent calls append different blockers', async () => {
writeStateMd(tmpDir, [
'# Project State',
'',
'**Current Phase:** 01',
'',
'### Blockers',
'None.',
].join('\n') + '\n');
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" state add-blocker --text "Need API credentials" --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" state add-blocker --text "Waiting for design review" --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const content = readStateMd(tmpDir);
assert.ok(
content.includes('Need API credentials') && content.includes('Waiting for design review'),
'Both concurrent add-blocker calls must survive.\n' +
'Content:\n' + content
);
});
test('state commands use readModifyWriteStateMd (source audit)', () => {
const stateSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'state.cjs'),
'utf-8'
);
// Each of these functions should NOT contain a bare `fs.readFileSync(...STATE.md...)`
// followed by a `writeStateMd` — they should use readModifyWriteStateMd instead.
//
// We verify this by checking that within the function body we do NOT see the
// TOCTOU pattern: `let content = fs.readFileSync(statePath` (old pattern)
// while also calling `writeStateMd` — except wrapped in readModifyWrite.
const affectedFunctions = [
'cmdStateUpdate',
'cmdStateAdvancePlan',
'cmdStateRecordMetric',
'cmdStateUpdateProgress',
'cmdStateAddDecision',
'cmdStateAddBlocker',
'cmdStateResolveBlocker',
'cmdStateRecordSession',
'cmdStateBeginPhase',
];
for (const fnName of affectedFunctions) {
// Find the function in source
const fnIdx = stateSrc.indexOf(`function ${fnName}(`);
assert.ok(fnIdx !== -1, `${fnName} must exist in state.cjs`);
// Grab the function body (rough heuristic: up to the next top-level function)
const bodyStart = stateSrc.indexOf('{', fnIdx);
// Find end by tracking braces
let depth = 0;
let bodyEnd = bodyStart;
for (let i = bodyStart; i < stateSrc.length; i++) {
if (stateSrc[i] === '{') depth++;
else if (stateSrc[i] === '}') {
depth--;
if (depth === 0) { bodyEnd = i; break; }
}
}
const fnBody = stateSrc.slice(fnIdx, bodyEnd + 1);
// The function must call readModifyWriteStateMd
assert.ok(
fnBody.includes('readModifyWriteStateMd'),
`${fnName} must use readModifyWriteStateMd() to prevent TOCTOU races`
);
// The function must NOT have bare readFileSync for statePath outside the lambda
// (the readFileSync inside readModifyWrite's lambda is fine — that's inside the lock)
// We check for the pre-fix pattern: `let content = fs.readFileSync(statePath`
assert.ok(
!fnBody.match(/let content\s*=\s*fs\.readFileSync\s*\(\s*statePath/),
`${fnName} must not read STATE.md with fs.readFileSync outside readModifyWriteStateMd (TOCTOU)`
);
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// #1927 — config.json has no locking in setConfigValue
// setConfigValue does read-modify-write on config.json without holding any lock.
// Fix: wrap in withPlanningLock.
// ─────────────────────────────────────────────────────────────────────────────
describe('#1927 config.json: setConfigValue must hold planning lock', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('both concurrent config-set calls persist their values', async () => {
writeConfig(tmpDir, {
model_profile: 'balanced',
workflow: {
research: true,
plan_check: true,
},
});
const nodeBin = process.execPath;
const cmdA = `"${nodeBin}" "${TOOLS_PATH}" config-set model_profile quality --cwd "${tmpDir}"`;
const cmdB = `"${nodeBin}" "${TOOLS_PATH}" config-set workflow.research false --cwd "${tmpDir}"`;
await Promise.all([
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
]);
const config = readConfig(tmpDir);
assert.strictEqual(
config.model_profile,
'quality',
'config-set model_profile must survive concurrent write'
);
assert.strictEqual(
config.workflow?.research,
false,
'config-set workflow.research must survive concurrent write'
);
});
test('config.cjs setConfigValue uses withPlanningLock (source audit)', () => {
const configSrc = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib', 'config.cjs'),
'utf-8'
);
// setConfigValue must import/use withPlanningLock
assert.ok(
configSrc.includes('withPlanningLock'),
'config.cjs must use withPlanningLock in setConfigValue to prevent concurrent write data loss'
);
});
});