mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Two halves of the same problem. A screen has to keep playing when the link is gone, and it must not keep playing the wrong thing once the link is back. CACHING FOR OFFLINE, on the players that could not: - Tizen cached nothing but the playlist, so a panel came back from a reboot knowing exactly what to show and fetched every frame of it from a server that was not there. tizen/js/media-cache.js caches the media itself to wgt-private (the store Tizen documents as surviving reboots), resumable via Range and If-Range, with the transfer async so a stalled chunk cannot freeze the player. offline.cache moves from "absent" to a runtime claim: a build with no writable private storage still says nothing. - The web player's worker stored only what a single fetch() happened to complete, which on a marginal link is nothing at all — a 200MB asset never finishes in one go and every retry starts from zero. It now accumulates in resumable chunks, driven by the player's playlist rather than by playback, so the prefetch is not competing with the video that is currently on screen for the same scarce bandwidth. BrightSign inherits this. STILL UPDATING, which caching quietly breaks: PUT /api/content/:id/replace changes an asset's bytes under a stable id. Every cache keys on that id, so before this the new bytes could not reach a panel that already held the old ones — not until the next refresh, but never. Content now carries a revision, stamped onto each item at send time like widget revs, and every player keys its cache on it. The same send-time refresh fixes a second bug: a replace writes a new randomly-named file and unlinks the old one, so the filepath in a published snapshot pointed at a deleted file and web panels 404'd on the item until somebody republished the playlist. The route now also pushes to affected devices, which it never did. Bytes are kept only where they can be built upon: no validator means no safe resume, so the partial is discarded and the attempt backs off as the failure it is rather than re-fetching the same prefix forever. Server needed no new transfer support — res.sendFile already does Range, If-Range and 416. The Tizen cache and the service worker are both driven in Node against fakes, because neither can be exercised without hardware and "the chunks assemble correctly" is not something to discover from a panel showing a corrupt video. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
227 lines
9.9 KiB
JavaScript
227 lines
9.9 KiB
JavaScript
'use strict';
|
|
|
|
// Drives the REAL service worker (server/player/sw.js) against a fake Cache API and a deliberately
|
|
// bad link. The policy tests cover the arithmetic; this covers the orchestration around it, which is
|
|
// where a resumed download actually gets corrupted: appending the wrong chunk, publishing a
|
|
// half-assembled asset, or serving a bookkeeping entry as if it were a video.
|
|
//
|
|
// It matters that this runs the shipped file rather than a copy — the worker cannot be exercised on
|
|
// a device without deploying to one, and "the chunks assemble correctly" is not something you want
|
|
// to discover from a panel showing a corrupt video.
|
|
|
|
const { test, beforeEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
const SW_SRC = fs.readFileSync(path.join(__dirname, '..', 'player', 'sw.js'), 'utf8');
|
|
const POLICY_PATH = path.join(__dirname, '..', 'lib', 'player-cache-policy.js');
|
|
|
|
const ASSET = 'http://s/uploads/content/clip.mp4?rev=100';
|
|
|
|
/** Cache API over a Map. Keys are URLs, exactly as the real one behaves for our uses. */
|
|
class FakeCache {
|
|
constructor() { this.map = new Map(); }
|
|
#url(req) { return typeof req === 'string' ? req : req.url; }
|
|
async match(req) { const r = this.map.get(this.#url(req)); return r ? r.clone() : undefined; }
|
|
async put(req, res) { this.map.set(this.#url(req), res); }
|
|
async keys() { return [...this.map.keys()].map((u) => ({ url: u })); }
|
|
async delete(req) { return this.map.delete(this.#url(req)); }
|
|
}
|
|
|
|
/**
|
|
* A server for one asset. `failEvery` drops the connection on every Nth request (0 = never), which
|
|
* is what a marginal link looks like from the client's side.
|
|
*/
|
|
function makeServer(body, { etag = '"v1"', failEvery = 0, rangeSupport = true } = {}) {
|
|
const state = { body, etag, failEvery, rangeSupport, requests: 0, ranged: [] };
|
|
state.fetch = async (request) => {
|
|
state.requests++;
|
|
if (state.failEvery && state.requests % state.failEvery === 0) throw new Error('network dropped');
|
|
|
|
const range = request.headers.get('Range');
|
|
const ifRange = request.headers.get('If-Range');
|
|
if (!range || !state.rangeSupport) {
|
|
return new Response(state.body, { status: 200, headers: { ETag: state.etag, 'Content-Type': 'video/mp4' } });
|
|
}
|
|
// If-Range with a stale validator: the server must send the WHOLE asset, not a tail. This is
|
|
// the mechanism that stops a resume splicing two different files together.
|
|
if (ifRange && ifRange !== state.etag) {
|
|
return new Response(state.body, { status: 200, headers: { ETag: state.etag, 'Content-Type': 'video/mp4' } });
|
|
}
|
|
const m = /bytes=(\d+)-(\d*)/.exec(range);
|
|
const start = Number(m[1]);
|
|
if (start >= state.body.length) {
|
|
return new Response('', { status: 416, headers: { 'Content-Range': `bytes */${state.body.length}` } });
|
|
}
|
|
const end = m[2] === '' ? state.body.length - 1 : Math.min(Number(m[2]), state.body.length - 1);
|
|
state.ranged.push([start, end]);
|
|
return new Response(state.body.slice(start, end + 1), {
|
|
status: 206,
|
|
headers: {
|
|
'Content-Range': `bytes ${start}-${end}/${state.body.length}`,
|
|
ETag: state.etag,
|
|
'Content-Type': 'video/mp4'
|
|
}
|
|
});
|
|
};
|
|
return state;
|
|
}
|
|
|
|
let sandbox, caches, server;
|
|
|
|
function load(srv, chunkBytes) {
|
|
server = srv;
|
|
const contentCache = new FakeCache();
|
|
const shellCache = new FakeCache();
|
|
caches = {
|
|
open: async (name) => (name === 'rd-content-v1' ? contentCache : shellCache),
|
|
keys: async () => ['rd-content-v1'],
|
|
delete: async () => true,
|
|
match: async () => undefined,
|
|
_content: contentCache
|
|
};
|
|
|
|
sandbox = {
|
|
caches,
|
|
fetch: (req) => server.fetch(req),
|
|
Response, Request, Blob, URL, console,
|
|
location: { href: 'http://s/player/index.html' },
|
|
navigator: {},
|
|
importScripts() {
|
|
// The worker importScripts()es the same policy module the Node tests require, so both sides
|
|
// are provably the same rules rather than two implementations that agree today.
|
|
delete require.cache[require.resolve(POLICY_PATH)];
|
|
sandbox.self.PlayerCachePolicy = require(POLICY_PATH);
|
|
},
|
|
addEventListener() {},
|
|
skipWaiting() {},
|
|
clients: { claim() {} }
|
|
};
|
|
sandbox.self = sandbox;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(SW_SRC, sandbox);
|
|
// A 4MB production chunk would make these tests move 100MB around; the logic is size-agnostic.
|
|
if (chunkBytes) sandbox.self.PlayerCachePolicy.CHUNK_BYTES = chunkBytes;
|
|
return sandbox;
|
|
}
|
|
|
|
const bytes = (n, fill) => Buffer.alloc(n, fill);
|
|
async function cachedBody(url = ASSET) {
|
|
const hit = await caches._content.match(url);
|
|
return hit ? Buffer.from(await hit.arrayBuffer()) : null;
|
|
}
|
|
const internalKeys = () => [...caches._content.map.keys()].filter((k) => k.includes('__st_part'));
|
|
|
|
beforeEach(() => { sandbox = null; });
|
|
|
|
test('THE BUG: a link that drops every other request still assembles a byte-perfect asset', async () => {
|
|
// Each call gets a chunk or two and dies. Without accumulation this is an infinite loop that
|
|
// caches nothing — the panel keeps an empty cache and goes dark the moment the uplink does.
|
|
const body = bytes(1000, 0x41);
|
|
const sw = load(makeServer(body, { failEvery: 2 }), 100);
|
|
|
|
for (let pass = 0; pass < 40 && !(await cachedBody()); pass++) {
|
|
try { await sw.ensureCached(ASSET); } catch (e) { /* the link, not the worker */ }
|
|
}
|
|
|
|
const got = await cachedBody();
|
|
assert.ok(got, 'the asset must eventually be cached');
|
|
assert.deepEqual(got, body, 'reassembled bytes must be identical to the original');
|
|
});
|
|
|
|
test('the full entry appears only when whole — a fragment is never published', async () => {
|
|
// The invariant that protects playback: a cache hit is always a complete asset. Publishing early
|
|
// would hand a media element a truncated file that it cannot report as "incomplete", only as
|
|
// broken.
|
|
const body = bytes(1000, 0x42);
|
|
const sw = load(makeServer(body, { failEvery: 3 }), 100);
|
|
|
|
for (let pass = 0; pass < 40; pass++) {
|
|
try { await sw.ensureCached(ASSET); } catch (e) { /* */ }
|
|
const partial = await cachedBody();
|
|
if (partial) { assert.equal(partial.length, body.length, 'a published entry must be the whole asset'); break; }
|
|
assert.ok(internalKeys().length >= 0);
|
|
}
|
|
assert.deepEqual(await cachedBody(), body);
|
|
});
|
|
|
|
test('bookkeeping entries are cleaned up once the asset is whole', async () => {
|
|
const body = bytes(500, 0x43);
|
|
const sw = load(makeServer(body), 100);
|
|
await sw.ensureCached(ASSET);
|
|
assert.deepEqual(await cachedBody(), body);
|
|
assert.deepEqual(internalKeys(), [], 'chunks and meta must not outlive the assembled asset');
|
|
});
|
|
|
|
test('an already-cached asset costs no requests at all', async () => {
|
|
// The prefetch runs on every playlist sweep. Re-fetching a cached asset each time would be a
|
|
// constant drain on the link least able to afford it.
|
|
const srv = makeServer(bytes(500, 0x44));
|
|
const sw = load(srv, 100);
|
|
await sw.ensureCached(ASSET);
|
|
const after = srv.requests;
|
|
await sw.ensureCached(ASSET);
|
|
assert.equal(srv.requests, after, 'a second pass over a cached asset must not touch the network');
|
|
});
|
|
|
|
test('an asset replaced mid-transfer is NOT spliced — the chunks are discarded', async () => {
|
|
// The corruption this whole design exists to prevent: appending the tail of the new asset to the
|
|
// head of the old one yields a file of exactly the right length that is wrong throughout, and it
|
|
// would pass every completeness check we have.
|
|
const v1 = bytes(1000, 0x61);
|
|
const v2 = bytes(1000, 0x62);
|
|
const srv = makeServer(v1, { failEvery: 3 });
|
|
const sw = load(srv, 100);
|
|
|
|
try { await sw.ensureCached(ASSET); } catch (e) { /* */ }
|
|
assert.ok(internalKeys().length > 0, 'expected partial progress to exist before the swap');
|
|
|
|
srv.body = v2; srv.etag = '"v2"'; srv.failEvery = 0;
|
|
|
|
// The first pass after the swap gets a 200 from If-Range and must throw the v1 chunks away
|
|
// rather than continue on top of them; the next rebuilds from scratch.
|
|
await sw.ensureCached(ASSET);
|
|
for (let pass = 0; pass < 10 && !(await cachedBody()); pass++) await sw.ensureCached(ASSET);
|
|
|
|
const got = await cachedBody();
|
|
assert.ok(got, 'the replaced asset must still end up cached');
|
|
assert.deepEqual(got, v2, 'the cached asset must be all-v2, with no v1 bytes spliced in');
|
|
assert.ok(!got.includes(0x61), 'not one byte of the superseded asset may survive');
|
|
});
|
|
|
|
test('a superseded revision is swept rather than left to fill the quota', async () => {
|
|
// Replacing an asset changes the revision in the URL, which is what makes the new bytes a miss.
|
|
// Without the sweep the old copy sits there until the quota evicts it — on a 1GB panel budget a
|
|
// handful of replaced videos is the whole cache.
|
|
const oldUrl = 'http://s/uploads/content/clip.mp4?rev=100';
|
|
const newUrl = 'http://s/uploads/content/clip.mp4?rev=200';
|
|
const sw = load(makeServer(bytes(300, 0x45)), 100);
|
|
|
|
await sw.ensureCached(oldUrl);
|
|
assert.ok(await cachedBody(oldUrl));
|
|
|
|
server.body = bytes(300, 0x46); server.etag = '"v2"';
|
|
await sw.ensureCached(newUrl);
|
|
|
|
assert.ok(await cachedBody(newUrl), 'the new revision is cached');
|
|
assert.equal(await cachedBody(oldUrl), null, 'the superseded revision is gone');
|
|
});
|
|
|
|
test('a server with no range support still caches the asset whole', async () => {
|
|
// Not every deployment sits behind something that honours Range. Falling back to a plain store is
|
|
// the pre-existing behaviour and remains correct — just not resumable.
|
|
const body = bytes(600, 0x47);
|
|
const sw = load(makeServer(body, { rangeSupport: false }), 100);
|
|
await sw.ensureCached(ASSET);
|
|
assert.deepEqual(await cachedBody(), body);
|
|
});
|
|
|
|
test('a chunked transfer asks for each range exactly once, in order', async () => {
|
|
const sw = load(makeServer(bytes(1000, 0x48)), 250);
|
|
await sw.ensureCached(ASSET);
|
|
const starts = server.ranged.map((r) => r[0]);
|
|
assert.deepEqual(starts, [0, 250, 500, 750], 'no gaps, no repeats, no overlap');
|
|
});
|