feat(tizen): harden FIX B with an application-level liveness watchdog

Replace the resume-only hide-duration heuristic as the AUTHORITATIVE half-open detector with a
real server-silence watchdog, so the .wgt self-heals a dead-but-connected socket from ANY cause
(network drop, NAT idle timeout, transport death while foregrounded), not just resume.

- Central receive-path liveness: markAlive() refreshes lastServerMsgAt on EVERY inbound server
  message — app events via socket.onAny, and the server's ~15s engine ping via socket.io 'ping'
  (both client-only signals the server already sends; no server change; heartbeats get no ack).
- Watchdog (10s cadence): if socket.connected && authenticated && silent > 35s (2+ missed pings,
  under engine.io's own ~45s close), treat as half-open and reconnect via the teardown-first
  connect() -> exactly one socket, #118 re-registers once.
- #148 discipline: fires ONLY while socket.connected===true (the state socket.io can't see), so
  it never races socket.io's own down-socket auto-reconnect; connect() resets liveness so the
  watchdog and the resume fast-path can't double-fire. Resume path kept as the fast suspend path.
- Timers cleared on exit.

Client-only; keep-awake+lifecycle remain the leading flap candidate, NOT a confirmed cause.
This commit is contained in:
ScreenTinker 2026-07-07 14:57:50 -05:00
parent 78c71e00ab
commit dcd3a05a7e

View file

@ -134,6 +134,41 @@
}
if (typeof window !== 'undefined') window.__stResumeDecision = resumeDecision; // test hook (inert in prod)
// FIX B (hardened) — application-level LIVENESS WATCHDOG. The resume path above only fires on
// visibilitychange, so a socket that goes half-open with NO visibility event (network drop, NAT
// idle timeout, transport death while foregrounded) would never be caught: socket.connected stays
// true on a dead socket and socket.io won't reconnect. The watchdog watches for server SILENCE.
// The server sends an engine ping every ~15s (config.pingInterval) AND app events, so a healthy
// socket refreshes lastServerMsgAt at least every ~15s (markAlive is wired into a central receive
// path in connect(): socket.onAny + socket.io 'ping'). If the socket goes quiet past the liveness
// window while we still believe we're connected + authenticated, it is half-open -> clean
// teardown-before-reopen via connect() (exactly one socket; #118 re-registers once).
//
// Double-connect discipline: the watchdog fires ONLY while socket.connected===true (the half-open
// state socket.io cannot see) — socket.io's own auto-reconnect only runs when socket.connected is
// false, so the two never overlap. connect() is teardown-first, and it resets lastServerMsgAt, so
// 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 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 startWatchdog() {
stopWatchdog();
watchdogTimer = setInterval(function () {
var silentMs = lastServerMsgAt ? (Date.now() - lastServerMsgAt) : 0;
if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), authenticated, silentMs)) {
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)
// ---- networking ----
var socket = null;
var deviceId = get(LS.id);
@ -184,6 +219,15 @@
timeout: 10000
});
// FIX B (hardened): central receive-path liveness. A fresh socket is assumed alive; then EVERY
// inbound server message refreshes lastServerMsgAt — app events via onAny, and the engine ping
// (~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();
socket.onAny(markAlive);
socket.io.on('ping', markAlive);
socket.on('connect', function () {
// #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
@ -530,7 +574,7 @@
document.addEventListener('keydown', function (e) {
if (e.keyCode === 10009) { // Samsung RETURN / BACK
if (!elSetup.classList.contains('hidden')) {
stopKeepAwake(); // FIX A: clear the interval cleanly before the app exits
stopKeepAwake(); stopWatchdog(); // FIX A/B: clear timers cleanly before the app exits
try { tizen.application.getCurrentApplication().exit(); } catch (x) {}
} else {
if (socket) { try { socket.disconnect(); } catch (x) {} }
@ -546,7 +590,8 @@
// fully provisioned device (has a saved device_id + token) goes straight to
// playback; otherwise show the setup screen and ask for / confirm the server.
startKeepAwake(); // FIX A: assert + re-assert keep-awake on an interval
document.addEventListener('visibilitychange', onVisibility); // FIX B: handle suspend/resume
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
} else if (serverUrl) {