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]); +});