From 1c6c9ccc1f5c6381e0d33956e63ba92e3976b536 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Mon, 5 Oct 2026 18:36:47 -0500 Subject: [PATCH] test(display): one live object URL per cached slide, released when done The media cache minted a new object URL on every lookup and never revoked one. Each state refresh looks up every cached slide, so a display leaked one pinned blob per slide per refresh. In a real browser (Ekklesia draft PR #56, scenario stream-recycle) its own state requests then failed with net::ERR_INSUFFICIENT_RESOURCES within about four minutes, and it showed its offline banner on a working network. Fails today: 303 live URLs for 3 slides after 100 refreshes. The same bytes get a new URL each time, nothing is revoked on prune or replace, and there is no release. Co-Authored-By: Claude Opus 5.5 --- tests/browser/media-cache.test.ts | 119 ++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/browser/media-cache.test.ts diff --git a/tests/browser/media-cache.test.ts b/tests/browser/media-cache.test.ts new file mode 100644 index 0000000..9754dbe --- /dev/null +++ b/tests/browser/media-cache.test.ts @@ -0,0 +1,119 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createBrowserMediaCache, type SignageMediaCache } from "../../src/browser/storage.js"; + +/** + * The Cache Storage media cache hands out object URLs, and every live one pins its blob in the + * browser until it is revoked. + * + * Every state refresh looks up every cached slide, so a cache that minted a fresh URL per lookup + * leaked one blob per slide per refresh. In a real browser that exhausted blob storage within + * minutes: the display's own state requests then failed with `net::ERR_INSUFFICIENT_RESOURCES` and + * it fell back to its offline banner on a perfectly good network. + */ + +/** Just enough Cache Storage: one named cache, keyed by request URL. */ +function fakeCacheStorage() { + const entries = new Map(); + const cache = { + async delete(request: Request | string): Promise { + return entries.delete(typeof request === "string" ? request : request.url); + }, + async keys(): Promise { + return [...entries.keys()].map((url) => new Request(url)); + }, + async match(request: Request | string): Promise { + const entry = entries.get(typeof request === "string" ? request : request.url); + return entry + ? new Response(entry.body, { headers: { "content-type": entry.type } }) + : undefined; + }, + async put(request: Request | string, response: Response): Promise { + entries.set(typeof request === "string" ? request : request.url, { + body: await response.arrayBuffer(), + type: response.headers.get("content-type") ?? "application/octet-stream", + }); + }, + }; + return { open: async () => cache } as unknown as CacheStorage; +} + +describe("Signage's media cache and object URLs", () => { + const realCreate = URL.createObjectURL; + const realRevoke = URL.revokeObjectURL; + let live: Set; + let media: SignageMediaCache; + + beforeEach(() => { + live = new Set(); + let next = 0; + URL.createObjectURL = () => { + next += 1; + const url = `blob:test/${next}`; + live.add(url); + return url; + }; + URL.revokeObjectURL = (url: string) => { + live.delete(url); + }; + media = createBrowserMediaCache({ + caches: fakeCacheStorage(), + fetch: async () => + new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } }), + location: { origin: "https://api.example.test" }, + } as unknown as typeof globalThis); + }); + + afterEach(() => { + URL.createObjectURL = realCreate; + URL.revokeObjectURL = realRevoke; + }); + + const keys = ["slide/a", "slide/b", "slide/c"]; + + it("keeps one live object URL per cached slide, however often it is looked up", async () => { + for (const key of keys) { + await media.store(key, `https://files.example.test/${key}?sig=1`); + } + // A state refresh looks every slide up again; a long-running display does this for months. + for (let refresh = 0; refresh < 100; refresh += 1) { + for (const key of keys) { + await media.find(key); + } + } + expect(live.size).toBe(keys.length); + }); + + it("hands out the same URL for the same bytes", async () => { + await media.store("slide/a", "https://files.example.test/a?sig=1"); + const first = await media.find("slide/a"); + const second = await media.find("slide/a"); + expect(first).toBeDefined(); + expect(second).toBe(first); + }); + + it("revokes a pruned slide's URL", async () => { + for (const key of keys) { + await media.store(key, `https://files.example.test/${key}?sig=1`); + await media.find(key); + } + await media.prune(["slide/a"]); + expect(live.size).toBe(1); + expect(await media.find("slide/b")).toBeUndefined(); + }); + + it("revokes the URL it replaces when a slide is stored again", async () => { + await media.store("slide/a", "https://files.example.test/a?sig=1"); + await media.store("slide/a", "https://files.example.test/a?sig=2"); + await media.find("slide/a"); + expect(live.size).toBe(1); + }); + + it("releases every URL it handed out when the display stops", async () => { + for (const key of keys) { + await media.store(key, `https://files.example.test/${key}?sig=1`); + await media.find(key); + } + (media as SignageMediaCache & { release(): void }).release(); + expect(live.size).toBe(0); + }); +});