fix(display): keep blackout across reloads and follow the display's current assignment

A kiosk that reloaded during a blackout restored its last frame from local
state without the blackout, so the hidden slide flashed on the wall until the
server answered.

A display resolved its channel once, at registration. Moving it to another
group or audience, or deleting the channel it was on, never reached a running
screen: it kept showing the old channel, or fell into offline continuation
against a 404 forever. The display now fetches its state by viewer, so the
server resolves the current assignment on every fetch, and the heartbeat reply
names the assigned channel so a reassignment lands within one heartbeat. A
display removed from the roster registers again once, on backoff; only
Signage's own not-found means removal, so a foreign 404 can never cause a
registration loop. The channel route stays for the preview and for display
code a kiosk cached earlier. A replaced realtime subscription can no longer
schedule a stray resubscription when it reports closing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-12 21:32:04 -05:00
co-authored by Claude Opus 5.5
parent 6bf9167f85
commit 67d89f1020
9 changed files with 441 additions and 32 deletions
+168 -4
View File
@@ -174,7 +174,7 @@ describe("display controller", () => {
},
{
method: "GET",
path: "channels/chan-1/display-state",
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
@@ -205,7 +205,7 @@ describe("display controller", () => {
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: "channels/chan-1/display-state",
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
@@ -376,6 +376,28 @@ describe("display controller", () => {
await controller.stop();
});
it("restores a blackout before the server answers, so a reload never flashes content", async () => {
const store = createMemoryStore();
const media = createMemoryMediaCache();
const online = createFakeHost({ permissions: [], routes: [] });
const before = build({ host: online, media, store, telemetry: false });
await before.start();
await before.apply(state({ playback: { blackout: true } as never }));
await settle();
await before.stop();
// A kiosk reloads mid-blackout and its first request has not been answered yet.
root.dataset.blackout = "false";
const pending = createFakeHost({ permissions: ["signage.display.view"], routes: [] });
Object.assign(pending.context.api, { request: () => new Promise<Response>(() => undefined) });
const after = build({ host: pending, media, store });
void after.start();
await settle();
expect(root.dataset.blackout).toBe("true");
await after.stop();
});
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
const beats: unknown[] = [];
const host = createFakeHost({
@@ -384,7 +406,7 @@ describe("display controller", () => {
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: "channels/chan-1/display-state",
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()),
},
{
@@ -428,7 +450,7 @@ describe("display controller", () => {
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: "channels/chan-1/display-state",
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
respond: () => jsonResponse(state()),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
@@ -471,4 +493,146 @@ describe("display controller", () => {
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop();
});
it("follows the server when an administrator moves it to another channel", async () => {
const moved = { groupId: "group-2", id: "chan-2", key: "online" };
let stateFetches = 0;
let realtimeHandlers: { onUpdate?: (state: unknown) => void } | undefined;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
respond: () => {
stateFetches += 1;
// Registered on chan-1; moved by an administrator right after its first fetch.
return jsonResponse(state(stateFetches === 1 ? {} : { channel: moved }));
},
},
{
method: "POST",
path: /^viewers\/.*\/heartbeat$/u,
respond: () => jsonResponse({ acknowledged: true, channelId: moved.id, recorded: true }),
},
],
});
Object.assign(host.context.realtime, {
subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => {
realtimeHandlers = handlers;
return { close: () => undefined };
},
});
const controller = build({ host });
await controller.start();
await settle();
// The heartbeat named a different channel, so the display refetched and adopted it.
expect(stateFetches).toBe(2);
expect(controller.diagnostics().channelId).toBe("chan-2");
// A change in the old group no longer concerns it; a change in the new one does.
realtimeHandlers?.onUpdate?.({
groups: [{ groupId: "group-1", revision: 99, selectionRevision: 1 }],
});
await settle();
expect(stateFetches).toBe(2);
realtimeHandlers?.onUpdate?.({
groups: [{ groupId: "group-2", revision: 99, selectionRevision: 1 }],
});
await settle();
expect(stateFetches).toBe(3);
await controller.stop();
});
it("registers again, exactly once, when it was removed from the roster", async () => {
let sessions = 0;
let subscriptions = 0;
let closes = 0;
let removed = false;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
sessions += 1;
removed = false;
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
respond: () =>
removed ? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404) : jsonResponse(state()),
},
{
method: "POST",
path: /^viewers\/.*\/heartbeat$/u,
respond: () =>
removed
? jsonResponse({ code: "SIGNAGE_NOT_FOUND" }, 404)
: jsonResponse({ acknowledged: true }),
},
],
});
let onUpdate: ((state: unknown) => void) | undefined;
Object.assign(host.context.realtime, {
subscribe: (_channel: string, handlers: { onUpdate?: (state: unknown) => void }) => {
subscriptions += 1;
onUpdate = handlers.onUpdate;
return { close: () => (closes += 1) };
},
});
const controller = build({ host });
await controller.start();
await settle();
expect(sessions).toBe(1);
// Removed from the roster; a realtime nudge and a heartbeat both discover it at once.
removed = true;
onUpdate?.({ groups: [{ groupId: "group-1", revision: 50, selectionRevision: 1 }] });
onUpdate?.({ groups: [{ groupId: "group-1", revision: 51, selectionRevision: 1 }] });
await settle();
expect(sessions).toBe(2);
// One live subscription: the old one closed, exactly one new one opened.
expect(subscriptions - closes).toBe(1);
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
await controller.stop();
});
it("treats a 404 that is not Signage's own as an ordinary failure, never a re-registration loop", async () => {
let sessions = 0;
const host = createFakeHost({
permissions: ["signage.display.view"],
routes: [
{
method: "POST",
path: "viewers/session",
respond: () => {
sessions += 1;
return jsonResponse(SESSION);
},
},
{
method: "GET",
path: /^viewers\/[^/]+\/display-state$/u,
// A proxy, or a server without this route — not "this viewer is gone".
respond: () => jsonResponse({ code: "NOT_FOUND" }, 404),
},
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
],
});
const controller = build({ host });
await controller.start();
await settle();
await settle();
expect(sessions).toBe(1);
await controller.stop();
});
});