fix(display): Stage 0 display continuity, revocation, and kiosk recovery #92
@@ -42,6 +42,11 @@ export interface DisplayAppProps {
|
|||||||
/** The shortest interval between two revalidations prompted by refused module requests. Test seam
|
/** The shortest interval between two revalidations prompted by refused module requests. Test seam
|
||||||
* only. */
|
* only. */
|
||||||
readonly revalidationIntervalMs?: number;
|
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
|
/** 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. */
|
* display that is merely missing one permission must not turn every refused request into two more. */
|
||||||
export const DISPLAY_REVALIDATION_INTERVAL_MS = 15_000;
|
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:
|
/** 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
|
* 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. */
|
* 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,
|
importModule,
|
||||||
moduleId,
|
moduleId,
|
||||||
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
|
offlineIndicatorDelayMs = DISPLAY_OFFLINE_INDICATOR_DELAY_MS,
|
||||||
|
periodicRevalidationMs = DISPLAY_PERIODIC_REVALIDATION_MS,
|
||||||
|
retryBaseMs = DISPLAY_RETRY_BASE_MS,
|
||||||
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
revalidationIntervalMs = DISPLAY_REVALIDATION_INTERVAL_MS,
|
||||||
} = props;
|
} = props;
|
||||||
|
/** Consecutive automatic retries since the display last started successfully. */
|
||||||
|
const failures = useRef(0);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
const revalidation = useRef<{ inFlight: AbortController | undefined; lastAt: number }>({
|
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.
|
* stays. A display that is not showing anything simply starts over.
|
||||||
*/
|
*/
|
||||||
const revalidate = useCallback(
|
const revalidate = useCallback(
|
||||||
(reason: "online" | "authorization"): void => {
|
(reason: "online" | "authorization" | "periodic"): void => {
|
||||||
const current = stateRef.current;
|
const current = stateRef.current;
|
||||||
if (current.status !== "ready") {
|
if (current.status !== "ready") {
|
||||||
if (reason === "online") {
|
if (reason === "online") {
|
||||||
@@ -264,6 +283,31 @@ export function DisplayApp(props: DisplayAppProps): ReactElement {
|
|||||||
|
|
||||||
const onAuthorizationResponse = useCallback(() => revalidate("authorization"), [revalidate]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!connectivitySource) {
|
if (!connectivitySource) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1161,4 +1161,69 @@ describe("display host — continuity across reconnects and revocation", () => {
|
|||||||
});
|
});
|
||||||
expect(query("display-offline")?.textContent).toBe("Offline — reconnecting");
|
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(
|
||||||
|
<DisplayApp
|
||||||
|
config={{ apiBaseUrl, fetch }}
|
||||||
|
importModule={display.importModule}
|
||||||
|
moduleId="announcements"
|
||||||
|
offlineIndicatorDelayMs={0}
|
||||||
|
retryBaseMs={20}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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(
|
||||||
|
<DisplayApp
|
||||||
|
config={{ apiBaseUrl, fetch: server.fetch }}
|
||||||
|
importModule={display.importModule}
|
||||||
|
moduleId="announcements"
|
||||||
|
offlineIndicatorDelayMs={0}
|
||||||
|
periodicRevalidationMs={40}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user