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
@@ -200,7 +200,8 @@ describe("display ↔ server contract", () => {
|
||||
|
||||
const sent = calls.map((call) => `${call.method} ${call.path}`);
|
||||
expect(sent).toContain("POST /viewers/session");
|
||||
expect(sent).toContain("GET /channels/:channelId/display-state");
|
||||
// By viewer, so the server — not the display's memory of its registration — decides the channel.
|
||||
expect(sent).toContain("GET /viewers/:viewerId/display-state");
|
||||
expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
|
||||
// Not one refusal, of any kind, for anything the display built itself.
|
||||
expect(calls.filter((call) => call.status >= 400)).toEqual([]);
|
||||
@@ -220,4 +221,48 @@ describe("display ↔ server contract", () => {
|
||||
expect(viewer?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
|
||||
expect(viewer?.reportedContributionId).toBeNull();
|
||||
});
|
||||
|
||||
it("a display moved to another channel group is served that group's state on its next fetch", async () => {
|
||||
const display = controller();
|
||||
await display.start();
|
||||
await settle();
|
||||
const viewerId = display.diagnostics().viewerId;
|
||||
const firstChannel = display.diagnostics().channelId;
|
||||
expect(viewerId).toBeDefined();
|
||||
|
||||
// An administrator creates a second group and moves this screen to it while it is running.
|
||||
const groupId = newId();
|
||||
const channelId = newId();
|
||||
await store.createChannelGroup({ channelId, channelName: "Default", groupId, name: "Nursery" });
|
||||
await store.updateViewer(viewerId as string, { channelId });
|
||||
|
||||
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
|
||||
const state = (await response.json()) as { channel: { id: string; groupId: string } };
|
||||
expect(state.channel).toMatchObject({ groupId, id: channelId });
|
||||
expect(channelId).not.toBe(firstChannel);
|
||||
|
||||
// And the heartbeat names the new assignment, which is how a display with a long slot hears.
|
||||
const beat = await host.context.api.request(`viewers/${viewerId}/heartbeat`, {
|
||||
body: JSON.stringify({}),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(((await beat.json()) as { channelId?: string }).channelId).toBe(channelId);
|
||||
await display.stop();
|
||||
});
|
||||
|
||||
it("a display whose channel was deleted falls back to the default channel, not to nothing", async () => {
|
||||
const display = controller();
|
||||
await display.start();
|
||||
await settle();
|
||||
const viewerId = display.diagnostics().viewerId as string;
|
||||
await store.updateViewer(viewerId, { channelId: null });
|
||||
|
||||
const response = await host.context.api.request(`viewers/${viewerId}/display-state`);
|
||||
expect(response.status).toBe(200);
|
||||
const [group] = await store.listChannelGroups();
|
||||
const fallback = group?.channels.find((channel) => channel.isDefault);
|
||||
expect(((await response.json()) as { channel: { id: string } }).channel.id).toBe(fallback?.id);
|
||||
await display.stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -145,7 +145,7 @@ describe("a display recovers from every failed request without a reload", () =>
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "channels/chan-1/display-state",
|
||||
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||
respond: () => {
|
||||
server.stateRequests += 1;
|
||||
if (server.stateFailing) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -391,7 +391,7 @@ describe("a display that loses its network", () => {
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "channels/chan-1/display-state",
|
||||
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||
respond: () => {
|
||||
if (!reachable) {
|
||||
throw new Error("network is gone");
|
||||
|
||||
@@ -177,6 +177,7 @@ describe("Signage server runtime", () => {
|
||||
"GET /timelines",
|
||||
"GET /timelines/:timelineId",
|
||||
"GET /viewers",
|
||||
"GET /viewers/:viewerId/display-state",
|
||||
"PATCH /images/:imageId",
|
||||
"PATCH /timelines/:timelineId",
|
||||
"PATCH /timelines/:timelineId/slots/:slotId",
|
||||
|
||||
Reference in New Issue
Block a user