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]>
120 lines
4.3 KiB
TypeScript
120 lines
4.3 KiB
TypeScript
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);
|
|
});
|
|
});
|