test(display): one live object URL per cached slide, released when done
The media cache minted a new object URL on every lookup and never revoked one. Each state refresh looks up every cached slide, so a display leaked one pinned blob per slide per refresh. In a real browser (Ekklesia draft PR #56, scenario stream-recycle) its own state requests then failed with net::ERR_INSUFFICIENT_RESOURCES within about four minutes, and it showed its offline banner on a working network. Fails today: 303 live URLs for 3 slides after 100 refreshes. The same bytes get a new URL each time, nothing is revoked on prune or replace, and there is no release. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ab4142fb95
commit
1c6c9ccc1f
@@ -0,0 +1,119 @@
|
||||
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 as SignageMediaCache & { release(): void }).release();
|
||||
expect(live.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user