mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Two faults that are live on 1.9.29, both silent, both ending in a dark screen. A missing upload answered 200 OK with Content-Type: text/html and 15KB of the dashboard, under the immutable/30-day header the mount sets before it knows whether the file exists. Every downloader here treats 200 as success, so a panel stores the page AS the video and caches it for a month; Android validates the byte count, not the type, so a correctly-sized page passes integrity and is promoted as a valid asset. Reachable exactly when it hurts — a replace writes a new random filename and unlinks the old one. Now a 404, with the cache header removed. And the service worker treated an empty playlist as "keep nothing". But `assignments: []` is what the server sends for a device between playlists, for a playlist never published, and from the catch when a snapshot fails to parse — so a message that means nothing of the sort deleted every byte of media the panel held. Only survivable while the uplink is up, i.e. exactly when the cache is worthless. Both regression tests drive the whole server or the real worker, because both bugs live in the relationship between two pieces that are individually correct: the order of two mounts, and the difference between "needs nothing" and "did not arrive". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
124 lines
5.5 KiB
JavaScript
124 lines
5.5 KiB
JavaScript
'use strict';
|
|
|
|
// What the worker does with the playlist message the player posts it — specifically the prune half.
|
|
//
|
|
// THE BUG: `pruneToPlaylist` deletes every content entry not in the keep-set, and the message
|
|
// handler ran it on an EMPTY keep-set. `assignments: []` is not a rare shape. buildPlaylistPayload()
|
|
// produces it for a device between playlists, for a playlist that has never been published, and —
|
|
// this is the one that hurts — inside `catch (e) { assignments = []; }` when a published_snapshot
|
|
// fails to JSON.parse. Any of those wiped every byte of media the panel had cached, which is only
|
|
// survivable while the uplink is up, i.e. exactly when the offline cache does not matter.
|
|
// Reproduced in a browser before the fix: three cached assets, one empty payload, cache emptied.
|
|
//
|
|
// Runs the SHIPPED worker (player/sw.js) against a fake Cache API, capturing the message listener
|
|
// it registers — the listener is the thing under test, so stubbing addEventListener away (as the
|
|
// prefetch tests do) would leave this path untested, which is how it shipped.
|
|
|
|
const { test } = 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 A = 'http://s/uploads/content/a.mp4?rev=1';
|
|
const B = 'http://s/uploads/content/b.png?rev=1';
|
|
const OLD = 'http://s/uploads/content/a.mp4?rev=0';
|
|
|
|
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)); }
|
|
}
|
|
|
|
function load() {
|
|
const content = new FakeCache();
|
|
const listeners = {};
|
|
const sandbox = {
|
|
caches: {
|
|
open: async () => content,
|
|
keys: async () => ['rd-content-v1'],
|
|
delete: async () => true,
|
|
match: async () => undefined,
|
|
},
|
|
// Any network use here would be a bug in the test, not the worker: prune touches no network.
|
|
fetch: async () => { throw new Error('prune must not fetch'); },
|
|
Response, Request, Blob, URL, console,
|
|
location: { href: 'http://s/player/index.html' },
|
|
navigator: {},
|
|
importScripts() {
|
|
delete require.cache[require.resolve(POLICY_PATH)];
|
|
sandbox.self.PlayerCachePolicy = require(POLICY_PATH);
|
|
},
|
|
addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
|
|
skipWaiting() {},
|
|
clients: { claim() {} },
|
|
};
|
|
sandbox.self = sandbox;
|
|
vm.createContext(sandbox);
|
|
vm.runInContext(SW_SRC, sandbox);
|
|
return { sandbox, content, post: (data) => listeners.message.forEach((fn) => fn({ data })) };
|
|
}
|
|
|
|
const seed = async (content, urls) => {
|
|
for (const u of urls) await content.put(u, new Response('x'));
|
|
};
|
|
const keys = (content) => [...content.map.keys()].sort();
|
|
// The worker chains prune onto its serialised prefetch queue, so give the microtasks a turn.
|
|
const settle = () => new Promise((r) => setTimeout(r, 20));
|
|
|
|
test('THE BUG: an empty playlist must NOT wipe the offline cache', async () => {
|
|
const { content, post } = load();
|
|
await seed(content, [A, B]);
|
|
post({ type: 'st-cache-playlist', urls: [], prune: true });
|
|
await settle();
|
|
assert.deepEqual(keys(content), [A, B].sort(),
|
|
'a payload with no assignments is indistinguishable from a payload that failed to build — it is not a delete instruction');
|
|
});
|
|
|
|
test('a real playlist still reclaims what it supersedes', async () => {
|
|
// The guard must not cost the feature it guards: a replace writes a NEW random filename, so the
|
|
// superseded copy lives at a different path and only the keep-set can find it.
|
|
const { content, post } = load();
|
|
await seed(content, [A, B, OLD]);
|
|
post({ type: 'st-cache-playlist', urls: [A], prune: true });
|
|
await settle();
|
|
assert.deepEqual(keys(content), [A], 'everything the display no longer needs is dropped');
|
|
});
|
|
|
|
test('an in-flight transfer\'s bookkeeping survives a prune of its own asset', async () => {
|
|
// The chunk keys are not in the keep-set (they carry __st_part), so deleting them on the URL test
|
|
// alone would restart that download on every 60s sweep — a resume that never completes.
|
|
const { content, post } = load();
|
|
const chunk = A + '&__st_part=0';
|
|
const meta = A + '&__st_part=meta';
|
|
await seed(content, [B, chunk, meta]);
|
|
post({ type: 'st-cache-playlist', urls: [A], prune: true });
|
|
await settle();
|
|
assert.deepEqual(keys(content), [chunk, meta].sort(), 'progress on a wanted asset is kept; B is not wanted');
|
|
});
|
|
|
|
test('prune:false never deletes, whatever the list says', async () => {
|
|
const { content, post } = load();
|
|
await seed(content, [A, B]);
|
|
post({ type: 'st-cache-playlist', urls: [A], prune: false });
|
|
await settle();
|
|
assert.deepEqual(keys(content), [A, B].sort());
|
|
});
|
|
|
|
test('a message that is not ours is ignored', async () => {
|
|
const { content, post } = load();
|
|
await seed(content, [A, B]);
|
|
for (const bad of [null, {}, { type: 'other', urls: [], prune: true },
|
|
{ type: 'st-cache-playlist', prune: true }, { type: 'st-cache-playlist', urls: 'all', prune: true }]) {
|
|
post(bad);
|
|
}
|
|
await settle();
|
|
assert.deepEqual(keys(content), [A, B].sort());
|
|
});
|