mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
feat(player): v4 liveness contract — throttle-aware watchdog + browser triggers + identity
/player v4 — client-only (served web player). Brings the browser player onto the locked v4 liveness contract, IDENTICAL on the wire to the now-v4 APK and .wgt: - v4 liveness watchdog: consume device:heartbeat-ack, lastServerMessageAt refreshes on ANY inbound (onAny + engine ping) while ARMING gates on the ack (degrade-safe); 45s±10s jittered threshold; backoff 1s/30s/±0.2 on the socket.io Manager; NO status/health poll; teardown-before-reopen (#148). - Browser-specific half-open triggers (the /player-unique part): Page Visibility, sleep/resume (pageshow/bfcache), network change (online) — all drive the SAME #148 teardown-first reconnect. - THROTTLE-AWARE: silence computed by timestamp (not timer-fire-count); watchdog does NOT act while the tab is HIDDEN (throttled-timer gap is expected); on becoming visible, verifyLivenessSoon() resets the grace and reconnects ONLY if genuinely dead — never spuriously tears down a live socket. - v4 client identity block on register (client_type=player / client_version / platform / v4). Verified: arm-after-ack both directions, v4 values MATCH .wgt exactly, browser-trigger no-duplicate- socket, throttle-aware reproduce-then-prove (real extracted checkLiveness/verifyLivenessSoon), foreground-recovery one-socket. Depends on the server device:heartbeat-ack (core pass) — degrade-safe until then. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bcbb3752c6
commit
80d6242806
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue