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
+32 -12
View File
@@ -36,7 +36,8 @@
// and go to the log as well.
//
// close() answers host-closing to a connection still waiting for its request
// line, since nothing of it ran, and drops every other connection.
// line, since nothing of it ran, and drops every other connection. It waits
// at most CLOSE_WAIT_MS for those answers to leave, then drops the rest.
//
// A bundle file lives in bus-host/ (0700) under a random name, and the host
// deletes it on every path.
@@ -62,6 +63,8 @@ const BODY_MIN_RATE = 256;
export const bodyDeadline = (size) => BODY_IDLE_MS + Math.ceil(size / BODY_MIN_RATE);
// Connections served at once; more refuse git-busy.
const MAX_ACTIVE = 8;
// The longest close() waits for its host-closing answers to leave.
export const CLOSE_WAIT_MS = 2000;
const CAP = /^[0-9a-f]{64}$/;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
@@ -119,8 +122,8 @@ function readHeader(socket, waiting) {
}
// Exactly `size` bytes from the socket into a new file (0600), within
// `deadlineMs` and with no gap over BODY_IDLE_MS.
function readBody(socket, file, size, deadlineMs) {
// `deadlineMs` and with no gap over `idleMs`.
function readBody(socket, file, size, deadlineMs, idleMs) {
return new Promise((resolve, reject) => {
const fd = openSync(file, "wx", 0o600);
let got = 0;
@@ -139,7 +142,7 @@ function readBody(socket, file, size, deadlineMs) {
err ? reject(err) : resolve();
};
const onShort = () => done(new Refusal("invalid-request", `the bundle ended after ${got} of ${size} bytes`));
const onIdle = () => done(new Refusal("body-timeout", `no bundle bytes for ${BODY_IDLE_MS} ms, after ${got} of ${size}`));
const onIdle = () => done(new Refusal("body-timeout", `no bundle bytes for ${idleMs} ms, after ${got} of ${size}`));
const deadline = setTimeout(() => done(new Refusal("body-timeout", `${got} of ${size} bundle bytes in ${deadlineMs} ms`)), deadlineMs);
const onData = (chunk) => {
if (got + chunk.length > size) return done(new Refusal("invalid-request", "more bytes than the request's size"));
@@ -154,7 +157,7 @@ function readBody(socket, file, size, deadlineMs) {
socket.on("data", onData);
socket.once("end", onShort);
socket.once("close", onShort);
socket.setTimeout(BODY_IDLE_MS);
socket.setTimeout(idleMs);
socket.once("timeout", onIdle);
socket.resume();
});
@@ -164,8 +167,13 @@ function readBody(socket, file, size, deadlineMs) {
// own file throws as it is. The reviewer reads exactly the bundle's size and
// hangs up, which can come before the loop sees the file's end or before
// 'finish'. So a close is fetch-aborted while chunks are still to go, or when
// writing one failed (EPIPE: the reader left with bytes still queued here).
// Exported for the tests.
// writing one failed, and served otherwise.
//
// That rests on one assumption: a reader that leaves while bytes are still
// queued in this process fails the write that holds them (EPIPE, or
// ECONNRESET), which sets socket.errored. Bytes the kernel already took count
// as sent, even if the reviewer never reads them; its own size check catches
// a short bundle on its side. Exported for the tests.
export async function send(socket, file) {
const aborted = () => new Refusal("fetch-aborted", "the reviewer left before the bundle was sent");
const served = () => !socket.errored;
@@ -197,9 +205,21 @@ export async function send(socket, file) {
if (!served()) throw aborted();
}
// Waits until each socket has flushed what it was sent or closed, for at most
// `ms`, then destroys every one still open: a peer that never reads can't hold
// close() up. Exported for the tests.
export async function letGo(sockets, ms = CLOSE_WAIT_MS) {
let timer = null;
const capped = new Promise((r) => (timer = setTimeout(r, ms)));
const flushed = sockets.map((s) => (s.destroyed || s.writableFinished ? null : new Promise((r) => (s.once("finish", r), s.once("close", r)))));
await Promise.race([capped, Promise.all(flushed)]);
clearTimeout(timer);
for (const s of sockets) s.destroy();
}
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
// bodyDeadline, maxActive and fsck are for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, maxActive = MAX_ACTIVE, fsck = FSCK }) {
// bodyDeadline, bodyIdle, maxActive and fsck are for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK }) {
const path = gitSocketPath(dataRoot);
const dir = join(dataRoot, "bus-host");
let server = null;
@@ -228,7 +248,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (rest.length) throw new Refusal("invalid-request", "bundle bytes before send");
await op({ op: "checkPush", cap, task_ref });
socket.write(`${JSON.stringify({ ok: true, send: size })}\n`);
await readBody(socket, file, size, deadlineOf(size));
await readBody(socket, file, size, deadlineOf(size), bodyIdle);
const result = await push({
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, branch, bundle: file, env, fsck,
record: (r) => op({ op: "recordPush", cap, push: { task_ref, project: req.project, branch, ...r } }),
@@ -319,8 +339,8 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
for (const socket of sockets) if (!told.has(socket)) socket.destroy();
await Promise.allSettled([...active]);
// The refusal is a few bytes; once it is flushed, or the peer is gone,
// nothing more is owed.
await Promise.all([...told].map((s) => (s.destroyed || s.writableFinished ? null : new Promise((r) => (s.once("finish", r), s.once("close", r))))));
// nothing more is owed, and past CLOSE_WAIT_MS it is dropped unsent.
await letGo([...told]);
for (const socket of sockets) socket.destroy();
await stopped;
rmSync(path, { force: true });