fix(display): keep one object URL per cached slide, and give them back #6

Merged
Zayin_Admin merged 3 commits from fix/display-media-object-urls into main 2026-10-06 02:56:01 +00:00
4 changed files with 171 additions and 3 deletions
+1
View File
@@ -4,3 +4,4 @@
two-pages two-pages
session-blip session-blip
stream-failure stream-failure
stream-recycle
+49 -3
View File
@@ -36,6 +36,9 @@ export interface SignageMediaCache {
* can never leave a screen with less than it had. */ * can never leave a screen with less than it had. */
prune(keep: readonly string[]): Promise<void>; prune(keep: readonly string[]): Promise<void>;
count(): Promise<number>; count(): Promise<number>;
/** Gives back every local URL this cache handed out. Called when a display stops; a URL handed
* out before this is no longer valid afterwards. */
release(): void;
} }
/** The stable key a native image's bytes are cached under. Deliberately built from identity, never /** The stable key a native image's bytes are cached under. Deliberately built from identity, never
@@ -86,6 +89,9 @@ export function createMemoryMediaCache(): SignageMediaCache {
} }
return Promise.resolve(); return Promise.resolve();
}, },
release(): void {
// Holds plain URLs, nothing the browser has to be given back.
},
store(key: string, url: string): Promise<string | undefined> { store(key: string, url: string): Promise<string | undefined> {
stored.set(key, url); stored.set(key, url);
return Promise.resolve(url); return Promise.resolve(url);
@@ -179,14 +185,45 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
return `${origin}/${key}`; return `${origin}/${key}`;
} }
async function localUrl(cache: Cache, url: string): Promise<string | undefined> { /**
const response = await cache.match(url); * The one object URL each cached slide is rendered from, by cache entry.
*
* An object URL pins its blob in the browser until it is revoked. Every state refresh looks every
* cached slide up again, so minting a URL per lookup leaked a blob per slide per refresh, and
* within minutes the browser refused the display's own requests for want of resources. So each
* entry gets exactly one URL, revoked when the entry is pruned or replaced, and all of them when
* the display stops.
*/
const objectUrls = new Map<string, string>();
function forget(request: string): void {
const url = objectUrls.get(request);
if (url !== undefined) {
objectUrls.delete(request);
URL.revokeObjectURL(url);
}
}
async function localUrl(cache: Cache, request: string): Promise<string | undefined> {
const existing = objectUrls.get(request);
if (existing !== undefined) {
return existing;
}
const response = await cache.match(request);
if (!response) { if (!response) {
return undefined; return undefined;
} }
try { try {
const blob = await response.blob(); const blob = await response.blob();
return URL.createObjectURL(blob); // Two lookups of one slide can be in flight at once; the first to finish wins, and the other
// gives its URL straight back rather than leaving it pinned.
const raced = objectUrls.get(request);
if (raced !== undefined) {
return raced;
}
const url = URL.createObjectURL(blob);
objectUrls.set(request, url);
return url;
} catch { } catch {
return undefined; return undefined;
} }
@@ -220,6 +257,7 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
for (const request of await cache.keys()) { for (const request of await cache.keys()) {
if (!retained.has(request.url)) { if (!retained.has(request.url)) {
await cache.delete(request); await cache.delete(request);
forget(request.url);
} }
} }
} catch { } catch {
@@ -227,6 +265,12 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
} }
}, },
release(): void {
for (const request of [...objectUrls.keys()]) {
forget(request);
}
},
async store(key: string, url: string): Promise<string | undefined> { async store(key: string, url: string): Promise<string | undefined> {
const cache = await open(); const cache = await open();
if (!cache) { if (!cache) {
@@ -242,6 +286,8 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis):
// Stored under the *stable* key, so the next refresh's freshly signed URL finds these // Stored under the *stable* key, so the next refresh's freshly signed URL finds these
// bytes instead of downloading them again. // bytes instead of downloading them again.
await cache.put(cacheRequest(key), response.clone()); await cache.put(cacheRequest(key), response.clone());
// New bytes under this key: the URL for the old ones is given back before one is made.
forget(cacheRequest(key));
return localUrl(cache, cacheRequest(key)); return localUrl(cache, cacheRequest(key));
} catch { } catch {
return undefined; return undefined;
+2
View File
@@ -715,6 +715,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp
exitOffline(); exitOffline();
await stage?.destroy(); await stage?.destroy();
stage = undefined; stage = undefined;
// After the stage is gone, so nothing on screen still points at a released URL.
media.release();
}, },
}; };
+119
View File
@@ -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<string, { body: ArrayBuffer; type: string }>();
const cache = {
async delete(request: Request | string): Promise<boolean> {
return entries.delete(typeof request === "string" ? request : request.url);
},
async keys(): Promise<Request[]> {
return [...entries.keys()].map((url) => new Request(url));
},
async match(request: Request | string): Promise<Response | undefined> {
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<void> {
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<string>;
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.release();
expect(live.size).toBe(0);
});
});