From 49a28f85378b3ce1840c6033b34b5a01bc2235aa Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 00:45:54 +0200 Subject: [PATCH 1/2] Start the gitea bundle's binds without awaiting them, so its handshake is answered while a refused bind is asked again (issue 1793) --- modules/gitea/bind.ts | 14 ++++++++++++++ modules/gitea/index.ts | 28 +++++++++++++++------------- modules/gitea/test/bind.test.ts | 27 ++++++++++++++++++++++++++- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/modules/gitea/bind.ts b/modules/gitea/bind.ts index 59d1fae0..3d6dd9b4 100644 --- a/modules/gitea/bind.ts +++ b/modules/gitea/bind.ts @@ -28,3 +28,17 @@ export async function bindUntilHeld(what: string, bind: () => Promise, } } } + +/** + * **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][], done: () => void, + sleep?: (ms: number) => Promise, say?: (line: string) => void): void { + void (async () => { + for (const [what, bind] of binds) await bindUntilHeld(what, bind, sleep, say); + done(); + })(); +} diff --git a/modules/gitea/index.ts b/modules/gitea/index.ts index b5c234d1..601290e6 100644 --- a/modules/gitea/index.ts +++ b/modules/gitea/index.ts @@ -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")); } diff --git a/modules/gitea/test/bind.test.ts b/modules/gitea/test/bind.test.ts index 7f8d00e8..9de8d693 100644 --- a/modules/gitea/test/bind.test.ts +++ b/modules/gitea/test/bind.test.ts @@ -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((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); +}); -- 2.54.0 From 77f3963d8338b4c2b742945cbf70dc2d1920ee12 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 00:49:27 +0200 Subject: [PATCH 2/2] Test that the gitea bundle's import resolves while its binds are refused (issue 1793) --- modules/gitea/test/bundle-import.test.ts | 38 ++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 modules/gitea/test/bundle-import.test.ts diff --git a/modules/gitea/test/bundle-import.test.ts b/modules/gitea/test/bundle-import.test.ts new file mode 100644 index 00000000..d7b24e35 --- /dev/null +++ b/modules/gitea/test/bundle-import.test.ts @@ -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((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)}`); +}); -- 2.54.0