Stop claiming offline cache on a runtime that refuses to run a service worker

Found on alpha after deploying rc4, by comparing what a device advertised
against what it actually requested.

A real BrightSign XT245 has navigator.serviceWorker, passes an
`'serviceWorker' in navigator` check, and then never even fetches sw.js — its
widget runtime refuses the registration. It was declaring offline.cache to the
fleet while unable to cache a single byte, which is precisely the lie the
capability model exists to prevent. The claim is now made on a worker that is
actually IN CONTROL, and a refused registration sets a flag so the negative
sticks on a runtime where it will never succeed.

That failure previously went to console.warn, on a display nobody has a console
for, so a panel that could cache nothing looked identical to one that could. It
now reports app_error/sw_unavailable — as an allow-listed event type, since an
unknown one is dropped by the server and would have been just as invisible.

The cost is that the first load under-reports, before the worker claims the
page. That is the right direction to be wrong in, and it self-corrects: the next
register sends the true set.

Also corrects docs/player-parity.md, which claimed BrightSign simply inherits
the web player's service worker. The failing unit runs BSN's Supervisor rather
than our brightsign/autorun.brs, and Supervisor's widget has no storage_path —
the setting our own host script does configure and the precondition for a widget
having persistent storage. So this is likely a widget config issue rather than a
platform limit, but it is UNVERIFIED on hardware and the doc now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
ScreenTinker 2026-08-05 19:34:52 -05:00
parent ba45c2d60c
commit 0a888910dc
4 changed files with 67 additions and 10 deletions

View file

