mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
Server-side keystone: the server now honors the v4 liveness contract uniformly across the MIXED fleet (v4 + old pre-v4 + disconnected), all three clients depending on it. - UNIFORM heartbeat-ack: emitted from the single shared device:heartbeat handler (uniform by construction; no per-client/per-path branch), BEFORE the auth guard so a known device's watchdog stays armed. Harmless to old clients (they ignore it). - RECONNECT-WINDOW ack-gap fix (ackableHeartbeat): ack a KNOWN device (authed socket OR a device_id that resolves) even mid-reconnect; NOT anonymous/never-authenticated sockets (degrade-safe); identity-agnostic. No state mutation before requireDeviceAuth (auth surface unchanged; device_ids are uuidv4). - DASHBOARD LIVENESS (deriveLiveness): server-derived, VERSION-AGNOSTIC Healthy/Degraded/Offline from signals every client sends (socket presence, heartbeat age, reconnect frequency); no client status-push. - IDENTITY CAPTURE (capture-don't-act): client_type/client_version/platform/contract_version columns; degrades to legacy/unknown for old clients; NEVER breaks register. - A-BUCKET FIX (QA): recordReconnect + persistIdentity gated on !isPlaylistRefresh (a ~45-60s refresh is not a reconnect/new identity — matches #134), and the identity write is change-detected — closing the WAL write-amplification (A1) and the benign-refresh -> false-"Degraded" (A2) regressions. New lib/liveness.js (pure helpers, unit-tested). 30 new tests (uniform ack, ack-gap, mixed fleet, identity capture, cross-client conformance, refresh-gate reproduce-then-prove); 366/366 total. OTA artifact-availability is a separate concern (out of scope). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
70 lines
4.3 KiB
JavaScript
70 lines
4.3 KiB
JavaScript
'use strict';
|
||
|
||
// v4 CORE-PASS liveness helpers — pure, VERSION-AGNOSTIC, mixed-fleet-safe. Dependency-free so they
|
||
// are unit-testable and the imperative shells (deviceSocket heartbeat/register handlers, the
|
||
// heartbeat offline sweep) stay thin. The server talks to a MIX simultaneously — v4 clients (have a
|
||
// watchdog, consume the ack, send an identity block), OLD pre-v4 clients (none of that), and
|
||
// genuinely-disconnected devices — and none of these may break the server or each other.
|
||
|
||
// ── Uniform ack (PRIMARY + FIX 1: reconnect-window gap) ────────────────────────────────────────
|
||
// Should THIS device:heartbeat be acked with device:heartbeat-ack? The ack keeps a v4 client's
|
||
// watchdog armed; it is emitted from the SHARED heartbeat handler (uniform by construction across
|
||
// APK / .wgt / /player) and is HARMLESS to old clients (they don't consume it). We ack a KNOWN
|
||
// device — identity-agnostic:
|
||
// - an already-authenticated socket (authedDeviceId set), OR
|
||
// - a heartbeat carrying a device_id that RESOLVES to a real device (a real device mid-reconnect,
|
||
// BEFORE this socket finished re-registering — the deferred ack-gap fix).
|
||
// We do NOT ack anonymous / never-authenticated sockets (no device_id, or an unknown id): those are
|
||
// covered by degrade-safe — an un-acked client's watchdog simply never arms, so there is no
|
||
// false-fire and no storm.
|
||
function ackableHeartbeat(authedDeviceId, heartbeatDeviceId, deviceExists) {
|
||
if (authedDeviceId) return true; // authenticated socket -> known
|
||
if (!heartbeatDeviceId) return false; // anonymous heartbeat -> not acked
|
||
return !!deviceExists(heartbeatDeviceId); // real device mid-reconnect -> ack (window fix)
|
||
}
|
||
|
||
// ── Dashboard liveness (FIX 2: server-derived, VERSION-AGNOSTIC 3-state) ────────────────────────
|
||
// Derived ONLY from signals EVERY client sends — socket presence, last-heartbeat age, reconnect
|
||
// frequency — never from v4-only signals. Correct for v4 clients, OLD clients (connected +
|
||
// heartbeating -> healthy), and disconnected clients (-> offline, a normal state, NOT an error).
|
||
// offline : no live socket.
|
||
// degraded : connected but reconnecting frequently (churn), OR connected but silent past the window.
|
||
// healthy : connected + a recent heartbeat + not churning.
|
||
const HEALTHY_HEARTBEAT_MS = 35000; // 2× the 15s client heartbeat + margin
|
||
const DEGRADED_RECONNECTS = 3; // >=3 (re)registers within the reconnect window => churn
|
||
|
||
function deriveLiveness({ connected, lastHeartbeatAgeMs, recentReconnects } = {}, opts = {}) {
|
||
const hbMax = opts.healthyHeartbeatMs != null ? opts.healthyHeartbeatMs : HEALTHY_HEARTBEAT_MS;
|
||
const churn = opts.degradedReconnects != null ? opts.degradedReconnects : DEGRADED_RECONNECTS;
|
||
if (!connected) return 'offline';
|
||
if ((recentReconnects || 0) >= churn) return 'degraded';
|
||
if ((lastHeartbeatAgeMs || 0) > hbMax) return 'degraded';
|
||
return 'healthy';
|
||
}
|
||
|
||
// ── Identity capture (FIX 3: capture-don't-act, DEGRADES on missing) ────────────────────────────
|
||
// Capture the v4 identity block when present; when absent/partial (an OLD client), fill
|
||
// "legacy"/"unknown" — NEVER fail on a missing field. No logic is built on this yet.
|
||
function captureIdentity(data) {
|
||
const d = data || {};
|
||
return {
|
||
client_type: d.client_type || 'legacy',
|
||
client_version: d.client_version || 'unknown',
|
||
platform: d.platform || 'unknown',
|
||
contract_version: d.contract_version || 'legacy',
|
||
};
|
||
}
|
||
|
||
// A1 change-detection: has the (already-captured) identity changed vs what's stored? A genuine
|
||
// reconnect with an unchanged identity (the common case) then does NO write. A never-stored device
|
||
// (current null / all-NULL columns) or a real change (e.g. new client_version after an OTA) writes.
|
||
function identityChanged(current, incoming) {
|
||
if (!current) return true;
|
||
return current.client_type !== incoming.client_type
|
||
|| current.client_version !== incoming.client_version
|
||
|| current.platform !== incoming.platform
|
||
|| current.contract_version !== incoming.contract_version;
|
||
}
|
||
|
||
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
|