diff --git a/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt b/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt index 70e0f91..a8ccb9c 100644 --- a/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt +++ b/android/app/src/main/java/com/remotedisplay/player/RemoteDisplayApp.kt @@ -18,6 +18,23 @@ class RemoteDisplayApp : Application() { override fun onCreate() { super.onCreate() createNotificationChannel() + installCrashExitSignal() + } + + // Exit-signal contract v1 — 'crashed'. A global uncaught-exception handler fires a BEST-EFFORT + // blocking last-gasp to the server, then delegates to the previous default handler so the crash + // still propagates and the process dies normally. Runs on the crashing thread (already dying), so + // the short blocking POST is acceptable. BEST-EFFORT: a native/OOM kill runs no JVM handler -> + // nothing is sent -> the server infers 'silent'. Honesty: only ever emits 'crashed' here. + private fun installCrashExitSignal() { + val prev = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + try { + val detail = (throwable.javaClass.simpleName + ": " + (throwable.message ?: "")).trim() + com.remotedisplay.player.service.ExitSignal.send(this, "crashed", detail) + } catch (t: Throwable) { /* never mask the original crash */ } + prev?.uncaughtException(thread, throwable) // chain -> normal crash reporting + process death + } } private fun createNotificationChannel() { diff --git a/android/app/src/main/java/com/remotedisplay/player/service/ExitSignal.kt b/android/app/src/main/java/com/remotedisplay/player/service/ExitSignal.kt new file mode 100644 index 0000000..b22a879 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/service/ExitSignal.kt @@ -0,0 +1,61 @@ +package com.remotedisplay.player.service + +import android.content.Context +import com.remotedisplay.player.data.ServerConfig +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +/** + * Exit-signal contract v1 — best-effort "last gasp" (manner of death), APK conformance. + * + * Sent via a BLOCKING OkHttp POST to /api/device/exit, NOT socket.emit: the socket emit path is + * async with no flush, so it will not reliably leave the buffer before the process dies. A short, + * bounded blocking POST from the crashing thread (about to die anyway) / a worker thread is the + * reliable transport on Android (matches the beacon the browser/Tizen clients use). + * + * Categories (honesty by construction — only ever these two; anything else -> server infers 'silent'): + * - "crashed" : the global uncaught-exception handler fired (RemoteDisplayApp). + * - "clean_exit" : Service.onDestroy on COOPERATIVE teardown (stopService/unbind/memory-reclaim-with- + * grace). NOT onStop/onPause (those fire on backgrounding). force-stop / MDM-uninstall + * / SIGKILL / OOM skip all callbacks -> nothing is sent -> server infers 'silent'. + * Idempotent: the first confident signal wins (a crash is never relabelled clean_exit). + */ +object ExitSignal { + @Volatile private var sent = false + private val JSON = "application/json".toMediaType() + private val client = OkHttpClient.Builder() + .callTimeout(2, TimeUnit.SECONDS) + .connectTimeout(2, TimeUnit.SECONDS) + .writeTimeout(2, TimeUnit.SECONDS) + .build() + + fun send(context: Context, reason: String, detail: String?) { + try { + if (sent) return + if (reason != "crashed" && reason != "clean_exit") return + val cfg = ServerConfig(context.applicationContext) + val id = cfg.deviceId + val token = cfg.deviceToken + val url = cfg.serverUrl + if (id.isEmpty() || token.isEmpty() || url.isEmpty()) return // unpaired -> nothing to attribute + sent = true + val payload = JSONObject().apply { + put("device_id", id) + put("device_token", token) + put("reason", reason) + if (!detail.isNullOrBlank()) put("detail", detail.take(200)) + }.toString() + val req = Request.Builder() + .url(url.trimEnd('/') + "/api/device/exit") + .post(payload.toRequestBody(JSON)) + .build() + client.newCall(req).execute().use { /* fire-and-forget; response ignored */ } + } catch (t: Throwable) { + /* a dying process must never throw further out of the last gasp */ + } + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 3a91ef9..50834f6 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -776,6 +776,19 @@ class WebSocketService : Service() { fun isConnected(): Boolean = socket?.connected() == true override fun onDestroy() { + // Exit-signal contract v1 — 'clean_exit' (BEST-EFFORT). onDestroy runs ONLY on cooperative + // teardown (stopService/unbind/memory-reclaim-with-grace); a force-stop / MDM-uninstall / SIGKILL + // skips it entirely -> the server infers 'silent' (correct — not misclassified). Try the still- + // live socket first, then a bounded blocking beacon (the reliable path); the server dedups. + try { + if (socket?.connected() == true && config.deviceId.isNotEmpty()) { + socket?.emit("device:exit", JSONObject().apply { + put("device_id", config.deviceId); put("reason", "clean_exit"); put("detail", "onDestroy") + }) + } + } catch (e: Throwable) { /* never let the last gasp block teardown */ } + val ctx = applicationContext + Thread { ExitSignal.send(ctx, "clean_exit", "onDestroy") }.apply { start(); try { join(1500) } catch (e: InterruptedException) { /* proceed with teardown */ } } wakeLock?.let { if (it.isHeld) it.release() } disconnect() super.onDestroy() diff --git a/server/db/database.js b/server/db/database.js index 15481a9..b53d639 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -94,6 +94,13 @@ const migrations = [ 'ALTER TABLE devices ADD COLUMN client_version TEXT', 'ALTER TABLE devices ADD COLUMN platform TEXT', 'ALTER TABLE devices ADD COLUMN contract_version TEXT', + // Exit-signal contract v1 — manner-of-death annotation on Offline (additive; NEVER alters offline + // detection). offline_reason: 'crashed'|'clean_exit' (client-sent via device:exit / beacon) or + // 'silent' (server-inferred when no signal arrived). Cleared on (re)online so it's always this + // session's. offline_detail: optional crash message / lifecycle-hook name. + 'ALTER TABLE devices ADD COLUMN offline_reason TEXT', + 'ALTER TABLE devices ADD COLUMN offline_reason_at INTEGER', + 'ALTER TABLE devices ADD COLUMN offline_detail TEXT', // Email settings on users "ALTER TABLE users ADD COLUMN email_alerts INTEGER DEFAULT 1", // Content folders diff --git a/server/lib/liveness.js b/server/lib/liveness.js index ee45ab6..681c4cb 100644 --- a/server/lib/liveness.js +++ b/server/lib/liveness.js @@ -66,4 +66,16 @@ function identityChanged(current, incoming) { || current.contract_version !== incoming.contract_version; } -module.exports = { ackableHeartbeat, deriveLiveness, captureIdentity, identityChanged, HEALTHY_HEARTBEAT_MS, DEGRADED_RECONNECTS }; +// 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 }; diff --git a/server/player/index.html b/server/player/index.html index 060ccf1..3944ae9 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -345,6 +345,44 @@ let config = getConfig(); let playlist = []; let currentIndex = -1; + + // ==================== Exit-signal contract v1 (best-effort last gasp) ==================== + // Announce manner-of-death via navigator.sendBeacon (reliable-on-unload — survives the socket + // teardown). crashed: real uncaught SCRIPT error / unhandled rejection. clean_exit: pagehide with + // persisted=false (a genuine unload — NOT a bfcache suspend, which the liveness watchdog handles, + // and NOT mere visibility-hidden). Honesty: only these two confident categories are ever sent; + // anything uncertain sends nothing -> the server infers 'silent'. Idempotent (first signal wins, + // so a crash is never relabelled clean_exit by the pagehide that follows it). + let __exitSent = false; + function sendExitBeacon(reason, detail) { + try { + if (__exitSent) return; + if (reason !== 'crashed' && reason !== 'clean_exit') return; + if (!config || !config.deviceId || !config.deviceToken) return; // unpaired -> nothing to attribute + __exitSent = true; + const url = (config.serverUrl || window.location.origin) + '/api/device/exit'; + const body = JSON.stringify({ device_id: config.deviceId, device_token: config.deviceToken, + reason, detail: (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined }); + const blob = new Blob([body], { type: 'application/json' }); // Content-Type so express.json parses it + if (navigator.sendBeacon && navigator.sendBeacon(url, blob)) return; + fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json' }, keepalive: true }).catch(() => {}); + } catch (e) { /* a dying page must never throw */ } + } + window.addEventListener('error', (ev) => { + // ONLY a real uncaught script error is a crash — a resource (img/script/link) load failure is NOT. + if (!ev) return; + const isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href); + if (isResourceError) return; + sendExitBeacon('crashed', (ev.error && ev.error.message) || ev.message || 'error'); + }); + window.addEventListener('unhandledrejection', (ev) => { + const r = ev && ev.reason; + sendExitBeacon('crashed', (r && (r.message || String(r))) || 'unhandledrejection'); + }); + window.addEventListener('pagehide', (ev) => { + if (ev && ev.persisted) return; // bfcache SUSPEND (may restore) — NOT a death; watchdog owns it + sendExitBeacon('clean_exit', 'pagehide'); + }); let isPlaying = false; let playerTimezone = null; // #74/#75: device-effective IANA tz for schedule eval let scheduleRetryTimer = null; // re-check when every item is filtered out diff --git a/server/server.js b/server/server.js index 6b56a41..9d99d7a 100644 --- a/server/server.js +++ b/server/server.js @@ -647,6 +647,29 @@ app.get('/api/update/check', (req, res) => { }); }); +// Exit-signal contract v1 — beacon transport (reliable-on-unload). Clients that can't reliably +// socket.emit at death (browser/Tizen pagehide, APK crash where async emit won't flush) POST their +// manner-of-death here via navigator.sendBeacon / blocking HTTP. Token-authed (there's no JWT/socket +// session at unload time); PUBLIC (mounted before requireAuth). Sets offline_reason exactly like the +// device:exit socket handler — the later Offline transition resolves + surfaces it. NEVER triggers +// offline itself (additive only). Always 204 (never error a dying client; never leak an id/token oracle). +app.post('/api/device/exit', (req, res) => { + const { db } = require('./db/database'); + const liveness = require('./lib/liveness'); + const { device_id, device_token, reason, detail } = req.body || {}; + if (!device_id || typeof device_token !== 'string') return res.status(204).end(); + const row = db.prepare('SELECT device_token FROM devices WHERE id = ?').get(device_id); + let ok = false; + try { + ok = !!(row && row.device_token && device_token.length === row.device_token.length && + crypto.timingSafeEqual(Buffer.from(row.device_token), Buffer.from(device_token))); + } catch (_) { ok = false; } + if (!ok) return res.status(204).end(); + const e = liveness.sanitizeExitReason(reason, detail); // unknown/invalid -> null -> device falls to 'silent' + if (e) db.prepare("UPDATE devices SET offline_reason = ?, offline_reason_at = strftime('%s','now'), offline_detail = ? WHERE id = ?").run(e.reason, e.detail, device_id); + res.status(204).end(); +}); + // (Content file endpoint moved above protected routes) // (Screenshot route moved above protected routes) diff --git a/server/services/heartbeat.js b/server/services/heartbeat.js index dccac39..107d9a8 100644 --- a/server/services/heartbeat.js +++ b/server/services/heartbeat.js @@ -84,15 +84,22 @@ function startHeartbeatChecker(io) { const sock = deviceNs.sockets.get(conn.socketId); if (sock) { try { sock.disconnect(true); } catch (_) { /* already gone */ } } } - db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?") + // Exit-signal contract: this timeout path is the classic 'silent' case (froze, no clean + // disconnect, no signal) — COALESCE annotates 'silent' unless a device:exit reason arrived + // this session (e.g. a crash emit that beat the freeze). Pure annotation; detection unchanged. + db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now'), offline_reason = COALESCE(offline_reason, 'silent'), offline_reason_at = COALESCE(offline_reason_at, strftime('%s','now')) WHERE id = ?") .run(device.id); deviceConnections.delete(device.id); + const _off = db.prepare("SELECT offline_reason, offline_detail, client_type FROM devices WHERE id = ?").get(device.id) || {}; // Notify dashboard (workspace-scoped via the device's room). emitToWorkspace(dashboardNs, deviceRoom(device.id), 'dashboard:device-status', { device_id: device.id, status: 'offline', liveness: 'offline', // FIX 2: derived — no live socket => offline (a normal state, not an error) + offline_reason: _off.offline_reason || 'silent', // exit-signal contract: manner-of-death + offline_detail: _off.offline_detail || null, + client_type: _off.client_type || null, telemetry: null }); reconnectTimes.delete(device.id); // clear churn history on a clean offline diff --git a/server/test/v4-exit-signal-phase3.test.js b/server/test/v4-exit-signal-phase3.test.js new file mode 100644 index 0000000..cee6e9e --- /dev/null +++ b/server/test/v4-exit-signal-phase3.test.js @@ -0,0 +1,129 @@ +// Exit-signal PHASE 3 — proof. (A) socket/#148/liveness safety; (B) per-category classification with +// NOTHING misclassified. The JS-client handlers are proven by EXECUTING THE REAL SOURCE blocks (sliced +// out of index.html / app.js) against shimmed window/navigator/socket and firing synthetic death events. +const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto'); +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const ioClient = require('../node_modules/socket.io-client'); +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// ---- harness: run a real client exit-block against shims, capturing what it sends ---- +function harness(file, startLine, endLine) { + const src = fs.readFileSync(file, 'utf8').split('\n').slice(startLine - 1, endLine).join('\n'); + const beacons = [], socketSends = [], handlers = {}; + const windowShim = { addEventListener: (ev, fn) => { (handlers[ev] = handlers[ev] || []).push(fn); }, location: { origin: 'http://srv' } }; + const navShim = { sendBeacon: (url, blob) => { beacons.push({ url, ...JSON.parse(blob.__body) }); return true; } }; + class BlobShim { constructor(parts, o) { this.__body = parts[0]; this.type = o && o.type; } } + const socketShim = { connected: true, emit: (ev, payload) => { socketSends.push({ ev, ...payload }); } }; + const config = { deviceId: 'D1', deviceToken: 'T1', serverUrl: 'http://srv' }; // /player reads config.* + const fn = new Function('window', 'navigator', 'Blob', 'fetch', 'config', 'deviceId', 'deviceToken', 'serverUrl', 'socket', 'JSON', src); + fn(windowShim, navShim, BlobShim, () => Promise.resolve(), config, 'D1', 'T1', 'http://srv', socketShim, JSON); + return { beacons, socketSends, fire: (ev, e) => (handlers[ev] || []).forEach(f => f(e)), handlers }; +} +const WINDOW = 'window-target'; // sentinel for ev.target === window (real error event on window) + +// ============ PART B — /player classification (real source, lines 349-385) ============ +const PLAYER = path.join(__dirname, '../player/index.html'); +test('B/player CRASH: uncaught error + unhandledrejection -> crashed (via sendBeacon, NOT the socket)', () => { + let h = harness(PLAYER, 349, 385); h.fire('error', { error: { message: 'boom' }, target: undefined }); + assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed'); + assert.equal(h.socketSends.length, 0, '/player uses the beacon channel only — no send over the dying socket'); + h = harness(PLAYER, 349, 385); h.fire('unhandledrejection', { reason: { message: 'rej' } }); + assert.equal(h.beacons[0].reason, 'crashed'); +}); +test('B/player NO-MISCLASSIFY: a RESOURCE load error (img/script) is NOT a crash', () => { + const h = harness(PLAYER, 349, 385); h.fire('error', { target: { src: 'https://x/img.png' } }); + assert.equal(h.beacons.length, 0, 'resource error must not emit crashed'); +}); +test('B/player CLEAN-CLOSE: pagehide(persisted=false) -> clean_exit', () => { + const h = harness(PLAYER, 349, 385); h.fire('pagehide', { persisted: false }); + assert.equal(h.beacons[0].reason, 'clean_exit'); +}); +test('B/player BACKGROUNDING: pagehide(persisted=true) bfcache suspend -> NO exit (not a death)', () => { + const h = harness(PLAYER, 349, 385); h.fire('pagehide', { persisted: true }); + assert.equal(h.beacons.length, 0, 'a suspend must NOT emit clean_exit'); + assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'exit block wires NO visibilitychange -> hidden never emits exit'); +}); +test('B/player IDEMPOTENT: crash then pagehide -> only crashed (crash not relabelled clean_exit)', () => { + const h = harness(PLAYER, 349, 385); h.fire('error', { error: { message: 'boom' } }); h.fire('pagehide', { persisted: false }); + assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed'); +}); + +// ============ PART B — .wgt classification (real source, lines 663-697) ============ +const TIZEN = path.join(__dirname, '../../tizen/js/app.js'); +test('B/wgt CRASH: error/rejection -> crashed (socket AND beacon; server dedups)', () => { + const h = harness(TIZEN, 663, 697); h.fire('error', { error: { message: 'boom' } }); + assert.equal(h.beacons[0].reason, 'crashed'); + assert.equal(h.socketSends[0].reason, 'crashed'); assert.equal(h.socketSends[0].ev, 'device:exit'); +}); +test('B/wgt NO-MISCLASSIFY: resource error is not a crash', () => { + const h = harness(TIZEN, 663, 697); h.fire('error', { target: { src: 'x.png' } }); + assert.equal(h.beacons.length, 0); assert.equal(h.socketSends.length, 0); +}); +test('B/wgt CLEAN-CLOSE: pagehide(false) -> clean_exit; BACKGROUNDING pagehide(true) -> NO exit', () => { + let h = harness(TIZEN, 663, 697); h.fire('pagehide', { persisted: false }); + assert.equal(h.beacons[0].reason, 'clean_exit'); + h = harness(TIZEN, 663, 697); h.fire('pagehide', { persisted: true }); + assert.equal(h.beacons.length, 0, 'suspend must NOT emit clean_exit'); + assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'no visibilitychange in the exit block'); +}); + +// ============ PART A — server-side socket / #148 / reconnect-vs-exit safety ============ +const PORT = 3975; const BASE = `http://127.0.0.1:${PORT}`; +const DATA_DIR = path.join(os.tmpdir(), 'st-exit3-' + crypto.randomBytes(4).toString('hex')); +let proc, JWT; +before(async () => { + const logFd = fs.openSync(path.join(os.tmpdir(), 'st-exit3.log'), 'w'); + proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] }); + let up = false; for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch {} await sleep(250); } + if (!up) throw new Error('boot fail'); + JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token; +}); +after(() => { try { proc.kill('SIGKILL'); } catch {} }); +const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true }); +const reg = (s, m) => new Promise((res, rej) => { s.once('device:registered', d => res(d)); s.emit('device:register', m); setTimeout(() => rej(new Error('to')), 5000); }); +const pair = (c) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: c, name: 't' }) }); +const row = async (id) => (await (await fetch(`${BASE}/api/devices/${id}`, { headers: { Authorization: 'Bearer ' + JWT } })).json()); +const ackWithin = (s, hb, ms = 1500) => new Promise(r => { let d = false; const f = v => { if (!d) { d = true; r(v); } }; s.once('device:heartbeat-ack', () => f(true)); s.emit('device:heartbeat', hb); setTimeout(() => f(false), ms); }); + +test('A: crash-emit then teardown -> device goes Offline normally (no orphan/half-open) + reason kept', async () => { + const s = connect(); await new Promise(r => s.on('connect', r)); + const d = await reg(s, { pairing_code: '820001', fingerprint: 'f1', device_info: {}, client_type: 'apk', contract_version: 'v4' }); + await pair('820001'); await sleep(150); + s.emit('device:exit', { reason: 'crashed', detail: 'boom' }); // emit AS the socket is about to die + s.close(); // teardown immediately after + await sleep(5800); + const r = await row(d.device_id); + assert.equal(r.status, 'offline', 'normal offline transition still fired (teardown not disturbed)'); + assert.equal(r.offline_reason, 'crashed'); +}); + +test('A: RECONNECT is NOT an exit, and an exit does not block reconnect — #148 one socket, reason cleared', async () => { + const s1 = connect(); await new Promise(r => s1.on('connect', r)); + const d = await reg(s1, { pairing_code: '820002', fingerprint: 'f2', device_info: {}, client_type: 'apk', contract_version: 'v4' }); + await pair('820002'); await sleep(150); + s1.emit('device:exit', { reason: 'crashed' }); await sleep(150); + assert.equal((await row(d.device_id)).offline_reason, 'crashed'); + s1.close(); await sleep(300); + // genuine reconnect (new socket) — must NOT emit an exit, and must clear the stale reason + const s2 = connect(); await new Promise(r => s2.on('connect', r)); + await reg(s2, { device_id: d.device_id, device_token: d.device_token, fingerprint: 'f2', device_info: {}, client_type: 'apk', contract_version: 'v4' }); + await sleep(200); + assert.equal((await row(d.device_id)).offline_reason, null, 'reconnect cleared the reason (reconnect != exit)'); + assert.equal(await ackWithin(s2, { device_id: d.device_id, telemetry: {} }), true, 'the single reconnected socket is healthy (#148 intact)'); + assert.equal((await row(d.device_id)).status, 'online'); + s2.close(); +}); + +test('A: VIOLENT kill (abrupt drop, NO device:exit) -> silent, never crashed/clean_exit (Bold-critical)', async () => { + const s = connect(); await new Promise(r => s.on('connect', r)); + const d = await reg(s, { pairing_code: '820003', fingerprint: 'f3', device_info: {}, client_type: 'apk', contract_version: 'v4' }); + await pair('820003'); await sleep(150); + s.io.engine.close(); // hard transport drop — no clean disconnect, no exit signal (force-stop/power/MDM) + await sleep(5800); + const r = await row(d.device_id); + assert.equal(r.status, 'offline'); + assert.equal(r.offline_reason, 'silent', 'external/violent death reads as silent'); + assert.notEqual(r.offline_reason, 'clean_exit'); assert.notEqual(r.offline_reason, 'crashed'); +}); diff --git a/server/test/v4-exit-signal.test.js b/server/test/v4-exit-signal.test.js new file mode 100644 index 0000000..7d4558f --- /dev/null +++ b/server/test/v4-exit-signal.test.js @@ -0,0 +1,95 @@ +// Exit-signal contract v1 — manner-of-death annotation on Offline. Server-side proof: the socket +// device:exit handler + the beacon POST endpoint set offline_reason; the Offline transition resolves +// crashed/clean_exit (kept) vs silent (no signal); clear-on-online prevents stale mislabels; honesty +// (client-sent 'silent'/garbage rejected). Client emit paths are proven in Phase 3 per platform. +const path = require('node:path'); const os = require('node:os'); const fs = require('node:fs'); const crypto = require('node:crypto'); +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const ioClient = require('../node_modules/socket.io-client'); +const liveness = require('../lib/liveness'); +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +// ===== UNIT: honesty by construction (sanitizeExitReason) ===== +test('sanitizeExitReason: only crashed/clean_exit accepted; silent + unknown REJECTED (-> server silent)', () => { + assert.equal(liveness.sanitizeExitReason('crashed', 'boom').reason, 'crashed'); + assert.equal(liveness.sanitizeExitReason('clean_exit', null).reason, 'clean_exit'); + assert.equal(liveness.sanitizeExitReason('silent'), null); // server-inferred only — never from a client + assert.equal(liveness.sanitizeExitReason('exploded'), null); // never fabricate an unknown category + assert.equal(liveness.sanitizeExitReason(''), null); + assert.equal(liveness.sanitizeExitReason('crashed', 'x'.repeat(500)).detail.length, 200); // capped + assert.equal(liveness.sanitizeExitReason('crashed', ' ').detail, null); // blank -> null +}); + +// ===== E2E ===== +const PORT = 3974; const BASE = `http://127.0.0.1:${PORT}`; +const DATA_DIR = path.join(os.tmpdir(), 'st-exit-' + crypto.randomBytes(4).toString('hex')); +const LOG = path.join(os.tmpdir(), 'st-exit.log'); +let proc, JWT; + +before(async () => { + const logFd = fs.openSync(LOG, 'w'); + proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] }); + let up = false; for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch { /* */ } await sleep(250); } + if (!up) throw new Error('boot fail:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000)); + JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token; +}); +after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } }); + +const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true }); +const registerOn = (s, msg) => new Promise((res, rej) => { s.once('device:registered', d => res(d)); s.emit('device:register', msg); setTimeout(() => rej(new Error('reg timeout')), 5000); }); +const pair = (code) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: code, name: 't' }) }); +const row = async (id) => (await (await fetch(`${BASE}/api/devices/${id}`, { headers: { Authorization: 'Bearer ' + JWT } })).json()); +async function provisionPaired(code, ident = {}) { + const s = connect(); await new Promise(r => s.on('connect', r)); + const reg = await registerOn(s, { pairing_code: code, fingerprint: 'fp' + code, device_info: {}, ...ident }); + await pair(code); await sleep(150); + return { s, id: reg.device_id, token: reg.device_token }; +} + +// (4 devices — the SELF_HOSTED plan caps at 5; offline devices still count.) + +test('crashed: device:exit sets offline_reason, and it SURVIVES the Offline transition (COALESCE)', async () => { + const d = await provisionPaired('810001', { client_type: 'apk', contract_version: 'v4' }); + d.s.emit('device:exit', { reason: 'crashed', detail: 'NullPointerException: boom' }); + await sleep(250); + assert.equal((await row(d.id)).offline_reason, 'crashed', 'set immediately on device:exit'); + d.s.close(); await sleep(5800); // OFFLINE_DEBOUNCE_MS=5000 + const r = await row(d.id); + assert.equal(r.status, 'offline'); assert.equal(r.offline_reason, 'crashed', 'kept through Offline (not overwritten by silent)'); +}); + +test('clean_exit + clear-on-online: reason set, then CLEARED on re-register (no stale mislabel)', async () => { + const d = await provisionPaired('810002', { client_type: 'wgt', contract_version: 'v4' }); + d.s.emit('device:exit', { reason: 'clean_exit', detail: 'onDestroy' }); await sleep(250); + assert.equal((await row(d.id)).offline_reason, 'clean_exit'); + d.s.close(); await sleep(300); + const s2 = connect(); await new Promise(r => s2.on('connect', r)); // reconnect = fresh session + await registerOn(s2, { device_id: d.id, device_token: d.token, fingerprint: 'fp810002', device_info: {}, client_type: 'wgt', contract_version: 'v4' }); + await sleep(200); + assert.equal((await row(d.id)).offline_reason, null, 'cleared on (re)online — a later death starts fresh'); + s2.close(); +}); + +test('silent + honesty: client-sent silent/garbage REJECTED, then Offline-with-no-signal -> server silent', async () => { + const d = await provisionPaired('810003', { client_type: 'apk', contract_version: 'v4' }); // also the old-client / no-signal case + d.s.emit('device:exit', { reason: 'silent' }); // client must NOT be able to assert silent + d.s.emit('device:exit', { reason: 'kaboom' }); // unknown -> rejected, never fabricated + await sleep(300); + assert.equal((await row(d.id)).offline_reason, null, 'neither client value was accepted'); + d.s.close(); await sleep(5800); + const r = await row(d.id); + assert.equal(r.status, 'offline'); assert.equal(r.offline_reason, 'silent', 'server infers silent on Offline (correct)'); +}); + +test('beacon endpoint: valid token sets reason; bad token is a silent 204 no-op', async () => { + const d = await provisionPaired('810004', { client_type: 'player', contract_version: 'v4' }); + const post = (body) => fetch(BASE + '/api/device/exit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + let res = await post({ device_id: d.id, device_token: 'WRONG', reason: 'crashed' }); + assert.equal(res.status, 204); + assert.equal((await row(d.id)).offline_reason, null, 'bad token did NOT set a reason (no oracle, no write)'); + res = await post({ device_id: d.id, device_token: d.token, reason: 'clean_exit', detail: 'pagehide' }); + assert.equal(res.status, 204); await sleep(150); + assert.equal((await row(d.id)).offline_reason, 'clean_exit', 'valid-token beacon set the reason'); + d.s.close(); +}); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 2dc6344..56d0b38 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -417,7 +417,7 @@ module.exports = function setupDeviceSocket(io) { pendingOfflines.delete(existing.device_id); } evictPriorSocket(existing.device_id, socket.id); - db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") + db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?") .run(getClientIp(socket), existing.device_id); socket.emit('device:registered', { device_id: existing.device_id, device_token: newToken, status: 'online' }); // If device was already claimed by a user, tell the player it's paired @@ -527,7 +527,7 @@ module.exports = function setupDeviceSocket(io) { } evictPriorSocket(device_id, socket.id); sessionSettle.accepted(device_id); // #148 patch2: (re)arm the settle window on an accepted connection - db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") + db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now'), offline_reason = NULL, offline_reason_at = NULL, offline_detail = NULL WHERE id = ?") .run(getClientIp(socket), device_id); // #143: past the validateDeviceToken gate above the stored token is @@ -836,6 +836,21 @@ module.exports = function setupDeviceSocket(io) { .run(ota_status ?? 'none', ota_target_version ?? null, ota_attempts ?? 0, device_id); }); + // Exit-signal contract v1 — the device's best-effort "last gasp": it announces its manner of death + // (crashed | clean_exit) as (usually) its final act. We record it; when the device then goes Offline + // the annotation is applied (else 'silent'). ADDITIVE — never touches offline detection. Cleared on + // (re)online (the register UPDATEs) so a stale reason can't mislabel a later death. The same canonical + // shape also arrives via the beacon POST /api/device/exit for reliable-on-unload delivery. + socket.on('device:exit', (data) => { + if (!requireDeviceAuth() || !currentDeviceId) return; + const { device_id, reason, detail } = data || {}; + if (device_id && device_id !== currentDeviceId) return; // forged/mismatched -> no-op + const e = liveness.sanitizeExitReason(reason, detail); // unknown -> null -> falls to 'silent' + if (!e) return; + db.prepare("UPDATE devices SET offline_reason = ?, offline_reason_at = strftime('%s','now'), offline_detail = ? WHERE id = ?") + .run(e.reason, e.detail, currentDeviceId); + }); + // Play event logging (proof-of-play) socket.on('device:play-event', (data) => { if (!requireDeviceAuth()) return; @@ -959,10 +974,14 @@ module.exports = function setupDeviceSocket(io) { const activeNow = heartbeat.getConnection(deviceId); if (activeNow && activeNow.socketId !== closingSocketId) return; - db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now') WHERE id = ?").run(deviceId); + // Exit-signal contract: resolve manner-of-death. If the device announced a reason before dying + // (offline_reason non-NULL, set by device:exit/beacon this session), keep it; else -> 'silent' + // (no signal arrived). COALESCE makes this a pure annotation — offline detection is unchanged. + db.prepare("UPDATE devices SET status = 'offline', updated_at = strftime('%s','now'), offline_reason = COALESCE(offline_reason, 'silent'), offline_reason_at = COALESCE(offline_reason_at, strftime('%s','now')) WHERE id = ?").run(deviceId); heartbeat.removeConnection(deviceId); logDeviceStatus(deviceId, 'offline'); - emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline' }); + const _off = db.prepare("SELECT offline_reason, offline_detail, client_type FROM devices WHERE id = ?").get(deviceId) || {}; + emitToDeviceWorkspace(dashboardNs, deviceId, 'dashboard:device-status', { device_id: deviceId, status: 'offline', liveness: 'offline', offline_reason: _off.offline_reason || 'silent', offline_detail: _off.offline_detail || null, client_type: _off.client_type || null }); // If this device was leading a wall, reassign leadership to the next // online member so playback stays driven. diff --git a/tizen/js/app.js b/tizen/js/app.js index 3ac68ad..b208c64 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -640,6 +640,7 @@ if (e.keyCode === 10009) { // Samsung RETURN / BACK if (!elSetup.classList.contains('hidden')) { stopKeepAwake(); stopWatchdog(); // FIX A/B: clear timers cleanly before the app exits + sendExitSignal('clean_exit', 'back_key'); // exit-signal: operator BACK-key exit = confident clean_exit try { tizen.application.getCurrentApplication().exit(); } catch (x) {} } else { if (socket) { try { socket.disconnect(); } catch (x) {} } @@ -658,6 +659,42 @@ startKeepAwake(); // FIX A: assert + re-assert keep-awake on an interval document.addEventListener('visibilitychange', onVisibility); // FIX B: suspend/resume fast-path startWatchdog(); // FIX B (hardened): server-silence liveness backstop + + // Exit-signal contract v1 — best-effort last gasp. crashed: window.onerror / unhandledrejection. + // clean_exit: operator BACK-key exit (below) + pagehide(persisted=false, a real unload not a bfcache + // suspend). Sends over BOTH the live socket (reliable when still connected, e.g. BACK-key / in-app + // crash) AND navigator.sendBeacon (reliable-on-unload — Chromium webview); the server dedups. Honesty: + // only these two confident categories; uncertain -> nothing -> server infers 'silent'. A Tizen system/ + // launcher terminate fires NO hook here -> correctly falls to 'silent'. Idempotent (first wins). + var __exitSent = false; + function sendExitSignal(reason, detail) { + try { + if (__exitSent) return; + if (reason !== 'crashed' && reason !== 'clean_exit') return; + if (!deviceId || !deviceToken || !serverUrl) return; // unpaired -> nothing to attribute + __exitSent = true; + var d = (typeof detail === 'string' && detail) ? detail.slice(0, 200) : undefined; + if (socket && socket.connected) { try { socket.emit('device:exit', { device_id: deviceId, reason: reason, detail: d }); } catch (e) {} } + if (navigator.sendBeacon) { + var body = JSON.stringify({ device_id: deviceId, device_token: deviceToken, reason: reason, detail: d }); + navigator.sendBeacon(serverUrl.replace(/\/+$/, '') + '/api/device/exit', new Blob([body], { type: 'application/json' })); + } + } catch (e) { /* a dying app must never throw */ } + } + window.addEventListener('error', function (ev) { + if (!ev) return; + var isResourceError = ev.target && ev.target !== window && (ev.target.src || ev.target.href); // img/script load fail is NOT a crash + if (isResourceError) return; + sendExitSignal('crashed', (ev.error && ev.error.message) || ev.message || 'error'); + }); + window.addEventListener('unhandledrejection', function (ev) { + var r = ev && ev.reason; + sendExitSignal('crashed', (r && (r.message || String(r))) || 'unhandledrejection'); + }); + window.addEventListener('pagehide', function (ev) { + if (ev && ev.persisted) return; // bfcache suspend (may restore) — NOT a death; the watchdog owns it + sendExitSignal('clean_exit', 'pagehide'); + }); if (serverUrl && deviceId && deviceToken) { // A2: render cached content IMMEDIATELY so a cold-start/offline TV isn't blank while the socket // connects (or if it can't). The socket's fresh device:playlist-update replaces it on connect.