From 73dd6bb592933fa5651ea4211b19891ce83f14d6 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 14:27:12 +0000 Subject: [PATCH] fix(dns): retry the ending list until it loads, then refresh it hourly (0.118.2) The bridge read the ending list once at start. Restarted on a machine whose resolver is routed to it (the normal setup), it could not reach the registry while it was itself not yet listening, logged 'could not read the ending list', and answered every Moshpit name NXDOMAIN until restarted again. Seen on bonita 2026-10-17: live.streaming and family.ancestry did not resolve though dev2's bridge served both. keepEndingsFresh retries an empty list (5 s, 10 s, 30 s, then every 5 min), refreshes a loaded one hourly (new endings resolve without a restart), and never empties a good list on a failed refresh. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- src/dns.mjs | 59 +++++++++++++++++++++++++++++++++ test/dns-endings-retry.test.mjs | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 test/dns-endings-retry.test.mjs diff --git a/package.json b/package.json index 4f0a1465..0658e797 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moshcode", - "version": "0.118.1", + "version": "0.118.2", "type": "module", "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", "repository": { diff --git a/src/dns.mjs b/src/dns.mjs index 1fec6493..946c5672 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -450,6 +450,63 @@ const TLD_PAGE = 1000; * Paged to exhaustion against `total`, with the page count bounded so a * registry that misreports it cannot spin here forever. */ +/** + * Keep the bridge's ending list loaded and current. + * + * Read once at start, the list was all-or-nothing for the life of the + * process: a bridge started while this machine's own resolver was itself (the + * usual case on a restart, since the resolver is routed here and the bridge + * is not listening yet) failed to reach the registry, answered every Moshpit + * name NXDOMAIN, and kept doing so until somebody restarted it again. Now an + * empty list is retried in the background (5 s, 10 s, 30 s, then every five + * minutes), and a loaded one is refreshed hourly so a newly claimed ending + * resolves without a restart. A failed refresh never empties a good list. + * + * `tldSet` is filled in place: the server reads that same set per query. + */ +export function keepEndingsFresh({ + tldSet, + load, + out = () => {}, + schedule = (fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; }, + cancel = clearTimeout, + retryDelays = [5_000, 10_000, 30_000], + retryEvery = 300_000, + refreshEvery = 3_600_000, +} = {}) { + let attempt = 0; + let timer = null; + let stopped = false; + const next = () => { + if (stopped) return; + const ms = tldSet.size ? refreshEvery : (retryDelays[attempt] ?? retryEvery); + timer = schedule(run, ms); + }; + const run = async () => { + if (stopped) return; + const hadNone = tldSet.size === 0; + try { + const found = await load(); + if (Array.isArray(found) && found.length) { + tldSet.clear(); + for (const one of found) tldSet.add(one); + if (hadNone) out(`answering for ${tldSet.size} endings (the ending list loaded on retry ${attempt + 1})`); + attempt = 0; + } else if (hadNone) { + attempt += 1; + } + } catch (err) { + if (hadNone) { + attempt += 1; + out(`! still cannot read the ending list — ${err?.message || err}; trying again`); + } + } + next(); + }; + next(); + return () => { stopped = true; if (timer) cancel(timer); }; +} + export async function fetchTlds({ registryBase = DEFAULT_REGISTRY_BASE, fetchImpl = fetch } = {}) { const base = `${registryBase.replace(/\/+$/, "")}/api/moshpit/tlds`; const seen = []; @@ -2881,6 +2938,8 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { }, onError: (err) => out(`! resolver socket error — ${err?.message || err}`), }); + // Retried until it loads, then refreshed: see keepEndingsFresh. + keepEndingsFresh({ tldSet, load: () => fetchTldsImpl({ registryBase }), out }); } catch (err) { const why = err?.code === "EACCES" ? `needs privileges to bind port ${port}` diff --git a/test/dns-endings-retry.test.mjs b/test/dns-endings-retry.test.mjs new file mode 100644 index 00000000..81d273cf --- /dev/null +++ b/test/dns-endings-retry.test.mjs @@ -0,0 +1,58 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { keepEndingsFresh } from "../src/dns.mjs"; + +function clock() { + const queue = []; + return { + schedule: (fn, ms) => { const t = { fn, ms }; queue.push(t); return t; }, + cancel: (t) => { const at = queue.indexOf(t); if (at >= 0) queue.splice(at, 1); }, + async tick() { const t = queue.shift(); assert.ok(t, "something scheduled"); await t.fn(); return t.ms; }, + pending: () => queue.map((t) => t.ms), + }; +} + +test("an ending list that failed at start is retried until it loads, then refreshed hourly", async () => { + const tldSet = new Set(); + const c = clock(); + const said = []; + let calls = 0; + const load = async () => { + calls += 1; + if (calls < 3) throw new Error("fetch failed"); + return calls === 3 ? ["eggs", "streaming"] : ["ancestry", "eggs", "streaming"]; + }; + const stop = keepEndingsFresh({ tldSet, load, out: (line) => said.push(line), schedule: c.schedule, cancel: c.cancel }); + assert.deepEqual(c.pending(), [5_000]); + assert.equal(await c.tick(), 5_000); + assert.deepEqual(c.pending(), [10_000]); + assert.equal(await c.tick(), 10_000); + assert.deepEqual([...tldSet], [], "still failing on the second try"); + assert.equal(await c.tick(), 30_000); + assert.deepEqual([...tldSet], ["eggs", "streaming"], "loaded on the third try"); + assert.ok(said.some((line) => /loaded on retry/.test(line))); + assert.deepEqual(c.pending(), [3_600_000]); + await c.tick(); + assert.deepEqual([...tldSet], ["ancestry", "eggs", "streaming"], "a refresh picks up a new ending"); + stop(); + assert.deepEqual(c.pending(), []); +}); + +test("a failed refresh never empties a good list", async () => { + const tldSet = new Set(["eggs"]); + const c = clock(); + keepEndingsFresh({ tldSet, load: async () => { throw new Error("registry down"); }, schedule: c.schedule, cancel: c.cancel }); + assert.deepEqual(c.pending(), [3_600_000]); + await c.tick(); + assert.deepEqual([...tldSet], ["eggs"]); + assert.deepEqual(c.pending(), [3_600_000]); +}); + +test("past the quick retries, an empty list is tried every five minutes", async () => { + const tldSet = new Set(); + const c = clock(); + keepEndingsFresh({ tldSet, load: async () => [], schedule: c.schedule, cancel: c.cancel }); + const seen = []; + for (let i = 0; i < 5; i++) seen.push(await c.tick()); + assert.deepEqual(seen, [5_000, 10_000, 30_000, 300_000, 300_000]); +});