fix(display): keep one object URL per cached slide, and give them back #6
+49
-3
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user