From 410c1fe7ba291dac0e47f4c623d0b95874ed7f8a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 20 Sep 2026 07:56:01 -0700 Subject: [PATCH] Fix bounded boot recovery and fresh deployment polling --- lith/server.mjs | 2 +- system/netlify/functions/version.mjs | 6 +- system/public/aesthetic.computer/boot.mjs | 30 +++- system/public/aesthetic.computer/lib/disk.mjs | 20 ++- tests/update-recovery.test.mjs | 133 ++++++++++++++++++ 5 files changed, 177 insertions(+), 14 deletions(-) create mode 100644 tests/update-recovery.test.mjs diff --git a/lith/server.mjs b/lith/server.mjs index 5255944bf1..2fcba4184f 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -105,7 +105,7 @@ const BOOT_TIME = Date.now(); const responseCache = new Map(); // key → { body, headers, statusCode, expires } const CACHE_TTLS = { "handle-colors": 60_000, // 1 min (colors rarely change) - "version": 30_000, // 30s (git state) + "version": 0, // Live deployment state; never cache long-poll replies. "handles": 60_000, // 1 min "mood": 30_000, // 30s "tv": 30_000, // 30s diff --git a/system/netlify/functions/version.mjs b/system/netlify/functions/version.mjs index 8a7350b0ef..b91557323b 100644 --- a/system/netlify/functions/version.mjs +++ b/system/netlify/functions/version.mjs @@ -208,7 +208,7 @@ export default async (request) => { { headers: { "Content-Type": "application/json", - "Cache-Control": "no-cache", + "Cache-Control": "no-store", }, } ); @@ -242,7 +242,7 @@ export default async (request) => { { headers: { "Content-Type": "application/json", - "Cache-Control": "public, max-age=60", // Cache for 1 minute + "Cache-Control": "no-store", // Deployment state must survive reloads fresh. }, } ); @@ -257,7 +257,7 @@ export default async (request) => { status: 200, // Return 200 even on error so UI can handle gracefully headers: { "Content-Type": "application/json", - "Cache-Control": "public, max-age=30", + "Cache-Control": "no-store", }, } ); diff --git a/system/public/aesthetic.computer/boot.mjs b/system/public/aesthetic.computer/boot.mjs index fcb00502ca..ab505cfc69 100644 --- a/system/public/aesthetic.computer/boot.mjs +++ b/system/public/aesthetic.computer/boot.mjs @@ -315,6 +315,9 @@ let bootCompleted = false; function markBootSuccess() { if (bootCompleted) return; bootCompleted = true; + if (connectionRetryTimer) clearTimeout(connectionRetryTimer); + connectionRetryTimer = null; + try { sessionStorage.removeItem(CONNECTION_RETRY_KEY); } catch {} bootTelemetry.complete({ elapsedTotal: Math.round(performance.now() - bootStartTime), timings: window._bootTimings || [], @@ -355,14 +358,29 @@ function hideBootLog() { markBootSuccess(); } -// Show connection error and retry UI -let retryCount = 0; +// Bound connection recovery across page reloads. A page-local counter resets +// on every retry and can never reach its limit. +const CONNECTION_RETRY_KEY = "aesthetic:connection-retries"; const MAX_RETRIES = 5; const RETRY_DELAY = 2000; +let connectionRetryTimer = null; +let connectionErrorHandled = false; function showConnectionError(error) { - retryCount++; - const message = retryCount <= MAX_RETRIES + // Background requests must not tear down a piece that already booted. + if (bootCompleted || connectionErrorHandled) return; + connectionErrorHandled = true; + let retryCount = MAX_RETRIES + 1; + try { + const previous = Number(sessionStorage.getItem(CONNECTION_RETRY_KEY) || 0); + retryCount = Number.isInteger(previous) && previous >= 0 + ? previous + 1 : MAX_RETRIES + 1; + sessionStorage.setItem(CONNECTION_RETRY_KEY, String(retryCount)); + } catch { + // Without persistent accounting, leave retrying to the visitor. + retryCount = MAX_RETRIES + 1; + } + const message = retryCount <= MAX_RETRIES ? `⚠️ connection error - retrying (${retryCount}/${MAX_RETRIES})...` : `❌ connection failed - please refresh`; bootLog(message); @@ -371,9 +389,9 @@ function showConnectionError(error) { retryCount, maxRetries: MAX_RETRIES, }); - + if (retryCount <= MAX_RETRIES) { - setTimeout(() => { + connectionRetryTimer = setTimeout(() => { bootLog(`🔄 retry attempt ${retryCount}...`); window.location.reload(); }, RETRY_DELAY); diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index a5170866bb..7f20865304 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -10414,7 +10414,7 @@ async function startGlobalVersionPoll() { const fetchVersion = async () => { try { - const res = await fetch("/api/version"); + const res = await fetch("/api/version", { cache: "no-store" }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); globalVersionInfo = debug @@ -10440,21 +10440,33 @@ async function startGlobalVersionPoll() { return; } - // Long-poll loop: detect new deployments within ~5 seconds. + // Long-poll loop: only a different, valid deployed hash signals an update. while (true) { try { if (!globalVersionInfo?.deployed) { await new Promise((r) => setTimeout(r, 5000)); + await fetchVersion(); continue; } updatePollController = new AbortController(); const res = await fetch( `/api/version?current=${globalVersionInfo.deployed}`, - { signal: updatePollController.signal }, + { signal: updatePollController.signal, cache: "no-store" }, ); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); - if (data.changed === false) continue; // Server already waited ~4s + if (data.changed === false) continue; // Server held the request open. + if (!/^[a-f0-9]{7}$/i.test(data.deployed || "") || + data.deployed === globalVersionInfo.deployed) { + // Cached full responses and transient version lookup failures are not + // deployments. Back off if a proxy answered without long-polling. + await new Promise((r) => setTimeout(r, 5000)); + continue; + } + if (!/^[a-f0-9]{7}$/i.test(globalVersionInfo.deployed)) { + globalVersionInfo = data; + continue; // Establish a baseline after an unavailable initial lookup. + } globalUpdateReady = true; globalVersionInfo = data; globalRecentCommits = data.recentCommits || []; diff --git a/tests/update-recovery.test.mjs b/tests/update-recovery.test.mjs new file mode 100644 index 0000000000..6846bdd666 --- /dev/null +++ b/tests/update-recovery.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const bootSource = readFileSync(new URL("../system/public/aesthetic.computer/boot.mjs", import.meta.url), "utf8"); +const recovery = bootSource.slice(bootSource.indexOf("// Bound connection recovery"), bootSource.indexOf("// Global error handler for module load failures")); +const success = bootSource.slice(bootSource.indexOf("let bootCompleted = false;"), bootSource.indexOf('// Hide the boot log overlay.')); + +function boot(storage, blocked = false) { + const timers = new Map(), errors = []; + let reloads = 0; + const context = vm.createContext({ + window: { location: { reload() { reloads++; } } }, + sessionStorage: { + getItem: key => storage.get(key), + setItem(key, value) { if (blocked) throw Error("blocked"); storage.set(key, value); }, + removeItem: key => storage.delete(key), + }, + bootLog() {}, bootTelemetry: { error: e => errors.push(e), complete() {} }, + performance, bootStartTime: performance.now(), + setTimeout: fn => { const id = timers.size + 1; timers.set(id, fn); return id; }, + clearTimeout: id => timers.delete(id), + }); + vm.runInContext(success + recovery, context); + return { fail: () => vm.runInContext('showConnectionError(new Error("Failed to fetch"))', context), + succeed: () => vm.runInContext("markBootSuccess()", context), timers, errors, + reload: () => { for (const fn of timers.values()) fn(); return reloads; } }; +} + +test("network failures stop after five reloads across fresh page contexts", () => { + const storage = new Map(); + for (let i = 1; i <= 7; i++) { + const page = boot(storage); + page.fail(); page.fail(); + assert.equal(page.errors.length, 1); + assert.equal(page.reload(), i <= 5 ? 1 : 0); + } +}); + +test("successful boot cancels a pending reload and resets the retry budget", () => { + const storage = new Map(); + const page = boot(storage); + page.fail(); page.succeed(); page.fail(); + assert.equal(page.reload(), 0); + assert.equal(storage.size, 0); + const next = boot(storage); next.fail(); + assert.equal(next.errors[0].retryCount, 1); +}); + +test("blocked storage cannot cause unbounded reloads", () => { + const page = boot(new Map(), true); page.fail(); + assert.equal(page.reload(), 0); +}); + +const diskSource = readFileSync(new URL("../system/public/aesthetic.computer/lib/disk.mjs", import.meta.url), "utf8"); +const pollSource = diskSource.slice(diskSource.indexOf("async function startGlobalVersionPoll()"), diskSource.indexOf("// ***Bootstrap***")); +async function poll(responses) { + const calls = [], sent = []; + const context = vm.createContext({ + updatePollStarted: false, globalVersionInfo: null, globalUpdateReady: false, + globalRecentCommits: [], updatePollController: null, debug: false, + $commonApi: {}, console: { log() {}, warn() {} }, AbortController, + setTimeout: fn => { queueMicrotask(fn); }, + send: message => sent.push(message), + fetch: async (url, options) => { + calls.push({ url, options }); + if (!responses.length) throw Object.assign(new Error("done"), { name: "AbortError" }); + const data = responses.shift(); + if (data instanceof Error) throw data; + return { ok: true, json: async () => data }; + }, + }); + await vm.runInContext(pollSource + "\nstartGlobalVersionPoll()", context); + return { context, calls, sent }; +} + +test("same-version and unknown responses do not announce an update or clear caches", async () => { + const result = await poll([{ deployed: "aaaaaaa" }, { deployed: "aaaaaaa" }, { deployed: "unknown" }]); + assert.equal(result.context.globalUpdateReady, false); + assert.equal(result.sent.length, 0); + assert.ok(result.calls.every(c => c.options.cache === "no-store")); +}); + +test("a new deployed hash announces exactly one update", async () => { + const result = await poll([{ deployed: "aaaaaaa" }, { changed: false, deployed: "aaaaaaa" }, { deployed: "bbbbbbb" }]); + assert.equal(result.context.globalUpdateReady, true); + assert.equal(result.context.globalVersionInfo.deployed, "bbbbbbb"); + assert.equal(result.sent.length, 1); +}); + +test("a failed initial version fetch is retried", async () => { + const result = await poll([new Error("offline"), { deployed: "aaaaaaa" }, { deployed: "bbbbbbb" }]); + assert.equal(result.calls[1].url, "/api/version"); + assert.equal(result.context.globalUpdateReady, true); +}); + +test("recovering an unknown baseline does not announce a deployment", async () => { + const result = await poll([{ deployed: "unknown" }, { deployed: "aaaaaaa" }]); + assert.equal(result.context.globalUpdateReady, false); + assert.equal(result.context.globalVersionInfo.deployed, "aaaaaaa"); +}); + +const versionSource = readFileSync(new URL("../system/netlify/functions/version.mjs", import.meta.url), "utf8") + .replace(/^import .*;$/gm, "") + .replace("const execFileAsync = promisify(execFile);", "") + .replace("export default async (request) =>", "globalThis.handler = async (request) =>") + .replace('export const config =', 'const config ='); +const { default: path } = await import("node:path"); +async function versionResponse({ current = "", fail = false } = {}) { + const context = vm.createContext({ + fs: { readFileSync: () => "aaaaaaa123456789", existsSync: () => false }, path, + process: { env: { VERSION_LONG_POLL_MS: "0" }, cwd: () => "/test/system" }, + execFileAsync: async () => { + if (fail) throw Error("git unavailable"); + return { stdout: "aaaaaaa123456789\trefs/heads/main" }; + }, + URL, Response, setTimeout, + }); + vm.runInContext(versionSource, context); + return context.handler(new Request(`https://aesthetic.computer/api/version${current ? `?current=${current}` : ""}`)); +} + +test("version payloads, unchanged polls, and lookup failures all disable HTTP caching", async () => { + for (const options of [{}, { current: "aaaaaaa" }, { fail: true }]) { + const response = await versionResponse(options); + assert.equal(response.headers.get("Cache-Control"), "no-store"); + const data = await response.json(); + assert.equal(data.deployed, "aaaaaaa"); + if (options.current) assert.equal(data.changed, false); + if (options.fail) assert.equal(data.status, "unknown"); + } +}); -- 2.51.2