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:
co-authored by
Claude Opus 5.5
parent
6bf9167f85
commit
67d89f1020
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user