mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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:
parent
2772d1fc4d
commit
8ad2258e7c
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
129
server/test/v4-exit-signal-phase3.test.js
Normal file
129
server/test/v4-exit-signal-phase3.test.js
Normal 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');
|
||||
});
|
||||
95
server/test/v4-exit-signal.test.js
Normal file
95
server/test/v4-exit-signal.test.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue