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
+153
-21
@@ -20,6 +20,7 @@ import {
|
|||||||
signageMediaKey,
|
signageMediaKey,
|
||||||
} from "../browser/storage.js";
|
} from "../browser/storage.js";
|
||||||
import {
|
import {
|
||||||
|
assignedChannelOf,
|
||||||
type DisplayChannelState,
|
type DisplayChannelState,
|
||||||
type DisplayHeartbeat,
|
type DisplayHeartbeat,
|
||||||
type DisplayRealtimeGroup,
|
type DisplayRealtimeGroup,
|
||||||
@@ -110,6 +111,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
let stage: SceneStage | undefined;
|
let stage: SceneStage | undefined;
|
||||||
let session: DisplaySessionView | undefined;
|
let session: DisplaySessionView | undefined;
|
||||||
let subscription: { close(): void } | undefined;
|
let subscription: { close(): void } | undefined;
|
||||||
|
/** Identifies the one subscription whose callbacks may still act. */
|
||||||
|
let activeSubscription: symbol | undefined;
|
||||||
let heartbeatHandle: unknown;
|
let heartbeatHandle: unknown;
|
||||||
let refreshHandle: unknown;
|
let refreshHandle: unknown;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -127,6 +130,13 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
* each resets once its request works again. */
|
* each resets once its request works again. */
|
||||||
let sessionHandle: unknown;
|
let sessionHandle: unknown;
|
||||||
let sessionAttempt = 0;
|
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 refreshAttempt = 0;
|
||||||
let realtimeHandle: unknown;
|
let realtimeHandle: unknown;
|
||||||
let realtimeAttempt = 0;
|
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
|
// 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.
|
// way its operator configured rather than the way this file happened to default.
|
||||||
stage.setCrossfade(snapshot.crossfadeMs);
|
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 atMs = serverNowFrom(snapshot, now());
|
||||||
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
const evaluation = resolveOffline({ atMs, snapshot, unusableSelectionIds: unusable });
|
||||||
const resolution = evaluation.resolution;
|
const resolution = evaluation.resolution;
|
||||||
@@ -500,22 +513,96 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
if (stopped || !session) {
|
if (stopped || !session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const viewerId = session.viewerId;
|
||||||
try {
|
try {
|
||||||
const response = await options.context.api.request(
|
// Asked by viewer, not by channel: the server resolves where this screen is assigned *now*,
|
||||||
`channels/${session.channelId}/display-state`,
|
// 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) {
|
if (!response.ok) {
|
||||||
await refreshFailed();
|
await refreshFailed();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const state = (await response.json()) as DisplayChannelStateView;
|
const state = (await response.json()) as DisplayChannelStateView;
|
||||||
refreshAttempt = 0;
|
refreshAttempt = 0;
|
||||||
|
rosterAttempt = 0;
|
||||||
|
adoptAssignment(state.channel);
|
||||||
await apply(state);
|
await apply(state);
|
||||||
} catch {
|
} catch {
|
||||||
await refreshFailed();
|
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
|
* 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
|
* 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.
|
* state — exactly as if it had worked the first time.
|
||||||
*/
|
*/
|
||||||
async function register(): Promise<void> {
|
async function register(): Promise<void> {
|
||||||
if (stopped || session) {
|
if (stopped || session || registering) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
registering = true;
|
||||||
let registered: DisplaySessionView | undefined;
|
let registered: DisplaySessionView | undefined;
|
||||||
try {
|
try {
|
||||||
const response = await options.context.api.request("viewers/session", {
|
registered = await requestSession();
|
||||||
body: JSON.stringify({ viewerKey: await viewerKey() }),
|
} finally {
|
||||||
headers: { "content-type": "application/json" },
|
registering = false;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (stopped) {
|
if (stopped || session) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!registered) {
|
if (!registered) {
|
||||||
@@ -590,6 +668,26 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
report();
|
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
|
* 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
|
* 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) {
|
if (stopped || !session) {
|
||||||
return;
|
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?.close();
|
||||||
subscription = options.context.realtime.subscribe("display-state", {
|
subscription = options.context.realtime.subscribe("display-state", {
|
||||||
onSnapshot: (state) => onRealtime(state),
|
onSnapshot: (state) => {
|
||||||
|
if (activeSubscription === token) {
|
||||||
|
onRealtime(state);
|
||||||
|
}
|
||||||
|
},
|
||||||
onStatus: (status) => {
|
onStatus: (status) => {
|
||||||
|
if (activeSubscription !== token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
online = status === "connected";
|
online = status === "connected";
|
||||||
if (status === "connected") {
|
if (status === "connected") {
|
||||||
realtimeAttempt = 0;
|
realtimeAttempt = 0;
|
||||||
@@ -616,7 +725,11 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
|
|||||||
}
|
}
|
||||||
report();
|
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,
|
reportedSelectionId: renderedSelectionId ?? null,
|
||||||
reportedTimelineId: lastState?.playback.activeTimelineId ?? 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),
|
body: JSON.stringify(report),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "POST",
|
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 {
|
} catch {
|
||||||
// Telemetry is best effort and never affects what is on screen.
|
// 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> {
|
async stop(): Promise<void> {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
|
activeSubscription = undefined;
|
||||||
subscription?.close();
|
subscription?.close();
|
||||||
subscription = undefined;
|
subscription = undefined;
|
||||||
if (heartbeatHandle !== undefined) {
|
if (heartbeatHandle !== undefined) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
describeViewerHealth,
|
describeViewerHealth,
|
||||||
openViewerSession,
|
openViewerSession,
|
||||||
recordViewerHeartbeat,
|
recordViewerHeartbeat,
|
||||||
|
resolveViewerSession,
|
||||||
SIGNAGE_VIEWER_NAME_MAX,
|
SIGNAGE_VIEWER_NAME_MAX,
|
||||||
updateViewerAssignment,
|
updateViewerAssignment,
|
||||||
} from "../viewers/service.js";
|
} 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
|
// The response says what a display needs to keep itself honest, and nothing about other
|
||||||
// displays or the installation.
|
// 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);
|
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({
|
http.route({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "/channels/:channelId/display-state",
|
path: "/channels/:channelId/display-state",
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export async function openViewerSession(input: ViewerSessionInput): Promise<View
|
|||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
throw new SignageError("SIGNAGE_OPERATION_FAILED");
|
||||||
}
|
}
|
||||||
return resolveSession(input.store, refreshed);
|
return resolveViewerSession(input.store, refreshed);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = defaultChannelOf(group.channels);
|
const channel = defaultChannelOf(group.channels);
|
||||||
@@ -121,7 +121,16 @@ function defaultChannelOf(channels: readonly StoredChannel[]): StoredChannel {
|
|||||||
return fallback;
|
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();
|
const groups = await store.listChannelGroups();
|
||||||
for (const group of groups) {
|
for (const group of groups) {
|
||||||
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
const channel = group.channels.find((candidate) => candidate.id === viewer.channelId);
|
||||||
|
|||||||
@@ -206,9 +206,28 @@ export function parseDisplayHeartbeat(raw: unknown): ProtocolResult<DisplayHeart
|
|||||||
export interface DisplayHeartbeatResponse {
|
export interface DisplayHeartbeatResponse {
|
||||||
readonly acknowledged: true;
|
readonly acknowledged: true;
|
||||||
readonly recorded: boolean;
|
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. */
|
/** A fresh, signed delivery URL. A bearer capability with an expiry: never persisted by a display. */
|
||||||
export interface DisplayMedia {
|
export interface DisplayMedia {
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ describe("display ↔ server contract", () => {
|
|||||||
|
|
||||||
const sent = calls.map((call) => `${call.method} ${call.path}`);
|
const sent = calls.map((call) => `${call.method} ${call.path}`);
|
||||||
expect(sent).toContain("POST /viewers/session");
|
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");
|
expect(sent).toContain("POST /viewers/:viewerId/heartbeat");
|
||||||
// Not one refusal, of any kind, for anything the display built itself.
|
// Not one refusal, of any kind, for anything the display built itself.
|
||||||
expect(calls.filter((call) => call.status >= 400)).toEqual([]);
|
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?.reportedProviderId).toBe(SIGNAGE_NATIVE_PROVIDER_ID);
|
||||||
expect(viewer?.reportedContributionId).toBeNull();
|
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",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => {
|
respond: () => {
|
||||||
server.stateRequests += 1;
|
server.stateRequests += 1;
|
||||||
if (server.stateFailing) {
|
if (server.stateFailing) {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ describe("display controller", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
@@ -205,7 +205,7 @@ describe("display controller", () => {
|
|||||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
@@ -376,6 +376,28 @@ describe("display controller", () => {
|
|||||||
await controller.stop();
|
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 () => {
|
it("reports a bounded, sanitized heartbeat and nothing else", async () => {
|
||||||
const beats: unknown[] = [];
|
const beats: unknown[] = [];
|
||||||
const host = createFakeHost({
|
const host = createFakeHost({
|
||||||
@@ -384,7 +406,7 @@ describe("display controller", () => {
|
|||||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -428,7 +450,7 @@ describe("display controller", () => {
|
|||||||
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
{ method: "POST", path: "viewers/session", respond: () => jsonResponse(SESSION) },
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => jsonResponse(state()),
|
respond: () => jsonResponse(state()),
|
||||||
},
|
},
|
||||||
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
{ method: "POST", path: /^viewers\/.*\/heartbeat$/u, respond: () => jsonResponse({}) },
|
||||||
@@ -471,4 +493,146 @@ describe("display controller", () => {
|
|||||||
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
expect(controller.diagnostics().renderedSelectionId).toBe("sel-1");
|
||||||
await controller.stop();
|
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",
|
method: "GET",
|
||||||
path: "channels/chan-1/display-state",
|
path: /^(viewers\/[^/]+|channels\/chan-1)\/display-state$/u,
|
||||||
respond: () => {
|
respond: () => {
|
||||||
if (!reachable) {
|
if (!reachable) {
|
||||||
throw new Error("network is gone");
|
throw new Error("network is gone");
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ describe("Signage server runtime", () => {
|
|||||||
"GET /timelines",
|
"GET /timelines",
|
||||||
"GET /timelines/:timelineId",
|
"GET /timelines/:timelineId",
|
||||||
"GET /viewers",
|
"GET /viewers",
|
||||||
|
"GET /viewers/:viewerId/display-state",
|
||||||
"PATCH /images/:imageId",
|
"PATCH /images/:imageId",
|
||||||
"PATCH /timelines/:timelineId",
|
"PATCH /timelines/:timelineId",
|
||||||
"PATCH /timelines/:timelineId/slots/:slotId",
|
"PATCH /timelines/:timelineId/slots/:slotId",
|
||||||
|
|||||||
Reference in New Issue
Block a user