fix(display): keep one object URL per cached slide, and give them back
The Cache Storage media cache minted a new object URL on every lookup and never revoked any. Every state refresh looks up every cached slide, so a running display pinned one more blob per slide per refresh until the browser refused its requests with net::ERR_INSUFFICIENT_RESOURCES. It then showed its offline banner on a working network. Two tabs leaked twice as fast, and real photographs fill blob storage far sooner than test slides. Each cache entry now has exactly one object URL: - every lookup reuses it; - pruning or replacing the entry revokes it; - release(), new on SignageMediaCache, revokes all of them, and the controller calls it on stop once the stage is gone. Two lookups of one slide racing each other keep the first URL and make no second one. The in-memory cache has nothing to give back. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1c6c9ccc1f
commit
607f375809
+49
-3
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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.store(key, `https://files.example.test/${key}?sig=1`);
|
||||||
await media.find(key);
|
await media.find(key);
|
||||||
}
|
}
|
||||||
(media as SignageMediaCache & { release(): void }).release();
|
media.release();
|
||||||
expect(live.size).toBe(0);
|
expect(live.size).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user