Skip to content

Commit 327d5ad

Browse files
committed
fix(runtime): publish migration gate atomically
Cause: The final state-migration lock pathname was created before owner metadata was fully written. A concurrent Node 22 process could observe the empty publication window, or the lock could disappear between EEXIST and inspection, and report RUNTIME_STATE_NOT_QUIESCENT. Scope: Write and fsync ownership metadata in an exclusive candidate inode, publish it with an atomic hard link, retry a legitimate disappearance race, and preserve fail-closed handling for stable malformed locks. Release version 0.25.1. Verification: - Node 22 migration race stress: 30/30 - Node 22 State suite: 637/637 - npm run verify:release: 637 State, 156 Orchestrator, 262 host/package, 55 Hook, 0 audit vulnerabilities - npm pack --dry-run --json: 207 files, no tests or cache artifacts Residual risk: The lock protocol requires filesystem hard-link support; unsupported filesystems fail closed rather than falling back to a torn pathname publication. Rollback: Revert this commit and reinstall 0.25.0; no database schema or persisted semantic authority changes are included.
1 parent d65627b commit 327d5ad

5 files changed

Lines changed: 165 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.25.1] — 2026-07-31
11+
12+
### Fixed
13+
14+
- Published state-migration lock ownership atomically from a fully written and
15+
synchronized candidate inode, so concurrent Node 22 processes cannot mistake a
16+
valid publication or release window for a malformed lock. Stable malformed locks
17+
remain fail-closed.
18+
1019
## [0.25.0] — 2026-07-31
1120

1221
### Added

