fix(display): continue a source slot's rotation while offline
CI / typecheck (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 28s
CI / test (pull_request) Successful in 29s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
CI / typecheck (pull_request) Successful in 24s
CI / lint (pull_request) Successful in 26s
CI / build (pull_request) Successful in 28s
CI / test (pull_request) Successful in 29s
CI / package (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 8m49s
Offline continuation replayed each slot's newest cached answer. A provider-source slot is answered with the provider's next item on every play, so its newest answer is the image already on screen: on a timeline of one source slot, every offline boundary "advanced" to the same picture. Stage 0's real timeline is exactly that, which is why the wall froze whether the network or only the API went away, and resumed the moment the server answered. Each slot's cached answers are now its observed rotation (one per item, oldest-played first), and the k-th play of the slot since the anchor shows the k-th entry. The play count is arithmetic, skipping whole passes wherever the eligible set is stable, so a week offline is still one evaluation. Where a play starts is unchanged and still decided by advanceCursor alone; a pinned slot is a rotation of one and behaves as before. Nothing is invented: only answers the server already resolved are replayed, and an item whose newest answer is unusable is skipped. Regression tests: the resolver on a one-slot rotation of two and three images, across a week, and with an unusable image; and the controller on the real shape for six slide intervals with the network down and with only the API down. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
5f52334664
commit
ab5b02960a
+133
-19
@@ -295,6 +295,98 @@ describe("offline continuation", () => {
|
||||
expect(JSON.stringify(persisted)).not.toContain("sig=secret");
|
||||
expect(JSON.stringify(persisted)).not.toContain("https://");
|
||||
});
|
||||
|
||||
/**
|
||||
* Stage 0's real timeline: **one** provider-source slot, which the server answers with the next
|
||||
* image of the provider's rotation on every play. Offline, replaying only that slot's newest answer
|
||||
* meant replaying the image already on screen — every boundary "advanced" to the same picture.
|
||||
*/
|
||||
function rotatingSnapshot(newestFirstItems: readonly string[]): OfflineSnapshot {
|
||||
const selections = newestFirstItems.map((item, index) =>
|
||||
selection({ id: `sel-${index}`, itemId: `item-${item}`, slotId: "slot-source" }),
|
||||
);
|
||||
return snapshot({
|
||||
playback: {
|
||||
blackout: false,
|
||||
currentSelectionId: "sel-0",
|
||||
currentSlotId: "slot-source",
|
||||
deadlineAtMs: 5000,
|
||||
pendingTimelineId: null,
|
||||
remainingMs: 5000,
|
||||
revision: 3,
|
||||
startedAtMs: 0,
|
||||
transport: "playing",
|
||||
},
|
||||
selections,
|
||||
timeline: {
|
||||
eligibleFromMs: null,
|
||||
eligibleUntilMs: null,
|
||||
id: "timeline-1",
|
||||
revision: 2,
|
||||
slots: [slot("slot-source", 0)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function itemAt(persisted: OfflineSnapshot, atMs: number): string | undefined {
|
||||
const { resolution } = resolveOffline({ atMs, snapshot: persisted });
|
||||
return resolution.kind === "selection" ? resolution.selection.itemId : undefined;
|
||||
}
|
||||
|
||||
it("continues a source slot's rotation through the answers it already has", () => {
|
||||
// On screen: B. Before it, A, then B, then A — the provider's two-image rotation.
|
||||
const persisted = rotatingSnapshot(["b", "a", "b", "a"]);
|
||||
|
||||
expect(itemAt(persisted, 2500)).toBe("item-b");
|
||||
expect([7500, 12_500, 17_500, 22_500, 27_500].map((at) => itemAt(persisted, at))).toEqual([
|
||||
"item-a",
|
||||
"item-b",
|
||||
"item-a",
|
||||
"item-b",
|
||||
"item-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the provider's own order across a longer rotation", () => {
|
||||
// Played C, A, B, C, A, B — B is on screen, so C comes next, then A, then B.
|
||||
const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]);
|
||||
|
||||
expect([7500, 12_500, 17_500, 22_500].map((at) => itemAt(persisted, at))).toEqual([
|
||||
"item-c",
|
||||
"item-a",
|
||||
"item-b",
|
||||
"item-c",
|
||||
]);
|
||||
});
|
||||
|
||||
it("catches a rotation up across a week in one evaluation", () => {
|
||||
const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]);
|
||||
// Mid-way through the slot's 120,961st play since the anchor.
|
||||
const week = 7 * 24 * 60 * 60 * 1000 + 7500;
|
||||
const started = Date.now();
|
||||
|
||||
// A three-image rotation that resumes at C shows C on every play numbered 1 mod 3.
|
||||
expect(itemAt(persisted, week)).toBe("item-c");
|
||||
expect(itemAt(persisted, week + 5000)).toBe("item-a");
|
||||
expect(Date.now() - started).toBeLessThan(200);
|
||||
});
|
||||
|
||||
it("skips an image it cannot render and keeps rotating through the rest", () => {
|
||||
const persisted = rotatingSnapshot(["b", "a", "c", "b", "a", "c"]);
|
||||
const unusable = new Set(["sel-1"]);
|
||||
|
||||
const items = [7500, 12_500, 17_500].map((atMs) => {
|
||||
const { resolution } = resolveOffline({
|
||||
atMs,
|
||||
snapshot: persisted,
|
||||
unusableSelectionIds: unusable,
|
||||
});
|
||||
return resolution.kind === "selection" ? resolution.selection.itemId : undefined;
|
||||
});
|
||||
|
||||
// A's newest answer is unusable, so A is not shown — and no older answer of A is dug up instead.
|
||||
expect(items).toEqual(["item-c", "item-b", "item-c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a display that loses its network", () => {
|
||||
@@ -660,18 +752,25 @@ describe("a running display that loses its network for several slides", () => {
|
||||
return selectionId?.split("-")[1];
|
||||
}
|
||||
|
||||
/** The two timeline shapes a pair of alternating images can take. Two pinned slots, one image
|
||||
* each; or Stage 0's real one — a single provider-source slot the server answers with the next
|
||||
* image of the rotation on every play. */
|
||||
type TimelineShape = "two pinned slots" | "one provider-source slot";
|
||||
|
||||
/** The server's answer at `atMs`: two images alternating, each play its own selection, and a
|
||||
* freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */
|
||||
function serverState(atMs: number): DisplayChannelStateView {
|
||||
function serverState(atMs: number, shape: TimelineShape): DisplayChannelStateView {
|
||||
const cycle = Math.floor(atMs / SLOT_MS);
|
||||
const slide = (c: number) => (c % 2 === 0 ? "a" : "b");
|
||||
const slotFor = (c: number) =>
|
||||
shape === "two pinned slots" ? `slot-${slide(c)}` : "slot-source";
|
||||
const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2]
|
||||
.filter((c) => c >= 0)
|
||||
.map((c) => ({
|
||||
...selection({
|
||||
id: `sel-${slide(c)}-${c}`,
|
||||
itemId: `item-${slide(c)}`,
|
||||
slotId: `slot-${slide(c)}`,
|
||||
slotId: slotFor(c),
|
||||
}),
|
||||
media: {
|
||||
display: {
|
||||
@@ -716,16 +815,29 @@ describe("a running display that loses its network for several slides", () => {
|
||||
eligibleUntilMs: null,
|
||||
id: "timeline-1",
|
||||
revision: 1,
|
||||
slots: [
|
||||
slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }),
|
||||
slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }),
|
||||
],
|
||||
slots:
|
||||
shape === "two pinned slots"
|
||||
? [
|
||||
slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }),
|
||||
slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }),
|
||||
]
|
||||
: [slot("slot-source", 0, { estimatedDurationMs: SLOT_MS })],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps advancing through its cached images on an origin with no Cache Storage", async () => {
|
||||
const network = { reachable: true };
|
||||
it.each([
|
||||
// Wi-Fi off on a plain-http LAN origin: no API, no storage, and no Cache Storage.
|
||||
{ outage: "network", shape: "two pinned slots" },
|
||||
{ outage: "network", shape: "one provider-source slot" },
|
||||
// The API process stopped while the storage server stays up.
|
||||
{ outage: "api", shape: "one provider-source slot" },
|
||||
] as const)("keeps cycling through its cached images with the $outage down: $shape", async ({
|
||||
outage,
|
||||
shape,
|
||||
}) => {
|
||||
const api = { reachable: true };
|
||||
const storage = { reachable: true };
|
||||
// Mid-slide, so every sample below lands halfway through one.
|
||||
const clock = createClock(12_500);
|
||||
const unreachable = () => new TypeError("Failed to fetch");
|
||||
@@ -749,17 +861,17 @@ describe("a running display that loses its network for several slides", () => {
|
||||
method: "GET",
|
||||
path: /^viewers\/[^/]+\/display-state$/u,
|
||||
respond: () => {
|
||||
if (!network.reachable) {
|
||||
if (!api.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return jsonResponse(serverState(clock.now()));
|
||||
return jsonResponse(serverState(clock.now(), shape));
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: /^viewers\/.*\/heartbeat$/u,
|
||||
respond: () => {
|
||||
if (!network.reachable) {
|
||||
if (!api.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return jsonResponse({});
|
||||
@@ -767,10 +879,10 @@ describe("a running display that loses its network for several slides", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
// A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else.
|
||||
// A plain-http LAN origin: no `caches`, but `fetch` — which needs the storage server.
|
||||
const media = createBrowserMediaCache({
|
||||
fetch: async () => {
|
||||
if (!network.reachable) {
|
||||
if (!storage.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
@@ -783,9 +895,9 @@ describe("a running display that loses its network for several slides", () => {
|
||||
const controller = createDisplayController({
|
||||
clearTimer: clock.clearTimer,
|
||||
context: host.context,
|
||||
// What a browser does: bytes this page holds always decode, and a remote URL needs a network.
|
||||
// What a browser does: bytes this page holds always decode, and a remote URL needs storage.
|
||||
decodeImage: (image) =>
|
||||
(image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable
|
||||
(image.getAttribute("src") ?? "").startsWith("blob:") || storage.reachable
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
||||
media,
|
||||
@@ -807,8 +919,9 @@ describe("a running display that loses its network for several slides", () => {
|
||||
expect(online).toEqual(["a", "b", "a"]);
|
||||
expect(controller.diagnostics().offline).toBe(false);
|
||||
|
||||
// The network goes away for six slides.
|
||||
network.reachable = false;
|
||||
// The outage lasts six slides.
|
||||
api.reachable = false;
|
||||
storage.reachable = outage !== "network";
|
||||
const offline: (string | undefined)[] = [];
|
||||
for (let interval = 0; interval < 6; interval += 1) {
|
||||
await clock.advance(SLOT_MS);
|
||||
@@ -821,8 +934,9 @@ describe("a running display that loses its network for several slides", () => {
|
||||
expect(controller.diagnostics().unusableSelectionIds).toEqual([]);
|
||||
expect(root.dataset.slate).toBe("false");
|
||||
|
||||
// The network returns. The server is authoritative again with no reload.
|
||||
network.reachable = true;
|
||||
// Everything returns. The server is authoritative again with no reload.
|
||||
api.reachable = true;
|
||||
storage.reachable = true;
|
||||
await clock.advance(70_000);
|
||||
const diagnostics = controller.diagnostics();
|
||||
expect(diagnostics.offline).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user