diff --git a/server/player/index.html b/server/player/index.html index c5b8418..060ccf1 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -745,6 +745,65 @@ container.appendChild(note); } + // ==================== v4 liveness watchdog ==================== + // Brings /player onto the LOCKED v4 contract, IDENTICAL on the wire to the APK and .wgt. + // Threshold 45s ± up to 10s jitter (canonical); arm ONLY after a device:heartbeat-ack + // (degrade-safe — an ack-less server never arms us); lastServerMessageAt refreshes on ANY + // inbound (the SILENCE check) while ARMING gates on the ack. Backoff (1s→30s ±20%) is on the + // socket.io Manager (io opts below). NO status/health poll — load is read from ack-silence. + // Browser-specific half-open triggers (visibility/resume/online) drive the SAME check + the + // #148 teardown-first reconnect — additional triggers, not a separate mechanism. + const PLAYER_VERSION = '1.1.0-web'; + const V4_THRESHOLD_BASE_MS = 45000, V4_THRESHOLD_JITTER_MS = 10000; + function v4ThresholdMs(rand) { return V4_THRESHOLD_BASE_MS + Math.round((rand - 0.5) * 2 * V4_THRESHOLD_JITTER_MS); } + // Wall-clock (Date.now) so silence COUNTS sleep/background time — the browser half-open causes + // (a setInterval tick alone would be throttled/frozen in a hidden tab and undercount). + let lastServerMessageAt = 0; + let livenessConfirmed = false; + let livenessWindowMs = V4_THRESHOLD_BASE_MS; + let watchdogTimer = null; + function markAlive() { lastServerMessageAt = Date.now(); } // ANY inbound refreshes silence (does NOT arm) + // Pure decision (matches the APK/.wgt watchdogShouldReconnect): reconnect only a connected + + // registered socket whose liveness we've ARMED that has gone silent past the jittered window. + function watchdogShouldReconnect(hasSocket, connected, armed, silentMs, windowMs) { + return !!(hasSocket && connected && armed && silentMs > windowMs); + } + function checkLiveness() { + // THROTTLE-AWARE. (1) Silence is computed by TIMESTAMP (now - lastServerMessageAt), never by + // timer-fire-count, so a background-THROTTLED/late timer still measures the ACTUAL elapsed + // silence — it won't miss a real half-open for firing late, nor false-fire for firing late. + // (2) While the tab is HIDDEN, timers are throttled and the gap is EXPECTED — do NOT act on it + // (never reconnect a backgrounded tab). The hidden->visible transition resets the grace via + // verifyLivenessSoon(), so we don't false-fire on resume either. + if (document.visibilityState !== 'visible') return; + const silentMs = lastServerMessageAt ? (Date.now() - lastServerMessageAt) : 0; + if (watchdogShouldReconnect(!!socket, !!(socket && socket.connected), livenessConfirmed, silentMs, livenessWindowMs)) { + console.log('[v4] half-open (silent ' + silentMs + 'ms > ' + livenessWindowMs + ') — teardown+reconnect'); + connect(config.serverUrl); // #148 teardown-before-reopen: connect() disconnects the old socket first + } + } + // Fresh liveness check for a resume / bfcache-restore / network-change: silence accumulated + // while hidden or throttled is NOT trustworthy, so NEVER tear down a possibly-live socket on it. + // Reset the grace so the socket gets a fresh window to prove itself — a live socket's engine + // ping/ack refreshes silence within the window (no reconnect); a genuinely dead one stays silent + // past the window and the (now-visible) watchdog reconnects it. #148 teardown-first via connect(). + function verifyLivenessSoon() { + lastServerMessageAt = Date.now(); // fresh grace — don't false-fire on stale hidden-silence + if (!socket) connect(config.serverUrl); // never connected / torn down -> establish (teardown-first) + // socket connected -> grace reset above; the watchdog verifies over the next window. + // socket present-but-disconnected -> socket.io's own reconnection already owns it. + } + function startWatchdog() { stopWatchdog(); watchdogTimer = setInterval(checkLiveness, 10000); } + function stopWatchdog() { if (watchdogTimer) { clearInterval(watchdogTimer); watchdogTimer = null; } } + function browserPlatform() { + try { + const m = navigator.userAgent.match(/(Edg|OPR|Chrome|Firefox|Version)\/(\d+)/); + if (m) { const name = { Edg: 'Edge', OPR: 'Opera', Version: 'Safari' }[m[1]] || m[1]; return name + ' ' + m[2]; } + return 'Browser'; + } catch (e) { return 'Browser'; } + } + if (typeof window !== 'undefined') { window.__v4ThresholdMs = v4ThresholdMs; window.__v4WatchdogShouldReconnect = watchdogShouldReconnect; } + // ==================== Socket Connection ==================== function connect(serverUrl) { if (socket) { socket.disconnect(); socket = null; } @@ -752,8 +811,9 @@ socket = io(serverUrl + '/device', { reconnection: true, reconnectionAttempts: Infinity, - reconnectionDelay: 2000, - reconnectionDelayMax: 10000, + reconnectionDelay: 1000, // v4 canonical: 1s start (was 2s) + reconnectionDelayMax: 30000, // v4 canonical: 30s cap, within the ~30-60s band (was 10s) + randomizationFactor: 0.2, // v4 canonical: ±20% jitter (was the socket.io 0.5 default) timeout: 20000, // Prefer WebSocket but allow polling fallback. Socket.IO default is // polling-first with an upgrade dance that's fragile on TV WebKits @@ -765,6 +825,17 @@ transports: ['websocket', 'polling'], }); + // v4 liveness: a fresh socket is assumed alive; DIS-arm until a heartbeat-ack re-arms; pick a + // fresh jittered window for this connection. markAlive on ANY inbound (app events via onAny + + // the engine ping) refreshes the SILENCE timer only — it does NOT arm. + lastServerMessageAt = Date.now(); + livenessConfirmed = false; + livenessWindowMs = v4ThresholdMs(Math.random()); + socket.onAny(markAlive); + socket.io.on('ping', markAlive); + // v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack. + socket.on('device:heartbeat-ack', () => { livenessConfirmed = true; }); + socket.on('connect', () => { console.log('Connected'); register(); @@ -773,6 +844,7 @@ socket.on('disconnect', () => { console.log('Disconnected'); stopHeartbeat(); + stopWatchdog(); // socket.io owns the reconnect once it KNOWS it's down; watchdog is for half-open only }); socket.on('connect_error', (err) => { @@ -797,6 +869,7 @@ } startHeartbeat(); + startWatchdog(); // v4: arm-gated half-open watchdog (no-op until a heartbeat-ack arms it) startPlaylistRefresh(); startVersionCheck(); }); @@ -1082,6 +1155,12 @@ screen_width: screen.width, screen_height: screen.height, }; + // v4 client identity block — additive, canonical snake_case (same shape as APK/.wgt so the + // server consumes one thing). Backward-compatible: an old server ignores unknown fields. + data.client_type = 'player'; + data.client_version = PLAYER_VERSION; + data.platform = browserPlatform(); + data.contract_version = 'v4'; // Browser fingerprint (survives localStorage clear) data.fingerprint = generateBrowserFingerprint(); console.log(`[register] device_id=${data.device_id || 'none'}, has_token=${!!data.device_token}, token_len=${data.device_token?.length || 0}, paired=${config.paired}, pairing_code=${data.pairing_code || 'none'}`); @@ -2193,7 +2272,18 @@ } catch {} } requestWakeLock(); - document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') requestWakeLock(); }); + // v4 browser half-open triggers: tab foreground / bfcache resume / network change all drive the + // SAME liveness check + #148 teardown-first reconnect (checkLiveness -> connect() only if the + // socket is armed+connected+silent-past-window). checkLiveness no-ops on a healthy socket, so a + // visibility change does NOT spawn a duplicate socket (the classic browser bug). + document.addEventListener('visibilitychange', () => { + // On becoming visible after a (possibly throttled/frozen) background stint, do the fresh + // liveness check — reset the grace and reconnect ONLY if genuinely dead; never spuriously + // tear down a live socket on the hidden->visible gap. + if (document.visibilityState === 'visible') { requestWakeLock(); verifyLivenessSoon(); } + }); + window.addEventListener('pageshow', verifyLivenessSoon); // sleep/resume via bfcache restore + window.addEventListener('online', verifyLivenessSoon); // network switch (wifi<->cellular) // Register service worker for offline content caching if ('serviceWorker' in navigator) {