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,
|
||||
} 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;
|
||||
}
|
||||
} catch {
|
||||
registered = undefined;
|
||||
registered = await requestSession();
|
||||
} finally {
|
||||
registering = false;
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user