mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -06:00
Best-effort "last gasp" so Offline is annotated with WHY it went away — completing the liveness story.
Categories: crashed (client uncaught-exception), clean_exit (client confident lifecycle-end, best-effort),
silent (SERVER-inferred by absence — the honest catch-all for violent/external death incl. force-stop/MDM).
SERVER:
- device:exit socket handler + token-authed beacon POST /api/device/exit (reliable-on-unload). Both gated
by liveness.sanitizeExitReason (honesty: only crashed/clean_exit accepted; 'silent'/unknown rejected).
- offline_reason/offline_reason_at/offline_detail columns (additive migration). Clear-on-online (a reason
is always THIS session's); offline transition COALESCEs to 'silent'. Pure annotation — offline detection
and #148/liveness are untouched. Offline dashboard emits carry offline_reason + client_type.
CLIENTS (canonical {reason,detail} shape):
- /player: window error/unhandledrejection + pagehide(persisted=false) -> sendBeacon.
- .wgt: same + BACK-key exit -> socket.emit + sendBeacon.
- APK: global UncaughtExceptionHandler -> crashed (blocking beacon, chains to default); Service.onDestroy
-> clean_exit (socket + bounded beacon). New ExitSignal.kt. onStop/onPause NOT wired (background != exit).
Proven (Phase 3): per-category classification, nothing misclassified, external kill -> silent (never
clean_exit), backgrounding emits no false exit, #148/reconnect-vs-exit intact. 382/382 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
82 lines
5.2 KiB
JavaScript
82 lines
5.2 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;
|
||
}
|
||
|
||
// Exit-signal contract v1 — manner-of-death. A client may ONLY announce 'crashed' (its uncaught-
|
||
// exception handler fired) or 'clean_exit' (a confident lifecycle-end). 'silent' is server-inferred by
|
||
// ABSENCE and is NEVER accepted from a client. Honesty by construction: an unknown/uncertain value is
|
||
// rejected (-> null), so the device falls to server-inferred 'silent' rather than being coerced into a
|
||
// wrong category. detail is optional (crash message / lifecycle-hook name), sanitized + length-capped.
|
||
const CLIENT_EXIT_REASONS = ['crashed', 'clean_exit'];
|
||
function sanitizeExitReason(reason, detail) {
|
||
if (!CLIENT_EXIT_REASONS.includes(reason)) return null;
|
||
const d = (typeof detail === 'string' && detail.trim()) ? detail.trim().slice(0, 200) : null;
|
||
return { reason, detail: d };
|
||
}
|
||
|
||
module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, sanitizeExitReason, CLIENT_EXIT_REASONS, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS };
|