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:
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user