Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
59 changes: 59 additions & 0 deletions src/dns.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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}`
Expand Down
58 changes: 58 additions & 0 deletions test/dns-endings-retry.test.mjs
Original file line number Diff line number Diff line change
@@ -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]);
});
Loading