Merge pull request 'Start the gitea bundle's binds without awaiting them, so its handshake is answered (issue 1793)' (#429) from fix/1793-bind-after-handshake into main

This commit was merged in pull request #429.
This commit is contained in:
2026-10-21 22:59:00 +00:00
4 changed files with 93 additions and 14 deletions
+14
View File
@@ -28,3 +28,17 @@ export async function bindUntilHeld(what: string, bind: () => Promise<unknown>,
}
}
}
/**
* **Starts the binds without making the bundle wait for them** (novox/hq issue 1793). The launcher imports the bundle
* and then answers the runtime's handshake, which the runtime gives 10 s and then kills the bundle without starting it
* again; a bind awaited at the top of the module that stays refused longer ends the bundle all the same. So this
* returns at once: the binds run in the order given, each until it holds, and `done` is called after the last.
*/
export function bindInBackground(binds: [what: string, bind: () => Promise<unknown>][], done: () => void,
sleep?: (ms: number) => Promise<void>, say?: (line: string) => void): void {
void (async () => {
for (const [what, bind] of binds) await bindUntilHeld(what, bind, sleep, say);
done();
})();
}
+15 -13
View File
@@ -47,7 +47,7 @@ import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateS
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindUntilHeld } from "./bind.js";
import { bindInBackground } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
@@ -591,16 +591,18 @@ if (gitea) {
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every());
// Bound again until it holds (bind.ts, issue 1793): a refusal at the start must not end the bundle.
await bindUntilHeld("mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
}));
await bindUntilHeld("node-build-agent.started", () => on("node-build-agent.started", buildStarted));
console.log("[gitea] watching for new repositories and merged pull requests");
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
// handshake in 10 s, so the import must end while a refused bind is still asked again.
bindInBackground([
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
})],
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
}
+26 -1
View File
@@ -5,7 +5,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { bindUntilHeld, bindWait } from "../dist/bind.js";
import { bindInBackground, bindUntilHeld, bindWait } from "../dist/bind.js";
test("a bind refused with no responders is asked again until it holds, and says so", async () => {
let calls = 0;
@@ -31,3 +31,28 @@ test("a bind that holds the first time says nothing and waits for nothing", asyn
test("the wait grows to 30 s and stays", () => {
assert.deepEqual([1, 2, 3, 4, 50].map(bindWait), [2_000, 5_000, 10_000, 30_000, 30_000]);
});
test("the binds start without being waited for: the caller returns while a bind keeps refusing, and they go in order", async () => {
const order: string[] = [];
let finished = false;
let refusals = 0;
const gate = new Promise<void>((r) => setTimeout(r, 0));
bindInBackground([
["first", async () => { refusals++; if (refusals < 3) throw new Error("nats: no responders available for request"); order.push("first"); }],
["second", async () => { order.push("second"); }],
], () => { finished = true; }, async () => { await gate; }, () => {});
// Returned at once, with the first bind refused and nothing done.
assert.equal(finished, false);
assert.deepEqual(order, []);
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(order, ["first", "second"]);
assert.equal(finished, true);
});
test("a bind that never holds does not stop the caller from returning", () => {
let returned = false;
bindInBackground([["x", async () => { throw new Error("no responders"); }]], () => assert.fail("done"),
() => new Promise(() => {}), () => {});
returned = true;
assert.equal(returned, true);
});
+38
View File
@@ -0,0 +1,38 @@
// **The bundle's import ends while a bind is refused** (novox/hq issue 1793). The launcher imports index.js and then answers
// node-tools' handshake, which has 10 s before the bundle is killed and never started again. A bind awaited at the top
// of index.ts, refused for longer, ended the bundle at 00:28:37 on 2026-10-22. Run in a child: the polls' timers keep a
// process alive, and the child says "imported" and exits as soon as the import resolves.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
test("importing the bundle resolves while the broker keeps refusing the binds", async () => {
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
const script = `
const { useBroker } = await import("@novox/mesh-sdk/messaging");
useBroker(() => ({
subscribe: async () => { throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request"); },
publish: async () => {}, request: async () => { throw new Error("no responders"); },
handle: async () => () => {}, close: async () => {},
}));
await import(${JSON.stringify(index)});
console.log("imported");
process.exit(0);`;
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" };
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE"]) delete env[k];
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const ended = new Promise<void>((r) => child.on("exit", () => r()));
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
await ended;
clearTimeout(timer);
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
});