mcp-server/lib/runtime-paths.cjs

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -599,15 +599,37 @@ function processIsAlive(pid) {
599599

600600
function readMigrationGate(gatePath) {
601601
const stat = lstatOrNull(gatePath);
602-
if (!stat || stat.isSymbolicLink() || !stat.isFile()) return null;
602+
if (!stat) return { missing: true };
603+
if (stat.isSymbolicLink() || !stat.isFile()) return null;
603604
try {
604605
const owner = JSON.parse(fs.readFileSync(gatePath, 'utf8'));
605606
return { owner, stat };
606-
} catch {
607+
} catch (error) {
608+
if (error.code === 'ENOENT') return { missing: true };
607609
return null;
608610
}
609611
}
610612

613+
function createMigrationGateCandidate(gatePath, owner) {
614+
while (true) {
615+
const candidatePath = `${gatePath}.candidate-${process.pid}-${randomUUID()}`;
616+
let descriptor;
617+
try {
618+
descriptor = fs.openSync(candidatePath, 'wx', 0o600);
619+
fs.writeFileSync(descriptor, `${JSON.stringify(owner)}\n`);
620+
fs.fsyncSync(descriptor);
621+
return { candidatePath, descriptor };
622+
} catch (error) {
623+
if (descriptor !== undefined) {
624+
try { fs.closeSync(descriptor); } catch { /* best effort */ }
625+
}
626+
fs.rmSync(candidatePath, { force: true });
627+
if (error.code === 'EEXIST') continue;
628+
throw error;
629+
}
630+
}
631+
}
632+
611633
function reclaimDeadMigrationGate(gatePath, observed) {
612634
const quarantine = `${gatePath}.stale-${process.pid}-${randomUUID()}`;
613635
fs.renameSync(gatePath, quarantine);
@@ -635,28 +657,28 @@ function acquireStateMigrationGate(paths, {
635657
} = {}) {
636658
const gatePath = path.join(paths.runtimeDir, 'state-migration.lock');
637659
const token = randomUUID();
660+
const owner = {
661+
version: 2,
662+
pid: process.pid,
663+
owner_started_at: processStartMarker(process.pid),
664+
token,
665+
legacy_state_db: paths.legacyStateDbPath,
666+
runtime_state_db: paths.stateDbPath,
667+
};
638668
const deadline = Date.now() + timeoutMs;
639669
let descriptor;
640670
while (descriptor === undefined) {
671+
const candidate = createMigrationGateCandidate(gatePath, owner);
672+
let published = false;
641673
try {
642-
descriptor = fs.openSync(gatePath, 'wx', 0o600);
643-
fs.writeFileSync(descriptor, `${JSON.stringify({
644-
version: 2,
645-
pid: process.pid,
646-
owner_started_at: processStartMarker(process.pid),
647-
token,
648-
legacy_state_db: paths.legacyStateDbPath,
649-
runtime_state_db: paths.stateDbPath,
650-
})}\n`);
651-
fs.fsyncSync(descriptor);
674+
fs.linkSync(candidate.candidatePath, gatePath);
675+
descriptor = candidate.descriptor;
676+
published = true;
652677
break;
653678
} catch (error) {
654-
if (descriptor !== undefined) {
655-
try { fs.closeSync(descriptor); } catch { /* best effort */ }
656-
descriptor = undefined;
657-
}
658679
if (error.code !== 'EEXIST') throw error;
659680
const observed = readMigrationGate(gatePath);
681+
if (observed?.missing) continue;
660682
const ownerPid = Number(observed?.owner?.pid);
661683
const live = processIsAlive(ownerPid);
662684
const currentStart = live ? processStartMarker(ownerPid) : null;
@@ -684,6 +706,11 @@ function acquireStateMigrationGate(paths, {
684706
continue;
685707
}
686708
reclaimDeadMigrationGate(gatePath, observed);
709+
} finally {
710+
fs.rmSync(candidate.candidatePath, { force: true });
711+
if (!published) {
712+
try { fs.closeSync(candidate.descriptor); } catch { /* best effort */ }
713+
}
687714
}
688715
}
689716
return () => {

mcp-server/lib/runtime-paths.test.cjs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,116 @@ test('state migration reclaims a gate whose owner was killed', async () => {
883883
}
884884
});
885885

886+
test('state migration publishes complete gate metadata before competitors can observe it', async () => {
887+
const rootDir = mkRoot();
888+
let first;
889+
let second;
890+
try {
891+
const paths = runtimePaths.pathsFor(rootDir);
892+
fs.mkdirSync(paths.runtimeDir, { recursive: true });
893+
const writerReady = path.join(rootDir, 'gate-writer-ready');
894+
const releaseWriter = path.join(rootDir, 'release-gate-writer');
895+
const modulePath = path.join(__dirname, 'runtime-paths.cjs');
896+
const firstScript = [
897+
"const fs = require('node:fs');",
898+
'const runtimePaths = require(process.argv[1]);',
899+
'const paths = runtimePaths.pathsFor(process.argv[2]);',
900+
'const ready = process.argv[3];',
901+
'const release = process.argv[4];',
902+
'const originalOpen = fs.openSync.bind(fs);',
903+
'const originalWrite = fs.writeFileSync.bind(fs);',
904+
'let gateDescriptor = null;',
905+
'let delayed = false;',
906+
'fs.openSync = (target, flags, mode) => {',
907+
' const descriptor = originalOpen(target, flags, mode);',
908+
" if (String(target).includes('state-migration.lock') && flags === 'wx') {",
909+
' gateDescriptor = descriptor;',
910+
' }',
911+
' return descriptor;',
912+
'};',
913+
'fs.writeFileSync = (target, ...args) => {',
914+
' if (target === gateDescriptor && !delayed) {',
915+
' delayed = true;',
916+
" originalWrite(ready, 'ready');",
917+
' while (!fs.existsSync(release)) {',
918+
' Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);',
919+
' }',
920+
' }',
921+
' return originalWrite(target, ...args);',
922+
'};',
923+
'try {',
924+
' const releaseGate = runtimePaths._internal.acquireStateMigrationGate(paths);',
925+
' releaseGate();',
926+
' process.stdout.write(JSON.stringify({ acquired: true }));',
927+
'} catch (error) {',
928+
' process.stdout.write(JSON.stringify({ acquired: false, code: error.code || null }));',
929+
'}',
930+
].join('\n');
931+
const secondScript = [
932+
'const runtimePaths = require(process.argv[1]);',
933+
'const paths = runtimePaths.pathsFor(process.argv[2]);',
934+
'try {',
935+
' const releaseGate = runtimePaths._internal.acquireStateMigrationGate(paths);',
936+
' releaseGate();',
937+
' process.stdout.write(JSON.stringify({ acquired: true }));',
938+
'} catch (error) {',
939+
' process.stdout.write(JSON.stringify({ acquired: false, code: error.code || null }));',
940+
'}',
941+
].join('\n');
942+
943+
first = spawn(
944+
process.execPath,
945+
['-e', firstScript, modulePath, rootDir, writerReady, releaseWriter],
946+
{ cwd: path.resolve(__dirname, '..', '..'), stdio: ['ignore', 'pipe', 'pipe'] },
947+
);
948+
const firstDone = collectChild(first, 'delayed migration gate writer');
949+
await waitForFile(writerReady, 'migration gate writer');
950+
951+
second = spawn(
952+
process.execPath,
953+
['-e', secondScript, modulePath, rootDir],
954+
{ cwd: path.resolve(__dirname, '..', '..'), stdio: ['ignore', 'pipe', 'pipe'] },
955+
);
956+
const secondResult = await collectChild(second, 'migration gate competitor');
957+
fs.writeFileSync(releaseWriter, 'release');
958+
const firstResult = await firstDone;
959+
960+
assert.deepEqual(secondResult, { acquired: true });
961+
assert.deepEqual(firstResult, { acquired: true });
962+
assert.equal(
963+
fs.existsSync(path.join(paths.runtimeDir, 'state-migration.lock')),
964+
false,
965+
);
966+
} finally {
967+
if (first?.exitCode === null) first.kill('SIGKILL');
968+
if (second?.exitCode === null) second.kill('SIGKILL');
969+
cleanup(rootDir);
970+
}
971+
});
972+
973+
test('state migration rejects a stable malformed gate', () => {
974+
const rootDir = mkRoot();
975+
try {
976+
const paths = runtimePaths.pathsFor(rootDir);
977+
const initialized = initStateDb(paths.legacyStateDbPath);
978+
closeStateDb(initialized.db);
979+
fs.mkdirSync(paths.runtimeDir, { recursive: true });
980+
const gatePath = path.join(paths.runtimeDir, 'state-migration.lock');
981+
fs.writeFileSync(gatePath, '{"version":');
982+
983+
assert.throws(
984+
() => runtimePaths.ensureRuntimeState(rootDir),
985+
(error) => error instanceof runtimePaths.RuntimePathError
986+
&& error.code === 'RUNTIME_STATE_NOT_QUIESCENT'
987+
&& /malformed or unsafe/.test(error.message),
988+
);
989+
assert.equal(fs.readFileSync(gatePath, 'utf8'), '{"version":');
990+
assert.equal(fs.existsSync(paths.stateDbPath), false);
991+
} finally {
992+
cleanup(rootDir);
993+
}
994+
});
995+
886996
test('state migration leaves a gate owned by a live process fail-closed', () => {
887997
const rootDir = mkRoot();
888998
try {

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ultra-builder-pro-cli",
3-
"version": "0.25.0",
3+
"version": "0.25.1",
44
"description": "Explicitly invoked Ultra Builder Pro workflows for Claude Code, OpenCode, Codex CLI, Kimi Code, and Grok Build, with a project-local MCP persistence and safety kernel.",
55
"bin": {
66
"ultra-builder-pro-cli": "bin/install.js",

0 commit comments

Comments
 (0)