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
3 changed files with 52 additions and 4 deletions
Showing only changes of commit 607f375809 - Show all commits
+49 -3
View File
@@ -36,6 +36,9 @@ export interface SignageMediaCache {
* can never leave a screen with less than it had. */
prune(keep: readonly string[]): Promise<void>;
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
@@ -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<string | undefined> {
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<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) {
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<string | undefined> {
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;
+2
View File
@@ -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();
},
};
+1 -1
View File
@@ -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);
});
});