mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 06:16:20 -06:00
A BrightSign could see things the page cannot ask for — the uptime, the wired IP, the video mode in force, which volume it booted from, whether a staged package applied — and it printed all of it to a serial console. On a panel on a wall that is the same as reporting nothing. The cost was concrete and recent. A single bad string literal stopped the host script compiling; the only evidence anywhere was one line on a cable, and from the server the display looked identical to one that had never started. Diagnosing it needed someone physically present with a serial adapter. Every other player reports its own failures. Three hops, each thin: the host posts, the bridge carries, the player emits on the channels it already uses (device:log, device:event, and the telemetry the heartbeat has carried for releases). The pre-widget phase is the part that matters and the part that was hardest to reach — the storage probe, a pending package being applied, the video mode being set, all happen before there is a page to talk to. Those lines accumulate in a buffer and flush the moment the widget exists, so the boot story arrives even though it happened before anyone could listen. BrightScript has no global store here (no GetGlobalAA), so the buffer is threaded explicitly; losing the boot entirely was the worse option. Two things become incidents rather than console lines: the watchdog rebuilding a wedged widget, which is the most important thing a player does unattended and previously healed in silence — a panel rebuilding itself every two minutes looked exactly like a healthy one — and a load-error, which now names the resource that failed. Both use event types the server actually accepts; an invented one is dropped silently and would have been just as invisible. Host telemetry merges into the existing snapshot rather than opening a channel, and the host's numbers win where they overlap: navigator.storage.estimate() describes the widget's cache quota, not the disk, so a panel can report gigabytes free while the volume holding them is full. Two API traps caught in my own new code before it shipped, both the same shape as the ones being fixed: Str() applied to a value already documented as a String (it is for numbers, and would abort the event loop while reporting a diagnostic), and Stri() handed a float from an inline division. The checker now pins the first. Verified on the XT245: boots clean, plays, online. The bridge and player halves are served BY the server, so they take effect on the next deploy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
117 lines
6.7 KiB
JavaScript
117 lines
6.7 KiB
JavaScript
'use strict';
|
|
|
|
// A BrightSign knows things the page cannot ask for — the uptime, the wired IP, the video mode
|
|
// actually in force, which volume it booted from, whether a staged package applied — and all of it
|
|
// used to go to a serial console. On a panel on a wall that is the same as reporting nothing.
|
|
//
|
|
// The cost was concrete: a single bad string literal stopped the host script compiling, and the only
|
|
// evidence anywhere in the world was one line on a cable. From the server the display looked
|
|
// identical to one that had simply never started. Every other player reports its own failures.
|
|
//
|
|
// This pins the three-hop contract — host posts, bridge forwards, player emits — because no part of
|
|
// it can be executed here. The host half is BrightScript (no interpreter), the bridge half needs a
|
|
// widget, and a broken link in the chain is silent by construction: diagnostics that do not arrive
|
|
// look exactly like diagnostics that were never generated.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..', '..');
|
|
const host = fs.readFileSync(path.join(ROOT, 'brightsign', 'autorun.brs'), 'utf8');
|
|
const bridge = fs.readFileSync(path.join(ROOT, 'brightsign', 'st-bridge.js'), 'utf8');
|
|
const player = fs.readFileSync(path.join(ROOT, 'server', 'player', 'index.html'), 'utf8');
|
|
const code = host.split('\n').filter((l) => !/^\s*'/.test(l)).join('\n');
|
|
|
|
test('the host reports its boot story, which happens before there is a page to hear it', () => {
|
|
// The interesting failures all live in this window: the storage probe, a pending package being
|
|
// applied, the video mode being set. A design that could only report after the widget existed
|
|
// would miss every one of them.
|
|
assert.match(code, /Sub LogTo\(buf As Object/, 'a buffer the pre-widget phase can log into');
|
|
assert.match(code, /Sub FlushLog\(widget As Object, buf As Object\)/, 'and a flush once there is a page');
|
|
assert.match(code, /boot = CreateObject\("roArray"/, 'Main must create the buffer');
|
|
assert.match(code, /FlushLog\(widget, boot\)/, 'and flush it once the widget exists');
|
|
// The update path is the one that replaces the boot script — its diagnostics are the ones you
|
|
// most want when a player does not come back.
|
|
assert.match(code, /Sub ApplyPendingPackage\(root As String, buf As Object\)/);
|
|
assert.match(code, /LogTo\(buf, "update"/);
|
|
});
|
|
|
|
test('the host reports facts the page has no API for', () => {
|
|
const fn = code.slice(code.indexOf('Sub SendHostTelemetry'));
|
|
for (const [needle, why] of [
|
|
['UpTime(', 'a display that always reports a small uptime is reboot-looping'],
|
|
['roNetworkConfiguration', 'the wired IP — there is no JavaScript route to it'],
|
|
['GetVersion', 'the OS build, which decides which APIs exist at all'],
|
|
['StorageProbe()', 'the real volume, not the widget cache quota'],
|
|
['StorageRoot()', 'which volume it booted from'],
|
|
['PackageVersion()', 'what it is actually running'],
|
|
]) {
|
|
assert.ok(fn.slice(0, 2000).includes(needle), `host telemetry must include ${needle}: ${why}`);
|
|
}
|
|
});
|
|
|
|
test('a widget rebuild is reported as an incident, not just a console line', () => {
|
|
// The watchdog healing a wedged page is the single most important thing a BrightSign does
|
|
// unattended. Doing it silently made a panel rebuilding itself every two minutes look identical
|
|
// to one that was healthy.
|
|
assert.match(code, /HostEvent\(widget, "crash", "watchdog"/);
|
|
assert.match(code, /HostEvent\(widget, "app_error", "load-error"/);
|
|
});
|
|
|
|
test('the event types the host emits are ones the server actually accepts', () => {
|
|
// The server drops unknown event types silently, so an invented one would be exactly as
|
|
// invisible as the console.warn this replaces.
|
|
const allowed = fs.readFileSync(path.join(ROOT, 'server', 'lib', 'incident-classify.js'), 'utf8');
|
|
for (const m of code.matchAll(/HostEvent\([^,]+,\s*"([a-z_]+)"/g)) {
|
|
assert.ok(allowed.includes(`'${m[1]}'`), `the server does not accept event type "${m[1]}"`);
|
|
}
|
|
});
|
|
|
|
test('the bridge carries logs and events without interpreting them', () => {
|
|
assert.match(bridge, /onHostLog: function/);
|
|
assert.match(bridge, /onHostEvent: function/);
|
|
// Bounded before they reach the wire: the server truncates too, but a host bug should not be
|
|
// able to push a megabyte through the socket every second.
|
|
assert.match(bridge, /msg\.message \|\| ''\)\.slice\(0, 2000\)/);
|
|
assert.match(bridge, /msg\.detail \|\| ''\)\.slice\(0, 500\)/);
|
|
});
|
|
|
|
test('host telemetry merges into the snapshot the heartbeat already sends', () => {
|
|
// Not a new channel — the heartbeat has carried BS.telemetrySnapshot() for releases. The host
|
|
// simply fills in the fields only it can see.
|
|
assert.match(bridge, /msg\.type === 'host-telemetry'/);
|
|
assert.match(bridge, /telemetry\[keys\[i\]\] = v/);
|
|
assert.match(player, /BS\.telemetrySnapshot\(\) : \{\}/);
|
|
});
|
|
|
|
test('the host telemetry listener is registered at load, not behind the readiness gate', () => {
|
|
// The host sends its boot report the moment the widget exists. A listener attached after the
|
|
// bridge finished its own probe would miss precisely the message that says which volume the
|
|
// player came up from and whether a package applied.
|
|
const seg = bridge.slice(bridge.indexOf('var telemetry = {}'), bridge.indexOf('var telemetry = {}') + 1400);
|
|
assert.match(seg, /listeners\.push\(function \(msg\)/);
|
|
assert.ok(!/onReady\(function/.test(seg), 'must not wait on readiness to start listening');
|
|
});
|
|
|
|
test('the player forwards them, and only where the hooks exist', () => {
|
|
assert.match(player, /function wireHostDiagnostics\(\)/);
|
|
assert.match(player, /typeof BS\.onHostLog !== 'function'\) return;/, 'a browser must skip this entirely');
|
|
assert.match(player, /socket\.emit\('device:log'/);
|
|
assert.match(player, /BS\.onHostEvent\(\(ev\) => emitDeviceEvent\(ev\.event, ev\.reason, ev\.detail\)\)/);
|
|
});
|
|
|
|
test('forwarding is wired AFTER the socket, or the boot report is dropped rather than delayed', () => {
|
|
const connect = player.slice(player.indexOf('startVersionCheck();'), player.indexOf('startVersionCheck();') + 400);
|
|
assert.match(connect, /wireHostDiagnostics\(\)/);
|
|
});
|
|
|
|
test('diagnostics can never take the player down', () => {
|
|
// The whole point is a display that keeps playing while telling you it is unhappy. A reporting
|
|
// path that throws would invert that.
|
|
const fn = player.slice(player.indexOf('function wireHostDiagnostics'), player.indexOf('function emitDeviceEvent'));
|
|
assert.equal((fn.match(/try \{/g) || []).length >= 2, true, 'both the wiring and each callback must be guarded');
|
|
assert.match(fn, /catch \(e\) \{ \/\* diagnostics must never break playback/);
|
|
});
|