cli: release host.lock on a late fork or send throw; retry a vanishing lock (row 106, #1600)

Darkwing round 1, approved by Dewey in #1600 comment 28319 on manifest
ae38cbe280aa5885aec7648d38143920753db86ea1ca3934d0a8885fb61e5105.
Sage gate rerun on f63c965f9 plus build.patch: every package suite and
every scripts/test-*.sh green (cli 168, harness 73, test-release 14).
readHostState now treats a host.json that vanishes mid-read as no host
for stopHost and hostStatus too (Dewey note b); Sage accepts it as
correct. Nothing goes live until a re-pin under Mos's go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 18:01:02 -05:00
co-authored by Claude Opus 5.5
parent f63c965f99
commit 8b58cb7e47
5 changed files with 109 additions and 24 deletions
+23 -12
View File
@@ -44,12 +44,16 @@ export function readHostState(dataRoot) {
return readState(hostFile(dataRoot));
}
function readState(file) {
// A file that goes between the check and the read counts as no file.
// `found` runs between the two; it is for the tests.
function readState(file, found = () => {}) {
if (!existsSync(file)) return null;
found();
let state;
try {
state = JSON.parse(readFileSync(file, "utf8"));
} catch {
} catch (e) {
if (e.code === "ENOENT") return null;
throw new CliError(`bus host state is unreadable: ${file}`, 3);
}
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
@@ -68,9 +72,11 @@ const same = (a, b) => a?.pid === b?.pid && a?.startTime === b?.startTime && a?.
// moved aside, checked to be the file that was read, and removed; another
// host's claim moved aside by that race goes back. A third host racing the
// same stale lock can still lose a claim there, and then the broker's writer
// lock refuses the second broker. `between` runs after the stale lock is
// read; it is for the tests. Exported for the tests.
export function claimHostLock(dataRoot, state, { between = () => {} } = {}) {
// lock refuses the second broker. A lock that goes before it is read is
// tried again, like one that goes before the check. `found` runs once the
// lock is found and before it is read, `between` after the stale lock is
// read; both are for the tests. Exported for the tests.
export function claimHostLock(dataRoot, state, { found = () => {}, between = () => {} } = {}) {
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
const file = hostLock(dataRoot);
const tag = `${process.pid}.${randomBytes(4).toString("hex")}`;
@@ -85,7 +91,7 @@ export function claimHostLock(dataRoot, state, { between = () => {} } = {}) {
} catch (e) {
if (e.code !== "EEXIST") throw e;
}
const prior = readState(file);
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
between();
@@ -177,15 +183,20 @@ export async function startHost({ boot, business, notifier = null, bootTimeoutMs
if (same(held, state)) rmSync(hostLock(dataRoot), { force: true });
};
const broker = fork(BROKER, [], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
const reply = firstReply(broker, bootTimeoutMs, "broker");
broker.send({ op: "boot", config: boot });
// A fork or a send that throws gives the lock back too. The wait starts
// after the send, so a send that throws leaves no wait behind. The reply
// and the exit event come on a later tick, so the wait misses neither.
let broker = null;
let ready;
try {
ready = await reply;
broker = fork(BROKER, [], { stdio: ["ignore", "inherit", "inherit", "ipc"] });
broker.send({ op: "boot", config: boot });
ready = await firstReply(broker, bootTimeoutMs, "broker");
} catch (e) {
broker.kill("SIGTERM");
await ended(broker, 5000);
if (broker) {
broker.kill("SIGTERM");
await ended(broker, 5000);
}
release();
throw e;
}