fix: Stage 0 Signage production readiness #18

Open
ekklesia-bot wants to merge 8 commits from stage0/signage-production-readiness into main
9 changed files with 441 additions and 32 deletions
Showing only changes of commit 67d89f1020 - Show all commits
+153 -21
View File
@@ -20,6 +20,7 @@ import {
signageMediaKey,
} from "../browser/storage.js";
import {
assignedChannelOf,
type DisplayChannelState,
type DisplayHeartbeat,
type DisplayRealtimeGroup,
@@ -110,6 +111,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
let stage: SceneStage | undefined;
let session: DisplaySessionView | undefined;
let subscription: { close(): void } | undefined;
/** Identifies the one subscription whose callbacks may still act. */
let activeSubscription: symbol | undefined;
let heartbeatHandle: unknown;
let refreshHandle: unknown;
let stopped = false;
@@ -127,6 +130,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
* each resets once its request works again. */
let sessionHandle: unknown;
let sessionAttempt = 0;
/** One registration in flight at a time. A refresh and a heartbeat can both discover that this
* viewer was removed from the roster; registering twice would open two realtime subscriptions and
* two heartbeat timers for one screen. */
let registering = false;
/** Consecutive times this screen found itself removed from the roster without a working fetch in
* between. Resets once a state fetch succeeds. */
let rosterAttempt = 0;
let refreshAttempt = 0;
let realtimeHandle: unknown;
let realtimeAttempt = 0;
@@ -347,6 +357,9 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
// The crossfade is part of the persisted state too, so a screen that resumes offline fades the
// way its operator configured rather than the way this file happened to default.
stage.setCrossfade(snapshot.crossfadeMs);
// A deliberate blackout is persisted state like any other. Restoring it first means a kiosk that
// reloads mid-blackout stays black while it reconnects, instead of flashing the hidden frame.
stage.setBlackout(snapshot.playback.blackout);
const atMs = serverNowFrom(snapshot, now());
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
const resolution = evaluation.resolution;
@@ -500,22 +513,96 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
if (stopped || !session) {
return;
}
const viewerId = session.viewerId;
try {
const response = await options.context.api.request(
`channels/${session.channelId}/display-state`,
);
// Asked by viewer, not by channel: the server resolves where this screen is assigned *now*,
// so a reassignment or a deleted channel reaches a display that is already running.
const response = await options.context.api.request(`viewers/${viewerId}/display-state`);
if (await removedFromRoster(response)) {
// This screen was removed from the roster. It registers again with the identity it keeps,
// and keeps its frame meanwhile.
reregister(viewerId);
return;
}
if (!response.ok) {
await refreshFailed();
return;
}
const state = (await response.json()) as DisplayChannelStateView;
refreshAttempt = 0;
rosterAttempt = 0;
adoptAssignment(state.channel);
await apply(state);
} catch {
await refreshFailed();
}
}
/** The server's answer about where this screen belongs wins, exactly as its playback state does. */
function adoptAssignment(channel: DisplayChannelStateView["channel"]): void {
if (!session || (session.channelId === channel.id && session.groupId === channel.groupId)) {
return;
}
session = {
...session,
channelId: channel.id,
channelKey: channel.key,
groupId: channel.groupId,
};
}
/**
* Only Signage's own "no such viewer" means the roster entry is gone. Any other 404 — a proxy, a
* route this server does not have — is an ordinary failure, retried with backoff like the rest;
* treating it as removal would re-register in a loop.
*/
async function removedFromRoster(response: Response): Promise<boolean> {
if (response.status !== 404) {
return false;
}
const body = (await response
.clone()
.json()
.catch(() => null)) as { code?: unknown } | null;
return body?.code === "SIGNAGE_NOT_FOUND";
}
/** Drops a session the server no longer recognizes and registers again, once, on backoff. */
function reregister(viewerId: string): void {
if (stopped || session?.viewerId !== viewerId) {
return;
}
endSession();
// Backed off as well: a server that keeps forgetting this screen the moment it registers must
// cost a request a minute, not a tight loop.
const delay = rosterAttempt === 0 ? 0 : displayRetryDelayMs(rosterAttempt);
rosterAttempt += 1;
if (delay === 0) {
void register();
return;
}
sessionHandle = setTimer(() => {
sessionHandle = undefined;
void register();
}, delay);
}
/** Releases everything a registration started: realtime, heartbeats, and pending retries. */
function endSession(): void {
session = undefined;
activeSubscription = undefined;
subscription?.close();
subscription = undefined;
if (heartbeatHandle !== undefined) {
clearInterval(heartbeatHandle as never);
heartbeatHandle = undefined;
}
if (realtimeHandle !== undefined) {
clearTimer(realtimeHandle);
realtimeHandle = undefined;
}
}
/**
* The server is unreachable, or answered with an error. The frame stays and local continuation
* takes over — nothing about a network failure belongs on a lobby wall — and the refresh is tried
@@ -545,26 +632,17 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
* state — exactly as if it had worked the first time.
*/
async function register(): Promise<void> {
if (stopped || session) {
if (stopped || session || registering) {
return;
}
registering = true;
let registered: DisplaySessionView | undefined;
try {
const response = await options.context.api.request("viewers/session", {
body: JSON.stringify({ viewerKey: await viewerKey() }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration, and is
// tried again like any other failure.
const parsed = parseViewerSessionResponse(await response.json());
registered = parsed.ok ? parsed.value : undefined;
registered = await requestSession();
} finally {
registering = false;
}
} catch {
registered = undefined;
}
if (stopped) {
if (stopped || session) {
return;
}
if (!registered) {
@@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
report();
}
async function requestSession(): Promise<DisplaySessionView | undefined> {
let registered: DisplaySessionView | undefined;
try {
const response = await options.context.api.request("viewers/session", {
body: JSON.stringify({ viewerKey: await viewerKey() }),
headers: { "content-type": "application/json" },
method: "POST",
});
if (response.ok) {
// Read, not cast: a registration the display cannot understand is no registration, and is
// tried again like any other failure.
const parsed = parseViewerSessionResponse(await response.json());
registered = parsed.ok ? parsed.value : undefined;
}
} catch {
registered = undefined;
}
return registered;
}
/**
* Subscribes to the display's realtime channel, and again whenever the host reports that a
* subscription has ended for good. The host retries ordinary disconnects itself; a stream that
@@ -600,10 +698,21 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
if (stopped || !session) {
return;
}
// Only the newest subscription may act. Closing the old one can report "closed" synchronously,
// and that report must not schedule a second resubscription on top of this one.
const token = Symbol("subscription");
activeSubscription = token;
subscription?.close();
subscription = options.context.realtime.subscribe("display-state", {
onSnapshot: (state) => onRealtime(state),
onSnapshot: (state) => {
if (activeSubscription === token) {
onRealtime(state);
}
},
onStatus: (status) => {
if (activeSubscription !== token) {
return;
}
online = status === "connected";
if (status === "connected") {
realtimeAttempt = 0;
@@ -616,7 +725,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
}
report();
},
onUpdate: (state) => onRealtime(state),
onUpdate: (state) => {
if (activeSubscription === token) {
onRealtime(state);
}
},
});
}
@@ -639,11 +752,29 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
reportedSelectionId: renderedSelectionId ?? null,
reportedTimelineId: lastState?.playback.activeTimelineId ?? null,
};
await options.context.api.request(`viewers/${session.viewerId}/heartbeat`, {
const viewerId = session.viewerId;
const response = await options.context.api.request(`viewers/${viewerId}/heartbeat`, {
body: JSON.stringify(report),
headers: { "content-type": "application/json" },
method: "POST",
});
if (await removedFromRoster(response)) {
reregister(viewerId);
return;
}
if (!response.ok) {
return;
}
// The heartbeat is also how a screen learns it was reassigned while its slot is long, paused,
// or held — times when no state fetch is otherwise due.
const assigned = assignedChannelOf(await response.json().catch(() => undefined));
if (
assigned !== undefined &&
session?.viewerId === viewerId &&
assigned !== session.channelId
) {
void refresh();
}
} catch {
// Telemetry is best effort and never affects what is on screen.
}
@@ -695,6 +826,7 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
async stop(): Promise<void> {
stopped = true;
activeSubscription = undefined;
subscription?.close();
subscription = undefined;
if (heartbeatHandle !== undefined) {
+40 -1
View File
@@ -18,6 +18,7 @@ import {
describeViewerHealth,
openViewerSession,
recordViewerHeartbeat,
resolveViewerSession,
SIGNAGE_VIEWER_NAME_MAX,
updateViewerAssignment,
} from "../viewers/service.js";
@@ -123,12 +124,50 @@ export function registerViewerRoutes(
});
// The response says what a display needs to keep itself honest, and nothing about other
// displays or the installation.
const response: DisplayHeartbeatResponse = { acknowledged: true, recorded: result.written };
const assigned = await resolveViewerSession(store(context), result.viewer);
const response: DisplayHeartbeatResponse = {
acknowledged: true,
channelId: assigned.channel.id,
recorded: result.written,
};
return Response.json(response);
});
},
});
http.route({
method: "GET",
path: "/viewers/:viewerId/display-state",
permission: "signage.display.view",
maxBodyBytes: 0,
handler(_request, context) {
return signageBoundary(async () => {
const viewerId = requireUuidParam(context, "viewerId");
const signageStore = store(context);
const viewer = await signageStore.findViewer(viewerId);
if (!viewer) {
// Removed from the roster. The display registers again with the identity it keeps.
throw new SignageError("SIGNAGE_NOT_FOUND");
}
// Resolved on every fetch, never remembered from registration: an administrator who moves
// a screen, or deletes the channel it was on, changes what it shows without a reboot.
const { channel } = await resolveViewerSession(signageStore, viewer);
const snapshot = await playback().snapshot(channel.groupId);
return Response.json(
await buildDisplayChannelState({
atMs: Date.now(),
channel,
playback: snapshot,
registry: registry(),
settings: readSignageSettings(context.config),
storage: context.storage,
store: signageStore,
}),
);
});
},
});
http.route({
method: "GET",
path: "/channels/:channelId/display-state",
+11 -2
View File
@@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise<View
if (!refreshed) {
throw new SignageError("SIGNAGE_OPERATION_FAILED");
}
return resolveSession(input.store, refreshed);
return resolveViewerSession(input.store, refreshed);
}
const channel = defaultChannelOf(group.channels);
@@ -121,7 +121,16 @@ function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
return fallback;
}
async function resolveSession(store: SignageStore, viewer: StoredViewer): Promise<ViewerSession> {
/**
* Where a known viewer is assigned **now**: its own channel, or the default channel of the default
* group when that channel no longer exists. A display asks this on every state fetch, which is what
* makes an administrator's reassignment — or deleting the channel a screen was on — reach a display
* that is already running, rather than only the next time it boots.
*/
export async function resolveViewerSession(
store: SignageStore,
viewer: StoredViewer,
): Promise<ViewerSession> {
const groups = await store.listChannelGroups();
for (const group of groups) {
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
+20 -1
View File
@@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeart
export interface DisplayHeartbeatResponse {
readonly acknowledged: true;
readonly recorded: boolean;
/** The channel an administrator has this viewer assigned to now. A display whose own idea of its
* channel differs refetches its state, so a reassignment reaches a running screen within one
* heartbeat. Optional: a display ignores a response without it. */
readonly channelId?: string;
}
// --- Channel state: GET channels/:channelId/display-state -----------------------------------------
/** The assigned channel from a heartbeat response, if it carried a usable one. */
export function assignedChannelOf(raw: unknown): string | undefined {
if (typeof raw !== "object" || raw === null) {
return undefined;
}
const channelId = (raw as { readonly channelId?: unknown }).channelId;
return typeof channelId === "string" && channelId.length > 0 && channelId.length <= 64
? channelId
: undefined;
}
// --- Channel state: GET viewers/:viewerId/display-state (and channels/:channelId/display-state) -----
//
// A registered display asks by its own viewer id, so the server resolves which channel it is assigned
// to at that moment. The channel route remains for the management preview and for display code a
// kiosk cached before the viewer route existed.
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
export interface DisplayMedia {
+46 -1
View File
@@ -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();
});
});
+1 -1
View File
@@ -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) {
+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();
});
});
+1 -1
View File
@@ -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");
+1
View File
@@ -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",