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.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
still a miss. The capability is declared at runtime rather than assumed: a build that cannot
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
fallback, which cannot read the video plane, so screenshots show everything except the video.
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.
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
// 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.
// Best-effort + auth-guarded (the reconnected socket is authenticated by the time we emit).
function emitDeviceEvent(type, reason, detail) {
@ -1723,11 +1727,21 @@
}
} 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
// controller: the first load registers it and has no controller yet, and a display that
// re-registers on every boot would otherwise flap this capability on and off.
// Offline caching is the service worker — and the API EXISTING is not the same as it
// working. A real BrightSign XT245 on alpha has `serviceWorker` in navigator, passes this
// 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 {
if ('serviceWorker' in navigator) caps.push('offline.cache');
if (!swRegistrationFailed && navigator.serviceWorker && navigator.serviceWorker.controller) {
caps.push('offline.cache');
}
} catch (e) { /* locked-down browser */ }
// 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 ====================

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
// 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
@ -8,7 +11,7 @@
// — 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
// 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
// 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.

View file

@ -21,7 +21,7 @@ const { CAP_SET } = require('../lib/player-capabilities');
const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8');
/** 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() {');
assert.notEqual(start, -1, 'declaredCapabilities() must exist in the player');
let depth = 0, end = -1;
@ -33,7 +33,9 @@ function declare({ host = false, sync = false, transitions = false, canvas = tru
const sandbox = {
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: {
createElement: () => (canvas
? { 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,
};
sandbox.swRegistrationFailed = swRefused;
sandbox.window = sandbox;
if (sync) sandbox.ScreenTinkerBSSync = { available: () => true };
// 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'));
});
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: 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', () => {
// It is a progressive enhancement — a failed load means hard cuts, not a broken player.
assert.ok(!declare({ transitions: false }).includes('playback.transitions'));