From 9331a8f7dfbf8cbb6528a8bff5ba842297b1ee5a Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 12 Oct 2026 21:45:37 -0500 Subject: [PATCH] fix(display): retry a display that could not start, and re-resolve a running one periodically A kiosk that booted before its server, as after a power cut, stopped on 'This display could not be reached' until someone pressed Retry: with the network up, no online event ever came. It now retries on its own, from two seconds doubling to a minute. A running display also re-resolves its descriptor every five minutes in the background, so a module upgraded on the server is picked up without a reload, and the same answer disturbs nothing. Co-Authored-By: Claude Opus 5.5 --- surfaces/shell/src/display/DisplayApp.tsx | 46 +++++++++++++++- surfaces/shell/tests/display-app.test.tsx | 65 +++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/surfaces/shell/src/display/DisplayApp.tsx b/surfaces/shell/src/display/DisplayApp.tsx index dc385d6..bcf8f00 100644 --- a/surfaces/shell/src/display/DisplayApp.tsx +++ b/surfaces/shell/src/display/DisplayApp.tsx @@ -42,6 +42,11 @@ export interface DisplayAppProps { /** The shortest interval between two revalidations prompted by refused module requests. Test seam * only. */ readonly revalidationIntervalMs?: number; + /** How often a running display re-resolves its descriptor on its own. Test seam only. */ + readonly periodicRevalidationMs?: number; + /** The first automatic retry delay after a failed start; doubles to a one-minute ceiling. Test seam + * only. */ + readonly retryBaseMs?: number; } /** A Wi-Fi reassociation or a switch reboot lasts seconds and needs no comment on a lobby wall. Only @@ -52,6 +57,16 @@ export const DISPLAY_OFFLINE_INDICATOR_DELAY_MS = 10_000; * display that is merely missing one permission must not turn every refused request into two more. */ export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000; +/** A running display asks again this often, so a module upgraded on the server is picked up and a + * revocation is noticed even by a module that makes no requests of its own. */ +export const DISPLAY_PERIODIC_REVALIDATION_MS = 5 * 60_000; + +/** A display that could not start tries again on its own: from this delay, doubling to a minute. A + * kiosk that boots before its server — after a power cut, say — must come up without anyone pressing + * Retry. */ +export const DISPLAY_RETRY_BASE_MS = 2_000; +export const DISPLAY_RETRY_MAX_MS = 60_000; + /** Whether a fresh resolution describes exactly the display already mounted. Compared structurally: * the descriptor and principal are small, server-produced, and carry nothing volatile, so any * difference — a new version, a new contribution, a different principal — is a real one. */ @@ -192,8 +207,12 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { importModule, moduleId, offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS, + periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS, + retryBaseMs = DISPLAY_RETRY_BASE_MS, revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS, } = props; + /** Consecutive automatic retries since the display last started successfully. */ + const failures = useRef(0); const stateRef = useRef(state); stateRef.current = state; const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({ @@ -211,7 +230,7 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { * stays. A display that is not showing anything simply starts over. */ const revalidate = useCallback( - (reason: "online" | "authorization"): void => { + (reason: "online" | "authorization" | "periodic"): void => { const current = stateRef.current; if (current.status !== "ready") { if (reason === "online") { @@ -264,6 +283,31 @@ export function DisplayApp(props: DisplayAppProps): ReactElement { const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]); + // While running: ask again now and then, without disturbing anything when the answer is the same. + useEffect(() => { + if (state.status !== "ready" || periodicRevalidationMs <= 0) { + return; + } + const timer = setInterval(() => revalidate("periodic"), periodicRevalidationMs); + return () => clearInterval(timer); + }, [periodicRevalidationMs, revalidate, state.status]); + + // Could not start: try again on its own, backing off. Pairing is not a failure — it has its own + // polling — and a ready display has nothing to retry. + useEffect(() => { + if (state.status === "ready") { + failures.current = 0; + return; + } + if (state.status !== "failed" && state.status !== "unavailable") { + return; + } + const delay = Math.min(DISPLAY_RETRY_MAX_MS, retryBaseMs * 2 ** Math.min(failures.current, 10)); + failures.current += 1; + const timer = setTimeout(() => setAttempt((value) => value + 1), delay); + return () => clearTimeout(timer); + }, [retryBaseMs, state.status]); + useEffect(() => { if (!connectivitySource) { return; diff --git a/surfaces/shell/tests/display-app.test.tsx b/surfaces/shell/tests/display-app.test.tsx index 9899d4b..f5e956d 100644 --- a/surfaces/shell/tests/display-app.test.tsx +++ b/surfaces/shell/tests/display-app.test.tsx @@ -1161,4 +1161,69 @@ describe("display host — continuity across reconnects and revocation", () => { }); expect(query("display-offline")?.textContent).toBe("Offline — reconnecting"); }); + + it("starts on its own when the server comes up after the screen did", async () => { + // A kiosk that boots before its server: the network is up, so no `online` event will ever come. + let up = false; + const fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/v1/module-displays/announcements")) { + return up ? json(descriptorPayload()) : json({ code: "UNAVAILABLE" }, 503); + } + return json({ code: "NOT_FOUND" }, 404); + }) as unknown as typeof globalThis.fetch; + const display = countingArtifact(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + expect(query("display-host-error")).not.toBeNull(); + + up = true; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + await flush(); + expect(display.counts.mounts).toBe(1); + expect(query("display-host-error")).toBeNull(); + }); + + it("re-resolves a running display periodically, remounting only for a new version", async () => { + const server = changingServer(); + const display = countingArtifact(); + await act(async () => { + root.render( + , + ); + }); + await flush(); + const before = server.descriptorCalls(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + await flush(); + expect(server.descriptorCalls()).toBeGreaterThan(before); + expect(display.counts).toEqual({ mounts: 1, unmounts: 0 }); + + server.set(200, descriptorPayload("announcements", "2.0.0")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 150)); + }); + await flush(); + expect(display.counts.mounts).toBe(2); + }); });