feat: app-ending signal (exit-signal contract v1) — server + APK + .wgt + /player

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>
This commit is contained in:
ScreenTinker 2026-07-08 15:32:40 -05:00
parent 2772d1fc4d
commit 8ad2258e7c
12 changed files with 464 additions and 6 deletions

View file

@ -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() {

View file

@ -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 */
}
}
}

View file

@ -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()

View file

@ -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

View file

@ -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 };

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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');
});

View file

@ -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();
});

View file

@ -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.

View file

@ -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.