diff --git a/src/browser/storage.ts b/src/browser/storage.ts index ca883c1..06c6867 100644 --- a/src/browser/storage.ts +++ b/src/browser/storage.ts @@ -36,6 +36,9 @@ export interface SignageMediaCache { * can never leave a screen with less than it had. */ prune(keep: readonly string[]): Promise; count(): Promise; + /** 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 @@ -86,6 +89,9 @@ export function createMemoryMediaCache(): SignageMediaCache { } return Promise.resolve(); }, + release(): void { + // Holds plain URLs, nothing the browser has to be given back. + }, store(key: string, url: string): Promise { stored.set(key, url); return Promise.resolve(url); @@ -179,14 +185,45 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis): return `${origin}/${key}`; } - async function localUrl(cache: Cache, url: string): Promise { - 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(); + + 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 { + const existing = objectUrls.get(request); + if (existing !== undefined) { + return existing; + } + const response = await cache.match(request); if (!response) { return undefined; } try { 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 { return undefined; } @@ -220,6 +257,7 @@ export function createBrowserMediaCache(scope: typeof globalThis = globalThis): for (const request of await cache.keys()) { if (!retained.has(request.url)) { await cache.delete(request); + forget(request.url); } } } 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 { const cache = await open(); 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 // bytes instead of downloading them again. 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)); } catch { return undefined; diff --git a/src/display/controller.ts b/src/display/controller.ts index d531c11..2cdf6d3 100644 --- a/src/display/controller.ts +++ b/src/display/controller.ts @@ -715,6 +715,8 @@ export function createDisplayController(options: DisplayControllerOptions): Disp exitOffline(); await stage?.destroy(); stage = undefined; + // After the stage is gone, so nothing on screen still points at a released URL. + media.release(); }, }; diff --git a/tests/browser/media-cache.test.ts b/tests/browser/media-cache.test.ts index 9754dbe..4618c55 100644 --- a/tests/browser/media-cache.test.ts +++ b/tests/browser/media-cache.test.ts @@ -113,7 +113,7 @@ describe("Signage's media cache and object URLs", () => { await media.store(key, `https://files.example.test/${key}?sig=1`); await media.find(key); } - (media as SignageMediaCache & { release(): void }).release(); + media.release(); expect(live.size).toBe(0); }); });