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
+77
View File
@@ -331,3 +331,80 @@ test("every project in the business file gets its own clone at its own branch",
assert.deepEqual(list.map((c) => [c.project, c.path, c.outcome, c.branch]), [["alpha", join(workspace, "alpha"), "cloned", "main"], ["beta", join(workspace, "beta"), "cloned", "dev"]]);
assert.equal(git(join(workspace, "beta"), "symbolic-ref", "--short", "HEAD"), "dev");
});
// A git on PATH that, for `git clone`, runs `body` in place of the real one.
// Every other git is the real one.
function slowClone(w, body) {
const bin = join(w.dir, "bin");
mkdirSync(bin);
const real = spawnSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).stdout.trim();
writeFileSync(join(bin, "git"), `#!/bin/bash\ncase " $* " in *" clone "*) ${body} ;; esac\nexec '${real}' "$@"\n`, { mode: 0o755 });
return { PATH: `${bin}:${process.env.PATH}` };
}
// True once `pid` has gone, or is a zombie waiting for init to reap it.
async function gone(pid, ms = 3000) {
const end = Date.now() + ms;
for (;;) {
let stat = null;
try {
stat = readFileSync(`/proc/${pid}/stat`, "utf8");
} catch {}
if (stat === null || stat.slice(stat.lastIndexOf(")") + 2)[0] === "Z") return true;
if (Date.now() > end) return false;
await new Promise((r) => setTimeout(r, 50));
}
}
// SIGKILL to a sleep a test left behind, if it still runs.
const kill = (pid) => {
try {
process.kill(pid, "SIGKILL");
} catch {}
};
// The pid a wrapper wrote to `file`, once it is there.
async function pidIn(file) {
for (let i = 0; i < 100 && !existsSync(file); i++) await new Promise((r) => setTimeout(r, 20));
return Number(readFileSync(file, "utf8").trim());
}
test("a clone past its time gets SIGTERM and refuses the launch, with nothing left in the workspace", async (t) => {
const w = world(t);
const env = slowClone(w, "exec sleep 30");
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), (e) => e instanceof CheckoutError && /killed by SIGTERM/.test(e.message));
assert.deepEqual(readdirSync(w.workspace), []);
});
test("a clone that ignores SIGTERM gets SIGKILL once the grace period is over", { timeout: 20000 }, async (t) => {
const w = world(t);
const env = slowClone(w, "trap '' TERM; sleep 30");
const started = Date.now();
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 300, graceMs: 700 } }), (e) => e instanceof CheckoutError && /killed by SIGKILL/.test(e.message));
assert.ok(Date.now() - started >= 1000, "SIGKILL waited out the grace period");
});
// git is the leader of its own process group, and the group gets the signal:
// a process git started (here a sleep) doesn't outlive the kill.
test("a timeout signals git's process group, so a process git started dies with it", { timeout: 20000 }, async (t) => {
const w = world(t);
const file = join(w.dir, "child.pid");
const env = slowClone(w, `sleep 30 </dev/null >/dev/null 2>&1 & echo $! > '${file}'; wait`);
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/);
const pid = await pidIn(file);
t.after(() => kill(pid));
assert.equal(await gone(pid), true, "the sleep git started is gone");
});
// git dies of SIGTERM, but a process it started ignores SIGTERM and holds
// none of git's output, so git's close comes at once. The group gets
// SIGKILL then, not after the grace period.
test("once a signalled git has closed, its group gets SIGKILL, so a process that ignored SIGTERM dies too", { timeout: 20000 }, async (t) => {
const w = world(t);
const file = join(w.dir, "child.pid");
const env = slowClone(w, `(trap '' TERM; exec sleep 30) </dev/null >/dev/null 2>&1 & echo $! > '${file}'; exec sleep 30`);
await assert.rejects(ensureCheckouts({ workspace: w.workspace, projects: { stack: { root: w.root } }, env, limits: { timeoutMs: 500, graceMs: 20000 } }), /killed by SIGTERM/);
const pid = await pidIn(file);
t.after(() => kill(pid));
assert.equal(await gone(pid, 1500), true, "the sleep that ignored SIGTERM is gone well inside the grace period");
});