mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
fix(tizen): P0 audit fix pass — watchdog config-proofing, teardown hygiene, dead-screen self-heal, offline snapshot
Client-only, no server change. Implemented in verified clusters: - H1 (config-proof, no heartbeat-ack): derive the liveness window from the server-negotiated pingInterval (version-robust read) + arm the watchdog only after a real inbound signal, so it degrades safe against any server and a raised PING_INTERVAL can't false-fire it into a storm. - A5: monotonic clock (performance.now) for watchdog/resume deltas — NTP/RTC jumps can't false-fire or blind the watchdog. - H4 (leak was verified ABSENT): timer/teardown hygiene — tracked register-retry + teardownSession() on reset/BACK (stop heartbeat/stream/player-loop/pending-register); all start*() are stop-first. - A1: single-item playlist retries a broken item (was a permanent black screen while heartbeat green). - A6: reconnect randomizationFactor 0.5 (no fleet thundering-herd) + timeout 10s->20s (parity). - A2 (minimal): cache last renderable playlist-update to localStorage, replay on cold-start/offline; cleared on unpair/reset/auth-error. - B3: input hardening (non-array assignments/zones guarded, duration_sec numeric-coerced). - A3: log keep-awake API availability so Bold can VERIFY the flap fix on real hardware. #148 double-connect discipline re-proven after socket-touching changes.
This commit is contained in:
parent
dcd3a05a7e
commit
646eab743a
105
tizen/js/app.js
105
tizen/js/app.js
|
|
@ -32,7 +32,8 @@
|
|||
id: 'st_device_id',
|
||||
token: 'st_device_token',
|
||||
fp: 'st_fingerprint',
|
||||
code: 'st_pairing_code'
|
||||
code: 'st_pairing_code',
|
||||
payload: 'st_payload_cache' // A2: last renderable playlist-update, replayed on cold-start/offline
|
||||
};
|
||||
|
||||
// ---- persistent state ----
|
||||
|
|
@ -85,6 +86,13 @@
|
|||
try { if (window.webapis && webapis.appcommon) webapis.appcommon.setScreenSaver(webapis.appcommon.AppCommonScreenSaverState.SCREEN_SAVER_OFF); } catch (e) {}
|
||||
}
|
||||
|
||||
// A5 — MONOTONIC clock for lifecycle time deltas (watchdog silence, resume hidden-duration), so an
|
||||
// NTP/RTC wall-clock step on a 24/7 TV can't false-fire (forward jump) or blind (backward jump) the
|
||||
// watchdog. Date.now() is kept ONLY where a real wall clock is needed (telemetry, cross-device wall sync).
|
||||
var mono = (typeof performance !== 'undefined' && performance.now)
|
||||
? function () { return performance.now(); }
|
||||
: function () { return Date.now(); };
|
||||
|
||||
// FIX A — RE-ASSERT keep-awake on an interval. tizen.power.request / the screensaver-off
|
||||
// setting can be released when the TV backgrounds/suspends the app, and the player had no
|
||||
// way to re-suppress it (keepAwake was only called at boot/connect/command). ~30s is well
|
||||
|
|
@ -124,9 +132,9 @@
|
|||
return (hiddenMs >= SUSPEND_HIDE_MS) ? 'reconnect' : 'noop';
|
||||
}
|
||||
function onVisibility() {
|
||||
if (document.visibilityState === 'hidden' || document.hidden) { hiddenAtMs = Date.now(); return; }
|
||||
if (document.visibilityState === 'hidden' || document.hidden) { hiddenAtMs = mono(); return; } // A5: monotonic
|
||||
keepAwake(); // re-assert immediately on resume
|
||||
var hiddenMs = hiddenAtMs ? (Date.now() - hiddenAtMs) : 0;
|
||||
var hiddenMs = hiddenAtMs ? (mono() - hiddenAtMs) : 0; // A5: monotonic hidden-duration
|
||||
hiddenAtMs = 0;
|
||||
var action = resumeDecision(!!socket, !!(socket && socket.connected), hiddenMs);
|
||||
if (action === 'reconnect') connect(); // teardown-before-reopen -> exactly one socket; #118 registers once
|
||||
|
|
@ -150,24 +158,34 @@
|
|||
// the watchdog and the resume fast-path can't double-fire a second reconnect. Client-only: uses
|
||||
// signals the server already sends; no server change.
|
||||
var lastServerMsgAt = 0;
|
||||
var LIVENESS_TIMEOUT_MS = 35000; // ~2+ missed 15s server pings; below engine.io's own ~45s close
|
||||
var livenessConfirmed = false; // H1: DON'T arm until the server has actually talked to us
|
||||
var DEFAULT_LIVENESS_MS = 35000;
|
||||
var livenessWindowMs = DEFAULT_LIVENESS_MS; // H1: derived from the negotiated engine pingInterval per connect
|
||||
var watchdogTimer = null;
|
||||
function markAlive() { lastServerMsgAt = Date.now(); } // central receive-path hook (see connect())
|
||||
// Pure, unit-testable: reconnect only for a connected+authenticated socket gone silent past the window.
|
||||
function watchdogShouldReconnect(hasSocket, connected, authed, silentMs) {
|
||||
return !!(hasSocket && connected && authed && silentMs > LIVENESS_TIMEOUT_MS);
|
||||
function markAlive() { lastServerMsgAt = mono(); livenessConfirmed = true; } // central receive-path hook; A5 monotonic
|
||||
// H1 (config-proof): adapt the silence window to whatever pingInterval the SERVER negotiated, so a larger
|
||||
// server pingInterval can't make the client false-fire into a reconnect storm. 2 intervals + 5s margin,
|
||||
// floored at the 35s default (which is 2×15s+5s). Called from the 'connect' handler once the handshake is known.
|
||||
function setLivenessWindowFromPing(pingIntervalMs) {
|
||||
if (pingIntervalMs && pingIntervalMs > 0) livenessWindowMs = Math.max(DEFAULT_LIVENESS_MS, 2 * pingIntervalMs + 5000);
|
||||
}
|
||||
// Pure, unit-testable. Reconnect ONLY when a connected+authenticated socket whose liveness we have CONFIRMED
|
||||
// (seen >=1 real inbound signal — H1 degrade-safe: a server that never talks never arms the watchdog, so no
|
||||
// storm) has gone silent past the server-derived window.
|
||||
function watchdogShouldReconnect(hasSocket, connected, authed, confirmed, silentMs, windowMs) {
|
||||
return !!(hasSocket && connected && authed && confirmed && silentMs > windowMs);
|
||||
}
|
||||
function startWatchdog() {
|
||||
stopWatchdog();
|
||||
watchdogTimer = setInterval(function () {
|
||||
var silentMs = lastServerMsgAt ? (Date.now() - lastServerMsgAt) : 0;
|
||||
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, silentMs)) {
|
||||
var silentMs = lastServerMsgAt ? (mono() - lastServerMsgAt) : 0; // A5 monotonic
|
||||
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, livenessConfirmed, silentMs, livenessWindowMs)) {
|
||||
connect(); // half-open backstop: teardown-first -> one socket, #118 re-registers once
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } }
|
||||
if (typeof window !== 'undefined') window.__stWatchdogShouldReconnect = watchdogShouldReconnect; // test hook (inert in prod)
|
||||
if (typeof window !== 'undefined') { window.__stWatchdogShouldReconnect = watchdogShouldReconnect; window.__stSetLivenessWindowFromPing = setLivenessWindowFromPing; }
|
||||
|
||||
// ---- networking ----
|
||||
var socket = null;
|
||||
|
|
@ -209,6 +227,7 @@
|
|||
if (!serverUrl) { show(elSetup); return; }
|
||||
keepAwake();
|
||||
if (socket) { try { socket.disconnect(); } catch (e) {} socket = null; }
|
||||
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; } // H4: a fresh connect supersedes any pending re-register
|
||||
|
||||
var base = serverUrl.replace(/\/+$/, '');
|
||||
socket = io(base + '/device', {
|
||||
|
|
@ -216,7 +235,8 @@
|
|||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 10000
|
||||
randomizationFactor: 0.5, // A6: ±50% jitter so a fleet of TVs doesn't reconnect in lockstep (thundering herd) after a server restart — matches the APK
|
||||
timeout: 20000 // cheap parity (GAP4c): match /player + APK; 10s prematurely errored slow TV WebKit / WS-blocked networks
|
||||
});
|
||||
|
||||
// FIX B (hardened): central receive-path liveness. A fresh socket is assumed alive; then EVERY
|
||||
|
|
@ -224,11 +244,21 @@
|
|||
// (~15s) via the manager 'ping'. This resets liveness so the watchdog / resume fast-path can't
|
||||
// double-fire, and feeds the watchdog's server-silence detection. (io() returns a fresh socket
|
||||
// per connect — verified — so these listeners don't accumulate.)
|
||||
lastServerMsgAt = Date.now();
|
||||
lastServerMsgAt = mono(); // A5 monotonic
|
||||
livenessConfirmed = false; // H1: arm the watchdog only after a real inbound signal
|
||||
livenessWindowMs = DEFAULT_LIVENESS_MS; // reset; refined from the handshake on 'connect'
|
||||
socket.onAny(markAlive);
|
||||
socket.io.on('ping', markAlive);
|
||||
|
||||
socket.on('connect', function () {
|
||||
// H1: derive the liveness window from the pingInterval the SERVER negotiated at the handshake,
|
||||
// so raising server PING_INTERVAL can't make the watchdog false-fire (config-proof). The engine
|
||||
// exposes it as `pingInterval` (socket.io-client 4.7.x, the bundled .wgt client) or `_pingInterval`
|
||||
// (4.8.x); read both, and fall back to the 35s default if neither is present.
|
||||
try {
|
||||
var eng = socket.io && socket.io.engine;
|
||||
setLivenessWindowFromPing(eng && (eng.pingInterval || eng._pingInterval));
|
||||
} catch (e) {}
|
||||
// #118: a brand-new socket is not authenticated until device:registered. Reset the
|
||||
// flag and kill any heartbeat carried over from the previous socket, so a beat can't
|
||||
// fire on this fresh, unregistered connection (TV sleep/wake reconnects often).
|
||||
|
|
@ -270,12 +300,12 @@
|
|||
});
|
||||
|
||||
socket.on('device:unpaired', function () {
|
||||
del(LS.id); del(LS.token); del(LS.code);
|
||||
del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
|
||||
deviceId = null; deviceToken = null;
|
||||
// FIX F — back off 3s before re-registering, symmetric with the auth-error path below,
|
||||
// so a repeatedly-unpaired device (e.g. MDM re-pair churn) can't tight-loop
|
||||
// register -> unpaired -> register.
|
||||
setTimeout(register, 3000);
|
||||
scheduleRegister(3000);
|
||||
});
|
||||
|
||||
socket.on('device:auth-error', function (data) {
|
||||
|
|
@ -286,9 +316,9 @@
|
|||
stopHeartbeat();
|
||||
toast((data && data.error) ? data.error : 'Auth error', false);
|
||||
// Bad/stale token or fingerprint-reclaim block: drop creds and re-pair.
|
||||
del(LS.id); del(LS.token);
|
||||
del(LS.id); del(LS.token); del(LS.payload); // A2: clear cached content when identity is lost
|
||||
deviceId = null; deviceToken = null;
|
||||
setTimeout(register, 3000);
|
||||
scheduleRegister(3000);
|
||||
});
|
||||
|
||||
socket.on('device:playlist-update', onPlaylist);
|
||||
|
|
@ -419,6 +449,12 @@
|
|||
? STDeviceControl.capabilities() : { backend: 'none', reboot: false, panel: false };
|
||||
reportCmd('info', 'capabilities',
|
||||
'fleet control backend=' + caps.backend + ' reboot=' + caps.reboot + ' panel=' + caps.panel);
|
||||
// A3 observability: the keep-awake fix only actually holds the screen if these APIs resolve on the
|
||||
// TV's firmware/signing path. Surface their presence to the dashboard log so Bold can VERIFY on real
|
||||
// hardware whether keep-awake is real (vs a silent no-op) — the load-bearing check for the flap fix.
|
||||
var ka = 'keep-awake: setScreenSaver=' + !!(window.webapis && webapis.appcommon)
|
||||
+ ' tizen.power=' + !!(window.tizen && tizen.power);
|
||||
reportCmd('info', 'keepawake', ka);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
|
|
@ -460,6 +496,25 @@
|
|||
function startStreaming() { stopStreaming(); streamTimer = setInterval(captureAndSend, 1000); }
|
||||
function stopStreaming() { if (streamTimer) { clearInterval(streamTimer); streamTimer = null; } }
|
||||
|
||||
// H4 (teardown hygiene): TRACK the register re-try so a reset/reconnect can cancel a pending late
|
||||
// register (Lens 2 found it untracked -> a stray register could fire on a fresh socket).
|
||||
var registerTimer = null;
|
||||
function scheduleRegister(delay) {
|
||||
if (registerTimer) clearTimeout(registerTimer);
|
||||
registerTimer = setTimeout(function () { registerTimer = null; register(); }, delay);
|
||||
}
|
||||
// H4: stop the per-SESSION timers/loops when leaving playback (reset / BACK-to-setup). Otherwise the
|
||||
// player loop keeps firing on the hidden stage and throws (serverUrl=null), heartbeat/stream keep
|
||||
// running, and a pending register can fire late. Keep-awake + the watchdog are LIFETIME timers
|
||||
// (guarded no-ops while off-session) and are intentionally left running. Idempotent.
|
||||
function teardownSession() {
|
||||
stopHeartbeat();
|
||||
stopStreaming();
|
||||
try { player.stop(); } catch (e) {}
|
||||
if (registerTimer) { clearTimeout(registerTimer); registerTimer = null; }
|
||||
authenticated = false;
|
||||
}
|
||||
|
||||
// ---- playback ----
|
||||
var player = new PlaylistPlayer(elStage, function () { return serverUrl.replace(/\/+$/, ''); });
|
||||
// Multi-zone layout renderer (matches the Android player). app.js picks the renderer
|
||||
|
|
@ -517,6 +572,9 @@
|
|||
show(elStage);
|
||||
return;
|
||||
}
|
||||
// A2: cache the last RENDERABLE payload so a reboot / WS-outage with no connectivity replays it
|
||||
// instead of showing the idle card. Only non-suspended payloads are cached.
|
||||
try { set(LS.payload, JSON.stringify(payload)); } catch (e) {}
|
||||
// If we have content + we're paired, make sure we're on the stage.
|
||||
if (elPairing.classList.contains('hidden') === false) show(elStage);
|
||||
else if (elStage.classList.contains('hidden')) show(elStage);
|
||||
|
|
@ -534,7 +592,7 @@
|
|||
wallController.exit(); // leave wall mode if we were in it
|
||||
applyOrientation(payload.orientation || 'landscape');
|
||||
var layout = payload.layout;
|
||||
if (layout && layout.zones && layout.zones.length) {
|
||||
if (layout && Array.isArray(layout.zones) && layout.zones.length) { // B3: non-array zones would throw in zoneRenderer
|
||||
// Multi-zone layout (matches the Android player). Leave single-zone mode first.
|
||||
player.stop();
|
||||
zoneRenderer.setTimezone(payload.timezone || null); // #74/#75: effective tz
|
||||
|
|
@ -563,9 +621,10 @@
|
|||
connect();
|
||||
}
|
||||
elReset.addEventListener('click', function () {
|
||||
del(LS.url); del(LS.id); del(LS.token); del(LS.code);
|
||||
del(LS.url); del(LS.id); del(LS.token); del(LS.code); del(LS.payload);
|
||||
deviceId = null; deviceToken = null; serverUrl = null;
|
||||
if (socket) { try { socket.disconnect(); } catch (e) {} }
|
||||
teardownSession(); // H4: stop heartbeat/stream/player-loop + pending register (no dangling timers on setup)
|
||||
show(elSetup);
|
||||
});
|
||||
|
||||
|
|
@ -578,6 +637,7 @@
|
|||
try { tizen.application.getCurrentApplication().exit(); } catch (x) {}
|
||||
} else {
|
||||
if (socket) { try { socket.disconnect(); } catch (x) {} }
|
||||
teardownSession(); // H4: same clean teardown when BACK returns to setup
|
||||
elUrl.value = serverUrl || '';
|
||||
elSetupStatus.textContent = ''; elSetupStatus.className = 'status';
|
||||
show(elSetup); elUrl.focus();
|
||||
|
|
@ -593,7 +653,12 @@
|
|||
document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path
|
||||
startWatchdog(); // FIX B (hardened): server-silence liveness backstop
|
||||
if (serverUrl && deviceId && deviceToken) {
|
||||
show(elStage); connect(); // paired — reconnect to playback
|
||||
// A2: render cached content IMMEDIATELY so a cold-start/offline TV isn't blank while the socket
|
||||
// connects (or if it can't). The socket's fresh device:playlist-update replaces it on connect.
|
||||
show(elStage);
|
||||
var _cp = get(LS.payload);
|
||||
if (_cp) { try { onPlaylist(JSON.parse(_cp)); } catch (e) {} }
|
||||
connect(); // paired — reconnect to playback
|
||||
} else if (serverUrl) {
|
||||
show(elSetup); elUrl.value = serverUrl; // server known, not paired — confirm + connect
|
||||
elSetupStatus.className = 'status';
|
||||
|
|
|
|||
|
|
@ -34,7 +34,9 @@ function PlaylistPlayer(stageEl, getBase) {
|
|||
}
|
||||
|
||||
PlaylistPlayer.prototype.load = function (assignments) {
|
||||
var items = (assignments || []).filter(function (a) {
|
||||
// B3: a malformed device:playlist-update with a non-array `assignments` used to throw
|
||||
// (.filter is not a function) out of the socket handler; coerce to [] instead.
|
||||
var items = (Array.isArray(assignments) ? assignments : []).filter(function (a) {
|
||||
return a && (a.content_id || a.widget_id || a.remote_url);
|
||||
});
|
||||
// Stable order
|
||||
|
|
@ -72,7 +74,10 @@ PlaylistPlayer.prototype.idle = function () {
|
|||
};
|
||||
|
||||
PlaylistPlayer.prototype.durationMs = function (item) {
|
||||
var d = item.duration_sec || this.DEFAULT_DURATION;
|
||||
// B3: a non-numeric duration_sec ("abc") used to yield NaN -> schedule(NaN) -> fire-ASAP spin.
|
||||
// Coerce; any non-positive/NaN falls back to the default.
|
||||
var d = Number(item.duration_sec);
|
||||
if (!(d > 0)) d = this.DEFAULT_DURATION;
|
||||
if (d < this.MIN_DURATION) d = this.MIN_DURATION;
|
||||
return d * 1000;
|
||||
};
|
||||
|
|
@ -203,7 +208,14 @@ PlaylistPlayer.prototype.playCurrent = function () {
|
|||
|
||||
// Give a broken item ~2s then move on so the loop never wedges.
|
||||
PlaylistPlayer.prototype.skipSoon = function () {
|
||||
if (this.items.length > 1) this.schedule(2000);
|
||||
if (this.items.length > 1) { this.schedule(2000); return; }
|
||||
// A1: a SINGLE-item playlist used to WEDGE on a broken item — skipSoon did nothing, so a transient
|
||||
// failure (CDN blip, brief network loss, a 404 that later resolves) left a permanent black screen
|
||||
// while the heartbeat still reported the device online. Retry the SAME item after a backoff so it
|
||||
// self-heals instead of going dark forever.
|
||||
var self = this;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(function () { self.playCurrent(); }, 5000);
|
||||
};
|
||||
|
||||
PlaylistPlayer.prototype.fit = function (el, item) {
|
||||
|
|
@ -239,7 +251,7 @@ PlaylistPlayer.prototype.renderVideo = function (item, single) {
|
|||
// Safety net: if 'ended' never fires (rare), advance after the known
|
||||
// content duration (or the assignment duration) + a buffer.
|
||||
if (!single) {
|
||||
var secs = item.content_duration || item.duration_sec || this.DEFAULT_DURATION;
|
||||
var secs = Number(item.content_duration || item.duration_sec) || this.DEFAULT_DURATION; // B3: numeric
|
||||
this.schedule((secs + 5) * 1000);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue