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
+79 -1
View File
@@ -11,6 +11,7 @@ import { spawnSync } from "node:child_process";
import { createHash, randomBytes } from "node:crypto";
import { connect } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { tmpdir } from "node:os";
import { Duplex } from "node:stream";
import { join } from "node:path";
@@ -21,7 +22,7 @@ import { gitSocketPath } from "../../seat/src/session.mjs";
import { workingRepo } from "../../seat/src/working.mjs";
import { fetchReview, pushWorking } from "../../harness/src/git-tools.mjs";
import { bootConfig, loadSystem, rolesDir } from "../src/config.mjs";
import { createGitSocket, send } from "../src/git-socket.mjs";
import { CLOSE_WAIT_MS, createGitSocket, letGo, send } from "../src/git-socket.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { fixture } from "./helpers.mjs";
@@ -526,6 +527,83 @@ test("send: a close after every byte left is served; one that fails a queued wri
await rejects(gone, "fetch-aborted");
});
test("a bundle with no bytes for bodyIdle refuses body-timeout long before its deadline", async (t) => {
// The deadline is a minute away, so only the idle limit can end it.
const w = await setup(t, { bodyDeadline: () => 60000, bodyIdle: 300 });
const { coder } = w.roles;
const started = Date.now();
// Sends one byte of 1000 after "send", then nothing.
const stalled = new Promise((resolve, reject) => {
const s = connect(w.sock.path);
let buf = "";
s.on("connect", () => s.write(pushReq(coder.cap, 1000)));
s.on("data", (b) => {
const sent = buf === `${JSON.stringify({ ok: true, send: 1000 })}\n`;
buf += b;
if (!sent && buf === `${JSON.stringify({ ok: true, send: 1000 })}\n`) s.write("x");
});
s.on("close", () => resolve({ last: buf.trim().split("\n").pop(), ms: Date.now() - started }));
s.on("error", () => {});
setTimeout(() => (s.destroy(), reject(new Error("no refusal in 5 s"))), 5000).unref();
});
const { last, ms } = await stalled;
assert.deepEqual(JSON.parse(last), { ok: false, error: "body-timeout" });
assert.ok(ms >= 300 && ms < 5000, `${ms} ms`);
assert.ok((await refusedOn(w)).includes("coder body-timeout"));
assert.equal(w.refs(), "");
});
// A FIFO as the bundle holds send()'s loop open on a read, so a test can end
// the connection between the last chunk and the loop's end: the arm after the
// loop. A plain close there was served; a failed write was fetch-aborted.
test("send: a close once the loop is past its last chunk is served, and a failed write there is fetch-aborted", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "mosaic-send-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const fifo = join(dir, "b.fifo");
assert.equal(spawnSync("mkfifo", [fifo]).status, 0);
// `fail` is how the stand-in socket ends: destroyed as it is, or by
// failing its held write as a socket whose reader left does.
const run = async (fail) => {
let pending = null;
const sock = new Duplex({ read() {}, write: (c, e, cb) => (pending = cb), final: (cb) => cb() });
sock.on("error", () => {});
const sent = send(sock, fifo);
// Settled before the FIFO ends, so neither outcome is unhandled.
const outcome = sent.then(() => "served", (e) => e.code);
const writer = await open(fifo, "w");
await writer.write("ten bytes!");
await until(() => pending, "the write");
if (fail) pending(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
else sock.destroy();
await until(() => sock.destroyed, "the socket's end");
await writer.close();
return outcome;
};
assert.equal(await run(false), "served");
assert.equal(await run(true), "fetch-aborted");
});
// A peer that never reads leaves the host's last write unfinished; this
// stand-in never finishes one.
test("letGo waits for an answer to leave for at most its cap, then destroys the socket", async (t) => {
const stuck = new Duplex({ read() {}, write() {}, final() {} });
stuck.on("error", () => {});
stuck.end("host-closing\n");
const quick = new Duplex({ read() {}, write: (c, e, cb) => cb(), final: (cb) => cb() });
quick.on("error", () => {});
quick.end("host-closing\n");
const started = Date.now();
let timer = null;
const hung = new Promise((r) => (timer = setTimeout(() => r("hung"), 3000)));
t.after(() => clearTimeout(timer));
assert.equal(await Promise.race([letGo([stuck, quick], 300).then(() => "let go"), hung]), "let go");
const ms = Date.now() - started;
assert.ok(ms >= 290 && ms < 3000, `${ms} ms`);
assert.ok(stuck.destroyed && quick.destroyed);
assert.ok(CLOSE_WAIT_MS > 0 && CLOSE_WAIT_MS <= 10000, `CLOSE_WAIT_MS is ${CLOSE_WAIT_MS}`);
});
test("a record that fails and can't be undone refuses rollback-failed, which the host logs", async (t) => {
let lock = null;
const w = await setup(t, {