@ -75,7 +75,7 @@ privilege model exists on those platforms — so the column is collapsed.
|---|---|---|---|---| |---|---|---|---|---|
| `sync.clock` | ✅ | ✅ | ✅ | ✅ | | `sync.clock` | ✅ | ✅ | ✅ | ✅ |
| `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ SyncManager, BOS 8.2.10+; multicast so all members must share one L2 network | | `sync.native` | ❌ no native protocol | ❌ | ❌ | ⚠️ SyncManager, BOS 8.2.10+; multicast so all members must share one L2 network |
| `offline.cache` | ✅ content downloaded to disk, **resumable** (Range + If-Range), revision-keyed | ✅ service worker, **resumable chunked prefetch**, revision-keyed | ✅ **media cached to `wgt-private`** (`js/media-cache.js`), resumable, revision-keyed — declared at runtime, since a build with no writable private storage must not claim it | ✅ inherits the web player's service worker | | `offline.cache` | ✅ content downloaded to disk, **resumable** (Range + If-Range), revision-keyed | ✅ service worker, **resumable chunked prefetch**, revision-keyed | ✅ **media cached to `wgt-private`** (`js/media-cache.js`), resumable, revision-keyed — declared at runtime | ⚠️ **depends on the host widget's storage config — see below** |
--- ---
@ -91,6 +91,20 @@ Ordered by how visible the failure is to an operator.
across attempts instead of restarting from zero, and revision-keyed, so a replaced asset is across attempts instead of restarting from zero, and revision-keyed, so a replaced asset is
still a miss. The capability is declared at runtime rather than assumed: a build that cannot still a miss. The capability is declared at runtime rather than assumed: a build that cannot
write to private storage keeps quiet about it. write to private storage keeps quiet about it.
3. **BrightSign offline caching is NOT automatic — it depends on who created the widget.** A real
XT245 on alpha exposes `navigator.serviceWorker`, and then never even fetches `sw.js`:
registration is refused, so there is no worker, no content cache and no offline playback. That
unit is running **BSN's Supervisor** (`autorun.createdby = Supervisor 2.1.18.3`) rather than our
`brightsign/autorun.brs`, and Supervisor's widget has no `storage_path` — the setting our own
host script does set (`storage_path: "/cache"`, `storage_quota: "1073741824"`), and the
precondition for a widget having persistent storage at all. So this is very likely a widget
CONFIG issue rather than a platform limit, but **it is unverified on hardware**: nobody has yet
watched a player running our package register a worker.
The player no longer lies about it either way — `offline.cache` is declared only when a worker
is genuinely in control, and a refused registration reports `app_error/sw_unavailable` to the
server instead of a `console.warn` on a display nobody has a console for.
3. **BrightSign `remote.screenshot` needs primary storage.** Reachable today only via the canvas 3. **BrightSign `remote.screenshot` needs primary storage.** Reachable today only via the canvas
fallback, which cannot read the video plane, so screenshots show everything except the video. fallback, which cannot read the video plane, so screenshots show everything except the video.
Resolves itself when a card or SSD is fitted. Resolves itself when a card or SSD is fitted.

View file

@ -497,6 +497,10 @@
// can't see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false. // can't see SSID/RSSI, so we send only offline_ms + link_lost + cold_start:false.
let disconnectedAt = 0; // Date.now() at the first disconnect of the current gap (0 = not in a gap) let disconnectedAt = 0; // Date.now() at the first disconnect of the current gap (0 = not in a gap)
let linkLostDuringGap = false; // navigator went offline at any point during the gap let linkLostDuringGap = false; // navigator went offline at any point during the gap
// Set when register() is REFUSED (not merely pending). A runtime can expose navigator
// .serviceWorker and still decline to run one — a real BrightSign widget does exactly that —
// and the display must stop claiming an offline capability it cannot honour.
let swRegistrationFailed = false;
// feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row. // feat/offline-cause-log: typed incident feed (device:event) — server inserts a device_events row.
// Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit). // Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit).
function emitDeviceEvent(type, reason, detail) { function emitDeviceEvent(type, reason, detail) {
@ -1723,11 +1727,21 @@
} }
} catch (e) { /* bundle absent: hard cuts, and we do not claim the capability */ } } catch (e) { /* bundle absent: hard cuts, and we do not claim the capability */ }
// Offline caching is the service worker. Reported on support rather than on an active // Offline caching is the service worker — and the API EXISTING is not the same as it
// controller: the first load registers it and has no controller yet, and a display that // working. A real BrightSign XT245 on alpha has `serviceWorker` in navigator, passes this
// re-registers on every boot would otherwise flap this capability on and off. // check, and then never even fetches sw.js: registration is refused by its widget runtime.
// It declared offline.cache to the fleet and could not cache a single byte.
//
// So the claim is made on a worker that is actually IN CONTROL. The cost is that the very
// first load under-reports (registration has happened but the worker has not claimed the
// page yet) — which is the right direction to be wrong in, and self-corrects: activation
// triggers a reload, and the next register sends the true set. `swRegistrationFailed` makes
// the negative stick on a runtime where it will never succeed, rather than waiting on a
// controller that is never coming.
try { try {
if ('serviceWorker' in navigator) caps.push('offline.cache'); if (!swRegistrationFailed && navigator.serviceWorker && navigator.serviceWorker.controller) {
caps.push('offline.cache');
}
} catch (e) { /* locked-down browser */ } } catch (e) { /* locked-down browser */ }
// Screenshots need somewhere to draw. Same-origin content and a 2d context are the real // Screenshots need somewhere to draw. Same-origin content and a 2d context are the real
@ -4071,7 +4085,20 @@
}); });
} }
}); });
}, (err) => console.warn('SW registration failed:', err)); }, (err) => {
// A registration that fails has to be VISIBLE. This one went to console.warn on a display
// nobody has a console for, so a panel that could not cache anything looked identical to
// one that could — for as long as nobody thought to compare nginx logs against the
// capability it was advertising.
swRegistrationFailed = true;
console.warn('SW registration failed:', err);
try {
// 'app_error' rather than a new type: the server allow-lists event types, and a type it
// does not know is dropped silently — which would have made this report as invisible as
// the console.warn it replaces.
emitDeviceEvent('app_error', 'sw_unavailable', String((err && err.message) || err).slice(0, 200));
} catch (e) { /* reporting must never break the player */ }
});
} }
// ==================== Keyboard shortcuts ==================== // ==================== Keyboard shortcuts ====================

View file

@ -1,3 +1,6 @@
// v23: offline.cache is claimed only when a worker is actually IN CONTROL — a real BrightSign
// widget exposes navigator.serviceWorker, refuses to register one, and was advertising the
// capability to the fleet regardless.
// v22: worker scope widened to '/' (it never controlled /player before) + prune-to-playlist, so a // v22: worker scope widened to '/' (it never controlled /player before) + prune-to-playlist, so a
// replaced asset's superseded copy is reclaimed rather than waiting on the quota. // replaced asset's superseded copy is reclaimed rather than waiting on the quota.
// v21: chunked resumable content prefetch + revision-keyed media URLs — index.html gained // v21: chunked resumable content prefetch + revision-keyed media URLs — index.html gained
@ -8,7 +11,7 @@
// — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat. // — a player then ran a new index.html against a stale st-bridge.js and threw on every heartbeat.
// Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this // Bump whenever a shipped /player asset changes shape; content lives in its own cache, so this
// costs a small re-download and never re-fetches the playlist. // costs a small re-download and never re-fetches the playlist.
const CACHE_NAME = 'rd-player-v22'; const CACHE_NAME = 'rd-player-v23';
// Content lives in its own cache so the shell can be re-versioned (the activate handler deletes // Content lives in its own cache so the shell can be re-versioned (the activate handler deletes
// every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still // every cache that is not CACHE_NAME) WITHOUT throwing away megabytes of media that are still
// perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist. // perfectly valid. Rolling the shell used to mean a player re-downloaded its entire playlist.

View file

@ -21,7 +21,7 @@ const { CAP_SET } = require('../lib/player-capabilities');
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
/** Pull declaredCapabilities() out of the player and run it in a controlled world. */ /** Pull declaredCapabilities() out of the player and run it in a controlled world. */
function declare({ host = false, sync = false, transitions = false, canvas = true, sw = true } = {}) { function declare({ host = false, sync = false, transitions = false, canvas = true, sw = true, swRefused = false } = {}) {
const start = HTML.indexOf(' function declaredCapabilities() {'); const start = HTML.indexOf(' function declaredCapabilities() {');
assert.notEqual(start, -1, 'declaredCapabilities() must exist in the player'); assert.notEqual(start, -1, 'declaredCapabilities() must exist in the player');
let depth = 0, end = -1; let depth = 0, end = -1;
@ -33,7 +33,9 @@ function declare({ host = false, sync = false, transitions = false, canvas = tru
const sandbox = { const sandbox = {
console: { log() {}, warn() {} }, console: { log() {}, warn() {} },
navigator: sw ? { serviceWorker: {} } : {}, // `sw` now means "a worker is IN CONTROL", not merely "the API exists" — those are different
// things, and a real BrightSign widget is the difference (see the test below).
navigator: sw ? { serviceWorker: { controller: {} } } : (swRefused ? { serviceWorker: {} } : {}),
document: { document: {
createElement: () => (canvas createElement: () => (canvas
? { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toDataURL: () => 'data:,' } ? { width: 0, height: 0, getContext: () => ({ drawImage() {} }), toDataURL: () => 'data:,' }
@ -41,6 +43,7 @@ function declare({ host = false, sync = false, transitions = false, canvas = tru
}, },
BS: host ? { hasHost: () => true } : null, BS: host ? { hasHost: () => true } : null,
}; };
sandbox.swRegistrationFailed = swRefused;
sandbox.window = sandbox; sandbox.window = sandbox;
if (sync) sandbox.ScreenTinkerBSSync = { available: () => true }; if (sync) sandbox.ScreenTinkerBSSync = { available: () => true };
// The real runtime globals the player's own transitionRuntimeReady() checks. An earlier draft // The real runtime globals the player's own transitionRuntimeReady() checks. An earlier draft
@ -101,11 +104,21 @@ test('screenshots are claimed only when there is something to draw on', () => {
assert.ok(!declare({ canvas: false }).includes('remote.screenshot')); assert.ok(!declare({ canvas: false }).includes('remote.screenshot'));
}); });
test('offline cache follows service-worker support', () => { test('offline cache follows a worker that is actually IN CONTROL', () => {
assert.ok(declare({ sw: true }).includes('offline.cache')); assert.ok(declare({ sw: true }).includes('offline.cache'));
assert.ok(!declare({ sw: false }).includes('offline.cache')); assert.ok(!declare({ sw: false }).includes('offline.cache'));
}); });
test('THE BRIGHTSIGN CASE: the API exists, no worker controls the page, so no claim', () => {
// Found on real hardware. A BrightSign XT245 on alpha has navigator.serviceWorker, passes an
// `'serviceWorker' in navigator` check, and then never even fetches sw.js — its widget runtime
// refuses to register one. It was advertising offline.cache to the fleet while being unable to
// cache a single byte, which is precisely the lie this whole capability model exists to stop.
const caps = declare({ swRefused: true });
assert.ok(!caps.includes('offline.cache'), 'a runtime that will not run a worker must not claim to cache');
assert.ok(caps.includes('playback.video'), 'and it must still declare what it genuinely can do');
});
test('transitions are declared only when the bundle actually loaded', () => { test('transitions are declared only when the bundle actually loaded', () => {
// It is a progressive enhancement — a failed load means hard cuts, not a broken player. // It is a progressive enhancement — a failed load means hard cuts, not a broken player.
assert.ok(!declare({ transitions: false }).includes('playback.transitions')); assert.ok(!declare({ transitions: false }).includes('playback.transitions'));