seat, cli, harness: git kill reach, close bound and host.lock (#1597)

Row 103, the row 100 follow-up. Built by Darkwing, packet
agents/darkwing/work/queue-103/r1/, candidate manifest
6e5ac8c0782bc595d7c0d711d457738ffb42f1ba5f38253912943dd55e55888a.
Sage approved on #1597, comment 28252; Dewey approved, comment 28260.

Host git runs through runGit: a detached group, SIGTERM at the limit,
SIGKILL after the grace, and one more SIGKILL to the group after a
signalled git closes. git-socket close() waits at most 2 s. startHost
claims bus-host/host.lock by hard link before the broker fork.
packages/cli/README.md and tests/host.test.mjs carry row 85's hunks
too; row 85 lands first.

Sage's gate rerun on 3a1e11b83 with rows 85, 102 and 103 applied,
Docker up: node suites bus 128, business 67, cli 165, control-board
124, conversation 182, discord 178, harness 73, ledger 78, mosaic 69,
queue 155, runs 43, seat 113, tasks 92, webui 108; scripts auth 15,
conductor 17, config 24, discord 66, extension-package 18,
foundation 44, queue 28, release 14, task 98. All pass, 0 fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-13 16:34:47 -05:00
co-authored by Claude Opus 5.5
parent 2a9b77b56b
commit 465bbdfbbc
12 changed files with 665 additions and 106 deletions
+147 -2
View File
@@ -4,12 +4,12 @@ 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, writeFileSync, mkdirSync } from "node:fs";
import { chmodSync, existsSync, readFileSync, readdirSync, 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";
import { bootConfig, loadSystem, REPO } from "../src/config.mjs";
import { hostDir, hostFile, hostStatus, startHost, startTimeOf, stopHost, watchChildren } from "../src/host.mjs";
import { claimHostLock, hostDir, hostFile, hostLock, hostStatus, readHostState, startHost, startTimeOf, stopHost, watchChildren } from "../src/host.mjs";
import { journalPath } from "../src/notifier.mjs";
import { IDS, makeDeployment } from "../../discord/tests/helpers.mjs";
import { fixture, notifyConfig, OPTIONS, tmp } from "./helpers.mjs";
@@ -186,6 +186,7 @@ test("a notifier that dies takes the host down with exit 1, so the unit restarts
assert.match(logs.join("\n"), /notifier exited \(SIGKILL\); stopping the host/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(hostLock(f.dataRoot)), false);
});
test("a second host for the same data root refuses with exit 3 while the first runs", async (t) => {
@@ -203,6 +204,145 @@ test("a second host for the same data root refuses with exit 3 while the first r
assert.equal(await host.close(0), 0);
});
test("two bus starts racing for one data root leave one host running; the other refuses that a host already runs", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const starts = [0, 1].map(() => {
const child = spawn(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"));
const run = { child, out: "", exit: once(child, "exit") };
child.stdout.on("data", (b) => (run.out += b));
child.stderr.on("data", (b) => (run.out += b));
return run;
});
// The loser exits; the winner keeps running.
const [first] = await Promise.race(starts.map((r, i) => r.exit.then((e) => [i, e])));
const loser = starts[first];
const winner = starts[1 - first];
assert.equal(loser.child.exitCode, 3, loser.out);
assert.match(loser.out, new RegExp(`a bus host already runs for acme \\(pid ${winner.child.pid}\\)`));
await until(() => /bus host up: business acme/.test(winner.out), 30000);
assert.equal(winner.child.exitCode, null);
const state = readHostState(f.dataRoot);
assert.equal(state.pid, winner.child.pid);
assert.equal(state.live, true);
assert.equal(JSON.parse(readFileSync(hostLock(f.dataRoot), "utf8")).pid, winner.child.pid);
const claims = () => readdirSync(hostDir(f.dataRoot)).filter((n) => n.startsWith("host.")).sort();
assert.deepEqual(claims(), ["host.json", "host.lock"], "no claim file left over");
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal((await winner.exit)[0], 0, winner.out);
assert.deepEqual(claims(), [], "the stop gives back host.lock and host.json");
});
test("a host takes over a host.lock its dead host left, and puts back a live claim that replaced it meanwhile", (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 };
const left = () => readdirSync(hostDir(dataRoot)).sort();
const held = () => JSON.parse(readFileSync(hostLock(dataRoot), "utf8"));
writeFileSync(hostLock(dataRoot), JSON.stringify(stale), { mode: 0o600 });
claimHostLock(dataRoot, mine);
assert.equal(held().startedAt, mine.startedAt);
assert.equal(statSync(hostLock(dataRoot)).mode & 0o777, 0o600);
assert.deepEqual(left(), ["host.lock"]);
// Another host removes the stale lock and takes it between this host's
// read and its move: that claim goes back, and this host refuses.
const other = { ...mine, business: "beta", startedAt: "2026-10-13T00:00:02.000Z" };
writeFileSync(hostLock(dataRoot), JSON.stringify(stale), { mode: 0o600 });
const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify(other), { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, mine, { between: race }), (e) => e.exitCode === 3 && e.message === `a bus host already runs for beta (pid ${process.pid})`);
assert.equal(held().startedAt, other.startedAt);
assert.deepEqual(left(), ["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 });
const live = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business: "beta", startedAt: "2000-01-01T00:00:00.000Z", notifier: null };
const text = JSON.stringify(live);
writeFileSync(hostLock(dataRoot), text, { mode: 0o600 });
const old = new Date("2000-01-01T00:00:00Z");
utimesSync(hostLock(dataRoot), old, old);
const mine = { ...live, business: "acme", startedAt: "2026-10-13T00:00:01.000Z" };
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `a bus host already runs for beta (pid ${process.pid})`);
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), text);
assert.equal(statSync(hostLock(dataRoot)).mtimeMs, old.getTime());
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
test("a host.lock left by a host killed with SIGKILL doesn't stop the next bus start", async (t) => {
const f = fixture(tmp(t));
notifyConfig(f.dataRoot, "acme", null);
const start = () => {
const child = spawn(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, stdio: ["ignore", "pipe", "pipe"] });
t.after(() => child.exitCode === null && child.signalCode === null && child.kill("SIGKILL"));
const run = { child, out: "", exit: once(child, "exit") };
child.stdout.on("data", (b) => (run.out += b));
child.stderr.on("data", (b) => (run.out += b));
return run;
};
const first = start();
await until(() => /bus host up: business acme/.test(first.out), 30000);
first.child.kill("SIGKILL");
await first.exit;
assert.equal(JSON.parse(readFileSync(hostLock(f.dataRoot), "utf8")).pid, first.child.pid, "the killed host never gave back its lock");
// The orphaned broker sees its channel close and lets go of the writer lock.
await until(() => !existsSync(join(f.dataRoot, "bus", "writer.lock")), 30000);
const second = start();
await until(() => /bus host up: business acme/.test(second.out) || second.child.exitCode !== null, 30000);
assert.equal(second.child.exitCode, null, second.out);
assert.equal(readHostState(f.dataRoot).pid, second.child.pid);
assert.equal(JSON.parse(readFileSync(hostLock(f.dataRoot), "utf8")).pid, second.child.pid);
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 70000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal((await second.exit)[0], 0, second.out);
});
test("while a host boots, a reader sees no host: the human commands still ask for --business, stop and status find none", (t) => {
const f = fixture(tmp(t));
const booting = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business: "acme", startedAt: "2026-10-13T00:00:01.000Z", notifier: null };
claimHostLock(f.dataRoot, booting);
assert.equal(readHostState(f.dataRoot), null);
assert.equal(hostStatus(f.dataRoot).host, null);
const inbox = spawnSync(process.execPath, [CLI, "inbox"], { env: f.env, encoding: "utf8", timeout: 20000 });
assert.notEqual(inbox.status, 0);
assert.match(inbox.stderr, /no bus host runs here, so name the business with --business/);
const stop = spawnSync(process.execPath, [CLI, "bus", "stop"], { env: f.env, encoding: "utf8", timeout: 20000 });
assert.equal(stop.status, 0, stop.stderr);
assert.match(stop.stdout, /no host runs/);
});
test("a host started before host.lock existed, holding only host.json, still refuses a second start, which leaves no lock", async (t) => {
const root = tmp(t);
const f = fixture(root);
makeDeployment(root);
mkdirSync(hostDir(f.dataRoot), { recursive: true, mode: 0o700 });
const legacy = { hostVersion: 1, pid: process.pid, startTime: startTimeOf(process.pid), business: "beta", startedAt: "2026-10-13T00:00:01.000Z", notifier: null };
writeFileSync(hostFile(f.dataRoot), JSON.stringify(legacy), { mode: 0o600 });
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const started = startHost({ boot, business: "acme", log: () => {} });
t.after(async () => (await started.catch(() => null))?.close(0));
await assert.rejects(started, (e) => e.exitCode === 3 && e.message === `a bus host already runs for beta (pid ${process.pid})`);
assert.deepEqual(readdirSync(hostDir(f.dataRoot)), ["host.json"]);
});
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);
makeDeployment(root);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const started = startHost({ boot, business: "acme", bootTimeoutMs: 1, log: () => {} });
t.after(async () => (await started.catch(() => null))?.close(0));
await assert.rejects(started, (e) => /broker/.test(e.message));
assert.equal(existsSync(hostLock(f.dataRoot)), false);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
test("a broker reply that misses the wait breaks the channel: the late reply answers nothing, later requests refuse, the host exits 1", { timeout: 30000 }, async (t) => {
const root = tmp(t);
const f = fixture(root);
@@ -296,6 +436,7 @@ test("a notifier that refuses stops the broker and the host refuses with exit 3"
await assert.rejects(started, (e) => e.exitCode === 3 && /no dmRecipient/.test(e.message));
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(hostLock(f.dataRoot)), false);
});
test("a notifier that refuses after the broker died still refuses with exit 3, without a send to the dead broker", async (t) => {
@@ -348,6 +489,8 @@ test("a notifier that dies before it replies, after the broker died, still refus
await new Promise((r) => setImmediate(r));
assert.deepEqual(errors, [], "no close was sent over the closed channel");
assert.equal(sends.filter((m) => m?.op === "close").length, 0);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(hostLock(f.dataRoot)), false);
});
test("a close send that fails with EPIPE after the notifier refuses still gives the notifier's refusal, exit 3", async (t) => {
@@ -435,6 +578,8 @@ test("bus start refuses with exit 3 and the code when the broker refuses to boot
const r = spawnSync(process.execPath, [CLI, "bus", "start", "acme"], { env: f.env, encoding: "utf8", timeout: 40000 });
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /broker refused to start: startup-refused/);
assert.equal(existsSync(hostFile(f.dataRoot)), false);
assert.equal(existsSync(hostLock(f.dataRoot)), false);
const s = spawnSync(process.execPath, [CLI, "bus", "status"], { env: f.env, encoding: "utf8" });
assert.equal(s.status, 0, s.stderr);
assert.match(s.stdout, /host: none/);