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
+65 -1
View File
@@ -4,7 +4,7 @@ import { spawn, spawnSync } from "node:child_process";
import { subscribe, unsubscribe } from "node:diagnostics_channel";
import { createServer } from "node:http";
import { once } from "node:events";
import { chmodSync, existsSync, readFileSync, readdirSync, statSync, symlinkSync, utimesSync, writeFileSync, mkdirSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Client } from "../../bus/src/client.mjs";
@@ -108,6 +108,33 @@ function failSends(t, fake) {
});
}
// Makes the next fork throw (`where` "fork") or the new child's sends throw
// ("send"), through the `send` that node installs on a new child: a fork
// throws when the install does. Every child it saw is killed after the test,
// the one a throwing fork started included.
function throwOn(t, where) {
const children = [];
const onChild = ({ process: child }) => {
children.push(child);
let send;
Object.defineProperty(child, "send", {
configurable: true,
get: () => send,
set() {
if (where === "fork") throw new Error("fork failed");
send = () => {
throw new Error("send failed");
};
},
});
};
subscribe("child_process", onChild);
t.after(() => {
unsubscribe("child_process", onChild);
for (const child of children) child.kill("SIGKILL");
});
}
const epipe = (child) => {
child.kill("SIGKILL");
return Object.assign(new Error("write EPIPE"), { code: "EPIPE", errno: -32, syscall: "write" });
@@ -259,6 +286,27 @@ test("a host takes over a host.lock its dead host left, and puts back a live cla
assert.deepEqual(left(), ["host.lock"]);
});
// Row 106 (#1600, Dewey a): a lock removed after the check and before the
// read is tried again, within CLAIM_TRIES, and never refused as unreadable.
test("a host.lock that goes between the check and the read is tried again; one that can't be parsed still refuses as unreadable", (t) => {
const dataRoot = tmp(t);
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
const stale = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-10-13T00:00:00.000Z" };
const mine = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business: "acme", startedAt: "2026-10-13T00:00:01.000Z", notifier: null };
writeFileSync(hostLock(dataRoot), JSON.stringify(stale), { mode: 0o600 });
let found = 0;
const gone = () => found++ === 0 && rmSync(hostLock(dataRoot));
claimHostLock(dataRoot, mine, { found: gone });
assert.equal(found, 1, "the lock was found once, and gone before the read");
assert.equal(JSON.parse(readFileSync(hostLock(dataRoot), "utf8")).startedAt, mine.startedAt);
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
test("a live host's lock stays, however old the file is", (t) => {
const dataRoot = tmp(t);
mkdirSync(hostDir(dataRoot), { recursive: true, mode: 0o700 });
@@ -330,6 +378,22 @@ test("a host started before host.lock existed, holding only host.json, still ref
assert.deepEqual(readdirSync(hostDir(f.dataRoot)), ["host.json"]);
});
// Row 106 (#1600, Sage N1): a throw after the claim and before the boot
// reply gives host.lock back like a refusal does.
for (const where of ["fork", "send"])
test(`a broker ${where} that throws leaves no host.lock and no host.json`, async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
throwOn(t, where);
const started = startHost({ boot, business: "acme", log: () => {} });
t.after(async () => (await started.catch(() => null))?.close(0));
await assert.rejects(started, (e) => e.message === `${where} failed`);
assert.equal(existsSync(hostLock(f.dataRoot)), false);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
});
test("a broker that misses its boot wait leaves no host.lock and no host.json", async (t) => {
const root = tmp(t);
const f = fixture(root);