mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 17:32:53 -06:00
Compare commits
No commits in common. "v1.9.2-beta3" and "main" have entirely different histories.
v1.9.2-bet
...
main
|
|
@ -11,8 +11,8 @@ android {
|
||||||
applicationId = "com.remotedisplay.player"
|
applicationId = "com.remotedisplay.player"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 33
|
versionCode = 31
|
||||||
versionName = "1.9.2-beta3"
|
versionName = "1.9.2-beta1"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|
|
||||||
|
|
@ -149,29 +149,4 @@ module.exports = {
|
||||||
// (device_id, content_id, status) reports within this window. A status CHANGE
|
// (device_id, content_id, status) reports within this window. A status CHANGE
|
||||||
// has a different key and passes immediately. In-memory; resets on restart.
|
// has a different key and passes immediately. In-memory; resets on restart.
|
||||||
contentAckDedupMs: parseInt(process.env.CONTENT_ACK_DEDUP_MS) || 10000,
|
contentAckDedupMs: parseInt(process.env.CONTENT_ACK_DEDUP_MS) || 10000,
|
||||||
|
|
||||||
// #143 content-ack RATE budget (lib/content-ack-limiter.js), layered on top of the
|
|
||||||
// dedup above. Caps TOTAL acks per device per window REGARDLESS of differing
|
|
||||||
// content_id — the flood the dedup misses (a device cycling 2-4 ids makes every
|
|
||||||
// ack look unique, so dedup never fires, yet aggregate volume blocks the loop).
|
|
||||||
// TUNING GUESSES — validate against Bold's real fleet. Legit playlist cadence is
|
|
||||||
// roughly <=1 ack/s/device; the flood is many/s. 20 per 10s (=2/s) sits above
|
|
||||||
// legit and below the flood. Easy to retune via env.
|
|
||||||
contentAckMaxPerWindow: parseInt(process.env.CONTENT_ACK_MAX_PER_WINDOW) || 20,
|
|
||||||
contentAckRateWindowMs: parseInt(process.env.CONTENT_ACK_RATE_WINDOW_MS) || 10000,
|
|
||||||
|
|
||||||
// #143 fingerprint-reclaim liveness. A reinstalled app (same fingerprint, no
|
|
||||||
// device_id, has pairing_code) may reclaim its old device's identity once that
|
|
||||||
// device is gone by RUNTIME signals: no live socket AND last heartbeat older than
|
|
||||||
// this settle window. Previously an effective 24h calendar grace treated a device
|
|
||||||
// merely offline <24h as "active", so a legitimately-gone device (liveConn=false,
|
|
||||||
// status=offline, stale heartbeat) could never reclaim and retried every ~2s,
|
|
||||||
// flooding logs (Bold beta1). Settle = the max reclaim wait; keep it comfortably
|
|
||||||
// above heartbeatTimeout (45s) so a brief blip isn't mistaken for "gone".
|
|
||||||
// SECURITY TRADEOFF: this also shortens the anti-(fingerprint-theft) window from
|
|
||||||
// 24h — raise it to re-tighten, at the cost of reinstall latency. Tuning guess.
|
|
||||||
reclaimSettleSeconds: parseInt(process.env.RECLAIM_SETTLE_SECONDS) || 300,
|
|
||||||
// #143 throttle the reclaim-deferred log to once per device per window, so a
|
|
||||||
// retrying/stuck device can't flood stdout (same discipline as the content-ack shed log).
|
|
||||||
reclaimRejectLogWindowMs: parseInt(process.env.RECLAIM_REJECT_LOG_WINDOW_MS) || 60000,
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -234,10 +234,6 @@ const migrations = [
|
||||||
// schema.sql creates these on fresh installs; this covers existing DBs.
|
// schema.sql creates these on fresh installs; this covers existing DBs.
|
||||||
"CREATE TABLE IF NOT EXISTS event_loop_lag (id INTEGER PRIMARY KEY AUTOINCREMENT, sampled_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), mean_ms REAL NOT NULL, p50_ms REAL NOT NULL, p99_ms REAL NOT NULL, max_ms REAL NOT NULL, band TEXT NOT NULL DEFAULT 'normal')",
|
"CREATE TABLE IF NOT EXISTS event_loop_lag (id INTEGER PRIMARY KEY AUTOINCREMENT, sampled_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), mean_ms REAL NOT NULL, p50_ms REAL NOT NULL, p99_ms REAL NOT NULL, max_ms REAL NOT NULL, band TEXT NOT NULL DEFAULT 'normal')",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_event_loop_lag_sampled ON event_loop_lag(sampled_at)",
|
"CREATE INDEX IF NOT EXISTS idx_event_loop_lag_sampled ON event_loop_lag(sampled_at)",
|
||||||
// #143: operator device kill switch. blocked=1 refuses the device at the first
|
|
||||||
// register gate on its next reconnect (no restart). Hand-settable by direct SQLite:
|
|
||||||
// UPDATE devices SET blocked = 1 WHERE id = '<device_id>'; (0 to unblock)
|
|
||||||
"ALTER TABLE devices ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0",
|
|
||||||
];
|
];
|
||||||
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
|
||||||
// error means the column is already present (expected on a migrated DB) - benign.
|
// error means the column is already present (expected on a migrated DB) - benign.
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ CREATE TABLE IF NOT EXISTS devices (
|
||||||
name TEXT NOT NULL DEFAULT 'Unnamed Display',
|
name TEXT NOT NULL DEFAULT 'Unnamed Display',
|
||||||
pairing_code TEXT UNIQUE,
|
pairing_code TEXT UNIQUE,
|
||||||
status TEXT NOT NULL DEFAULT 'offline',
|
status TEXT NOT NULL DEFAULT 'offline',
|
||||||
blocked INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_heartbeat INTEGER,
|
last_heartbeat INTEGER,
|
||||||
ip_address TEXT,
|
ip_address TEXT,
|
||||||
android_version TEXT,
|
android_version TEXT,
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// #143 — content-ack flood control (the single control on the content-ack path).
|
|
||||||
//
|
|
||||||
// Folds three concerns into ONE per-device limiter so there are no competing
|
|
||||||
// limiters on this path (reconnect-throttle.js is left untouched):
|
|
||||||
// 1. #142 dedup — drop an exact (content_id, status) repeat within the dedup
|
|
||||||
// window. Legit repeat suppression; does NOT consume rate budget.
|
|
||||||
// 2. #143 per-device RATE budget — cap TOTAL non-duplicate acks per device per
|
|
||||||
// window regardless of differing content_id. This is what dedup misses: a
|
|
||||||
// device cycling 2-4 ids makes each ack look unique, so dedup never fires,
|
|
||||||
// but aggregate volume still floods the loop. Over budget -> shed silently.
|
|
||||||
// 3. #143 global pressure valve — when loop-lag (services/loop-lag.js) reports
|
|
||||||
// the CRITICAL band, shed non-essential acks even for a device within its own
|
|
||||||
// budget. Reuses the existing band + hysteresis; never fires below critical.
|
|
||||||
//
|
|
||||||
// Per-device, in-memory, resets on restart (like lastPlayLogAt / pair-lockout).
|
|
||||||
// Fixed window (counter reset per window) — simple and makes "log once per window"
|
|
||||||
// natural. `band` is injected so this is testable without the loop-lag monitor.
|
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
// deviceId -> { winStart, count, shedNotified, dup: Map(content|status -> ts) }
|
|
||||||
const state = new Map();
|
|
||||||
|
|
||||||
// Returns one of:
|
|
||||||
// { action: 'pass' } -> caller logs + emits
|
|
||||||
// { action: 'dedup' } -> drop (exact repeat)
|
|
||||||
// { action: 'shed-rate', logStart, observed, budget } -> drop (over per-device budget)
|
|
||||||
// { action: 'shed-valve' } -> drop (global critical-lag valve)
|
|
||||||
function check(deviceId, contentId, status, band = 'normal', now = Date.now()) {
|
|
||||||
let s = state.get(deviceId);
|
|
||||||
if (!s) { s = { winStart: now, count: 0, shedNotified: false, dup: new Map() }; state.set(deviceId, s); }
|
|
||||||
|
|
||||||
// Roll the fixed rate window.
|
|
||||||
if (now - s.winStart >= config.contentAckRateWindowMs) {
|
|
||||||
s.winStart = now;
|
|
||||||
s.count = 0;
|
|
||||||
s.shedNotified = false;
|
|
||||||
// Bound the dedup map: drop entries older than the dedup window.
|
|
||||||
for (const [k, t] of s.dup) if (now - t >= config.contentAckDedupMs) s.dup.delete(k);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1) Dedup — exact (content, status) repeat within the dedup window. Does NOT
|
|
||||||
// consume rate budget (it's a legit repeat we simply suppress).
|
|
||||||
const key = `${contentId}|${status}`;
|
|
||||||
if (now - (s.dup.get(key) || 0) < config.contentAckDedupMs) return { action: 'dedup' };
|
|
||||||
s.dup.set(key, now);
|
|
||||||
|
|
||||||
// 2) Per-device rate budget — always applies, counts all non-duplicate acks.
|
|
||||||
s.count++;
|
|
||||||
if (s.count > config.contentAckMaxPerWindow) {
|
|
||||||
const logStart = !s.shedNotified; // log ONCE per device per window when shedding starts
|
|
||||||
s.shedNotified = true;
|
|
||||||
return { action: 'shed-rate', logStart, observed: s.count, budget: config.contentAckMaxPerWindow };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Global valve — extra shedding only under critical lag; a within-budget device
|
|
||||||
// in a non-critical band is never touched here.
|
|
||||||
if (band === 'critical') return { action: 'shed-valve' };
|
|
||||||
|
|
||||||
return { action: 'pass' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() { state.clear(); } // tests
|
|
||||||
module.exports = { check, reset };
|
|
||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "screentinker",
|
"name": "screentinker",
|
||||||
"version": "1.9.2-beta3",
|
"version": "1.9.2-beta1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "screentinker",
|
"name": "screentinker",
|
||||||
"version": "1.9.2-beta3",
|
"version": "1.9.2-beta1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-node": "^5.2.1",
|
"@azure/msal-node": "^5.2.1",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "screentinker",
|
"name": "screentinker",
|
||||||
"version": "1.9.2-beta3",
|
"version": "1.9.2-beta1",
|
||||||
"description": "ScreenTinker - Digital Signage Management Server",
|
"description": "ScreenTinker - Digital Signage Management Server",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -79,14 +79,6 @@ function sample() {
|
||||||
const tag = band !== prev ? ` (was ${prev})` : '';
|
const tag = band !== prev ? ` (was ${prev})` : '';
|
||||||
console.log(`[loop-lag] band=${band}${tag} mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms`);
|
console.log(`[loop-lag] band=${band}${tag} mean=${snap.mean_ms}ms p99=${snap.p99_ms}ms max=${snap.max_ms}ms`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #143 global pressure valve — log ONLY the band edge (open/close), not per shed
|
|
||||||
// message. When critical, deviceSocket sheds non-essential acks (it reads getBand()).
|
|
||||||
if (band === 'critical' && prev !== 'critical') {
|
|
||||||
console.warn(`[shed] global valve OPEN — loop-lag critical (p99=${snap.p99_ms}ms); shedding non-essential device messages (content-acks). reconnects + dashboard still processed.`);
|
|
||||||
} else if (prev === 'critical' && band !== 'critical') {
|
|
||||||
console.log(`[shed] global valve CLOSED — loop-lag recovered (band=${band}, p99=${snap.p99_ms}ms)`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneLag() {
|
function pruneLag() {
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// #143 integration — a device cycling DIFFERENT content ids at high rate (the case
|
|
||||||
// dedup misses) is rate-limited; an under-budget device passes every ack. Observed
|
|
||||||
// via the server log: passing acks log `Device <id> content <cid>: ready`; over-
|
|
||||||
// budget drops are silent except ONE `[content-ack] shedding device <id>` line.
|
|
||||||
// Normal band (default lag thresholds), unique PORT 3985.
|
|
||||||
|
|
||||||
const { test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { spawn } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const ioClient = require('socket.io-client');
|
|
||||||
|
|
||||||
const PORT = 3985;
|
|
||||||
const BASE = `http://127.0.0.1:${PORT}`;
|
|
||||||
const DATA_DIR = path.join(os.tmpdir(), 'st-flood-' + crypto.randomBytes(4).toString('hex'));
|
|
||||||
const LOG = path.join(os.tmpdir(), 'st-flood-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
||||||
let proc;
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
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',
|
|
||||||
CONTENT_ACK_MAX_PER_WINDOW: '5', CONTENT_ACK_RATE_WINDOW_MS: '10000', CONTENT_ACK_DEDUP_MS: '50',
|
|
||||||
},
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
});
|
|
||||||
let up = false;
|
|
||||||
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
|
|
||||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
||||||
});
|
|
||||||
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
||||||
|
|
||||||
function provision() {
|
|
||||||
const code = String(crypto.randomInt(100000, 1000000));
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
|
|
||||||
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
|
|
||||||
setTimeout(() => resolve(null), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function openRegistered(dev) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
sock.on('connect', () => sock.emit('device:register', { device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } }));
|
|
||||||
sock.on('device:registered', () => resolve(sock));
|
|
||||||
sock.on('device:auth-error', () => reject(new Error('auth-error')));
|
|
||||||
setTimeout(() => reject(new Error('register timeout')), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const linesFor = (id, needle) => fs.readFileSync(LOG, 'utf8').split('\n').filter(l => l.includes(id) && l.includes(needle)).length;
|
|
||||||
|
|
||||||
test('#143: cycling 4 different content ids at high rate is rate-limited (dedup misses it)', async () => {
|
|
||||||
const dev = await provision();
|
|
||||||
assert.ok(dev, 'provisioned');
|
|
||||||
const sock = await openRegistered(dev);
|
|
||||||
const ids = ['a', 'b', 'c', 'd'];
|
|
||||||
// 15 acks, ~100ms apart -> each id repeats every ~400ms (> 50ms dedup) so dedup
|
|
||||||
// never fires; budget is 5/10s, so only 5 pass and the rest are shed.
|
|
||||||
for (let i = 0; i < 15; i++) { sock.emit('device:content-ack', { device_id: dev.id, content_id: ids[i % 4], status: 'ready' }); await sleep(100); }
|
|
||||||
await sleep(400);
|
|
||||||
try { sock.close(); } catch { /* */ }
|
|
||||||
|
|
||||||
const passed = linesFor(dev.id, 'content '); // `Device <id> content <cid>: ready`
|
|
||||||
const shedStart = linesFor(dev.id, 'shedding device');
|
|
||||||
assert.equal(passed, 5, `exactly the budget (5) passed/logged, got ${passed}`);
|
|
||||||
assert.ok(shedStart >= 1, 'a single shed-start line was logged when flood control engaged');
|
|
||||||
assert.ok(shedStart === 1, `shed-start logged ONCE per window (no per-drop flood), got ${shedStart}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a device under budget has every ack processed', async () => {
|
|
||||||
const dev = await provision();
|
|
||||||
const sock = await openRegistered(dev);
|
|
||||||
for (const id of ['p', 'q', 'r', 's']) { sock.emit('device:content-ack', { device_id: dev.id, content_id: id, status: 'ready' }); await sleep(60); }
|
|
||||||
await sleep(300);
|
|
||||||
try { sock.close(); } catch { /* */ }
|
|
||||||
assert.equal(linesFor(dev.id, 'content '), 4, 'all 4 under-budget acks processed');
|
|
||||||
assert.equal(linesFor(dev.id, 'shedding device'), 0, 'no shedding for an under-budget device');
|
|
||||||
});
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// #143 — deterministic unit tests for the folded content-ack limiter (dedup +
|
|
||||||
// per-device rate budget + global critical-lag valve). Injected `now`/`band`, no
|
|
||||||
// sockets. DATA_DIR set before require so config's jwt-secret write goes to a temp
|
|
||||||
// dir (not the repo). Rate params pinned via env for clarity.
|
|
||||||
|
|
||||||
const os = require('node:os');
|
|
||||||
const path = require('node:path');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-ack143-' + crypto.randomBytes(4).toString('hex'));
|
|
||||||
process.env.CONTENT_ACK_MAX_PER_WINDOW = '5';
|
|
||||||
process.env.CONTENT_ACK_RATE_WINDOW_MS = '10000';
|
|
||||||
process.env.CONTENT_ACK_DEDUP_MS = '2000';
|
|
||||||
|
|
||||||
const { test, beforeEach } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const limiter = require('../lib/content-ack-limiter');
|
|
||||||
|
|
||||||
const T0 = 1_000_000;
|
|
||||||
beforeEach(() => limiter.reset());
|
|
||||||
|
|
||||||
function tally(results) {
|
|
||||||
const t = { pass: 0, dedup: 0, 'shed-rate': 0, 'shed-valve': 0, logStarts: 0 };
|
|
||||||
for (const r of results) { t[r.action]++; if (r.logStart) t.logStarts++; }
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('#143 REGRESSION: cycling 2-4 DIFFERENT content ids (evading dedup) IS rate-limited', () => {
|
|
||||||
// 4 ids, spaced 600ms so each id repeats every 2400ms (> 2000ms dedup) -> dedup
|
|
||||||
// never fires (the exact case it misses), so the RATE budget must cap them.
|
|
||||||
const ids = ['a', 'b', 'c', 'd'];
|
|
||||||
const out = [];
|
|
||||||
for (let i = 0; i < 12; i++) out.push(limiter.check('dev', ids[i % 4], 'ready', 'normal', T0 + i * 600));
|
|
||||||
const t = tally(out);
|
|
||||||
assert.equal(t.dedup, 0, 'dedup must NOT mask the cycling flood (proves rate limiter is what caps)');
|
|
||||||
assert.equal(t.pass, 5, 'first MAX(5) pass');
|
|
||||||
assert.equal(t['shed-rate'], 7, 'the remaining 7 are rate-shed');
|
|
||||||
assert.equal(t.logStarts, 1, 'shedding logged exactly once per device per window');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unique ids (dedup never matches): under budget passes, over budget sheds', () => {
|
|
||||||
const out = [];
|
|
||||||
for (let i = 0; i < 8; i++) out.push(limiter.check('dev', 'u' + i, 'ready', 'normal', T0 + i * 10));
|
|
||||||
const t = tally(out);
|
|
||||||
assert.equal(t.pass, 5);
|
|
||||||
assert.equal(t['shed-rate'], 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a device exactly at budget passes every ack', () => {
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const v = limiter.check('dev', 'u' + i, 'ready', 'normal', T0 + i * 10);
|
|
||||||
assert.equal(v.action, 'pass', `ack ${i + 1} (<=budget) passes`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dedup still works AND does not consume rate budget (no regression)', () => {
|
|
||||||
assert.equal(limiter.check('dev', 'a', 'ready', 'normal', T0).action, 'pass'); // count 1
|
|
||||||
assert.equal(limiter.check('dev', 'a', 'ready', 'normal', T0 + 100).action, 'dedup'); // repeat -> dedup
|
|
||||||
assert.equal(limiter.check('dev', 'a', 'ready', 'normal', T0 + 200).action, 'dedup'); // repeat -> dedup
|
|
||||||
// budget should still have 4 left (the 2 dedups didn't count): 4 distinct pass, 5th sheds
|
|
||||||
for (const id of ['b', 'c', 'd', 'e']) assert.equal(limiter.check('dev', id, 'ready', 'normal', T0 + 300).action, 'pass');
|
|
||||||
assert.equal(limiter.check('dev', 'f', 'ready', 'normal', T0 + 300).action, 'shed-rate', 'budget consumed only by non-duplicates');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('global valve: CRITICAL sheds an in-budget device; NORMAL leaves it untouched', () => {
|
|
||||||
assert.equal(limiter.check('A', 'x', 'ready', 'critical', T0).action, 'shed-valve', 'critical sheds even under budget');
|
|
||||||
assert.equal(limiter.check('B', 'x', 'ready', 'normal', T0).action, 'pass', 'normal-band healthy device is unaffected by the valve');
|
|
||||||
// a healthy device under normal band is never valve-touched across many acks
|
|
||||||
for (let i = 0; i < 5; i++) assert.equal(limiter.check('C', 'u' + i, 'ready', 'normal', T0 + i).action, 'pass');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('over-budget rate shedding takes precedence over the valve', () => {
|
|
||||||
let v;
|
|
||||||
for (let i = 0; i < 6; i++) v = limiter.check('dev', 'u' + i, 'ready', 'critical', T0 + i);
|
|
||||||
assert.equal(v.action, 'shed-rate', 'a flooding device reports rate shedding, not just valve');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('window reset: count + shed-notified reset, so a new window logs shedding again', () => {
|
|
||||||
for (let i = 0; i < 7; i++) limiter.check('dev', 'u' + i, 'ready', 'normal', T0 + i * 10); // sheds, logs once
|
|
||||||
// next window (>10s later): fresh budget
|
|
||||||
for (let i = 0; i < 5; i++) assert.equal(limiter.check('dev', 'w' + i, 'ready', 'normal', T0 + 11000 + i).action, 'pass');
|
|
||||||
const v = limiter.check('dev', 'w9', 'ready', 'normal', T0 + 11000 + 100);
|
|
||||||
assert.equal(v.action, 'shed-rate');
|
|
||||||
assert.equal(v.logStart, true, 'shedding is logged once in the NEW window too');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('per-device isolation: one device flooding does not shed another', () => {
|
|
||||||
for (let i = 0; i < 10; i++) limiter.check('STORM', 'u' + i, 'ready', 'normal', T0 + i); // STORM sheds
|
|
||||||
assert.equal(limiter.check('NEIGHBOR', 'x', 'ready', 'normal', T0 + 11).action, 'pass');
|
|
||||||
});
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// #143 Step 2 integration — the global loop-lag pressure valve. Lag thresholds are
|
|
||||||
// forced to 0 so the band is CRITICAL from the first sample; rate budget is set high
|
|
||||||
// so we isolate the VALVE (not the per-device rate limit). Under critical: content-
|
|
||||||
// acks are shed (no log/emit), while a reconnect AND an HTTP/dashboard request still
|
|
||||||
// process, and the valve edge is logged once. Unique PORT 3986.
|
|
||||||
|
|
||||||
const { test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { spawn } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const ioClient = require('socket.io-client');
|
|
||||||
|
|
||||||
const PORT = 3986;
|
|
||||||
const BASE = `http://127.0.0.1:${PORT}`;
|
|
||||||
const DATA_DIR = path.join(os.tmpdir(), 'st-valve-' + crypto.randomBytes(4).toString('hex'));
|
|
||||||
const LOG = path.join(os.tmpdir(), 'st-valve-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
||||||
let proc;
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
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',
|
|
||||||
LAG_CRITICAL_MS: '1', LAG_ELEVATED_MS: '1', LAG_SAMPLE_INTERVAL_MS: '150', // tiny thresholds (NOT 0 — config uses `|| default`, 0 is falsy) -> band critical immediately
|
|
||||||
CONTENT_ACK_MAX_PER_WINDOW: '1000', CONTENT_ACK_RATE_WINDOW_MS: '10000', // high, so the VALVE is what sheds
|
|
||||||
},
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
});
|
|
||||||
let up = false;
|
|
||||||
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
|
|
||||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
||||||
await sleep(600); // let the valve open (>=1 sample at 150ms in the critical band)
|
|
||||||
});
|
|
||||||
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
||||||
|
|
||||||
function provision() {
|
|
||||||
const code = String(crypto.randomInt(100000, 1000000));
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
|
|
||||||
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
|
|
||||||
setTimeout(() => resolve(null), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function openRegistered(dev) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
let done = false; const finish = (v) => { if (!done) { done = true; resolve(v); } };
|
|
||||||
sock.on('connect', () => sock.emit('device:register', { device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } }));
|
|
||||||
sock.on('device:registered', () => finish({ sock, registered: true }));
|
|
||||||
sock.on('device:auth-error', () => finish({ sock, registered: false }));
|
|
||||||
setTimeout(() => finish({ sock, registered: false }), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test('valve OPEN under critical: content-acks are shed, reconnect + HTTP still process', async () => {
|
|
||||||
const log0 = fs.readFileSync(LOG, 'utf8');
|
|
||||||
assert.ok(/\[shed\] global valve OPEN/.test(log0), 'valve edge logged once on entering critical');
|
|
||||||
|
|
||||||
// 1) content-acks under critical -> shed (no log/emit)
|
|
||||||
const dev = await provision();
|
|
||||||
const { sock, registered } = await openRegistered(dev);
|
|
||||||
assert.ok(registered, 'a device can still register/reconnect under critical (reconnects always processed)');
|
|
||||||
for (let i = 0; i < 6; i++) { sock.emit('device:content-ack', { device_id: dev.id, content_id: 'v' + i, status: 'ready' }); await sleep(40); }
|
|
||||||
await sleep(300);
|
|
||||||
try { sock.close(); } catch { /* */ }
|
|
||||||
const contentLines = fs.readFileSync(LOG, 'utf8').split('\n').filter(l => l.includes(dev.id) && l.includes('content ')).length;
|
|
||||||
assert.equal(contentLines, 0, 'all content-acks shed by the valve under critical (none logged/emitted)');
|
|
||||||
|
|
||||||
// 2) a fresh reconnect still registers (reconnects are never shed)
|
|
||||||
const dev2 = await provision();
|
|
||||||
const r2 = await openRegistered(dev2);
|
|
||||||
assert.ok(r2.registered, 'reconnect processed under critical');
|
|
||||||
try { r2.sock.close(); } catch { /* */ }
|
|
||||||
|
|
||||||
// 3) HTTP / dashboard path still serves under critical
|
|
||||||
const res = await fetch(BASE + '/api/status');
|
|
||||||
assert.equal(res.status, 200, 'HTTP/dashboard requests always processed');
|
|
||||||
const body = await res.json();
|
|
||||||
assert.equal(body.loop_lag.band, 'critical', 'sanity: band really is critical');
|
|
||||||
});
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// #143 (highest-priority) — auth short-circuit fix + operator kill switch.
|
|
||||||
// - A provisioned device whose token is NULLed is now REJECTED (Bold 75c2a08a:
|
|
||||||
// nulling the token used to RE-PROVISION the device instead of locking it out).
|
|
||||||
// - A `blocked=1` device is refused at the first register gate (the enforceable
|
|
||||||
// kill switch), settable by DIRECT SQLite edit while the server runs (no restart).
|
|
||||||
// - First pairing (token-less, no device_id) and normal auth still work.
|
|
||||||
// Direct DB edits below mimic the operator hand-editing SQLite during an outage.
|
|
||||||
// Unique PORT 3987 (not 3982-3986).
|
|
||||||
|
|
||||||
const { test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { spawn } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const ioClient = require('socket.io-client');
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
|
|
||||||
const PORT = 3987;
|
|
||||||
const BASE = `http://127.0.0.1:${PORT}`;
|
|
||||||
const DATA_DIR = path.join(os.tmpdir(), 'st-block-' + crypto.randomBytes(4).toString('hex'));
|
|
||||||
const LOG = path.join(os.tmpdir(), 'st-block-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
||||||
const DB_PATH = path.join(DATA_DIR, 'db', 'remote_display.db');
|
|
||||||
let proc;
|
|
||||||
let tdb; // ONE long-lived operator-style connection (mirrors how the server holds one);
|
|
||||||
// avoids the cross-process WAL checkpoint churn of many short-lived openers.
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
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 { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
|
|
||||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
||||||
tdb = new Database(DB_PATH);
|
|
||||||
tdb.pragma('busy_timeout = 3000');
|
|
||||||
});
|
|
||||||
after(() => { try { tdb && tdb.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
||||||
|
|
||||||
// Operator's direct hand-edit of SQLite while the server is running (no restart) —
|
|
||||||
// a single persistent connection, as a `sqlite3` session would be.
|
|
||||||
function dbEdit(sql, ...params) { return tdb.prepare(sql).run(...params).changes; }
|
|
||||||
|
|
||||||
function register(payload) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
const got = { registered: false, authError: false, errorMsg: null, playlist: false, newId: null, newToken: null };
|
|
||||||
const finish = () => { try { sock.close(); } catch { /* */ } resolve(got); };
|
|
||||||
sock.on('connect', () => sock.emit('device:register', payload));
|
|
||||||
sock.on('device:registered', (d) => { got.registered = true; got.newId = d.device_id; got.newToken = d.device_token; setTimeout(finish, 250); });
|
|
||||||
sock.on('device:playlist-update', () => { got.playlist = true; });
|
|
||||||
sock.on('device:auth-error', (e) => { got.authError = true; got.errorMsg = e && e.error; finish(); });
|
|
||||||
setTimeout(finish, 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function provision() {
|
|
||||||
const g = await register({ pairing_code: String(crypto.randomInt(100000, 1000000)) });
|
|
||||||
return g.registered ? { id: g.newId, token: g.newToken } : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
test('#143 repro: a provisioned device whose token is NULLed is REJECTED (was: re-provisioned)', async () => {
|
|
||||||
const dev = await provision();
|
|
||||||
assert.ok(dev && dev.token, 'provisioned with a token');
|
|
||||||
assert.equal(dbEdit('UPDATE devices SET device_token = NULL WHERE id = ?', dev.id), 1, 'operator nulled the token');
|
|
||||||
const got = await register({ device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } });
|
|
||||||
assert.ok(got.authError, 'null-token device is rejected (auth-error)');
|
|
||||||
assert.ok(!got.registered, 'and must NOT register / re-provision');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('kill switch: blocked=1 refuses at the first gate (no register, no playlist)', async () => {
|
|
||||||
const dev = await provision();
|
|
||||||
assert.equal(dbEdit('UPDATE devices SET blocked = 1 WHERE id = ?', dev.id), 1, 'operator blocked the device');
|
|
||||||
// no server restart — the block takes effect on the very next reconnect
|
|
||||||
const got = await register({ device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } });
|
|
||||||
assert.ok(got.authError && got.errorMsg === 'Device blocked', 'refused with Device blocked');
|
|
||||||
assert.ok(!got.registered, 'no register');
|
|
||||||
assert.ok(!got.playlist, 'no playlist build (refused at the first gate)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('unblocking (blocked=0) lets the same device connect again', async () => {
|
|
||||||
const dev = await provision();
|
|
||||||
dbEdit('UPDATE devices SET blocked = 1 WHERE id = ?', dev.id);
|
|
||||||
let got = await register({ device_id: dev.id, device_token: dev.token, device_info: {} });
|
|
||||||
assert.ok(!got.registered, 'blocked first');
|
|
||||||
dbEdit('UPDATE devices SET blocked = 0 WHERE id = ?', dev.id);
|
|
||||||
got = await register({ device_id: dev.id, device_token: dev.token, device_info: {} });
|
|
||||||
assert.ok(got.registered, 'unblocked -> connects normally again');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the pairing/provisioning seam still works: a NEW token-less device first-pairs', async () => {
|
|
||||||
const got = await register({ pairing_code: String(crypto.randomInt(100000, 1000000)) });
|
|
||||||
assert.ok(got.registered, 'first pairing (no device_id) still succeeds');
|
|
||||||
assert.ok(got.newId && got.newToken, 'a fresh device_id + token are issued');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no regression: a normal device with a valid token registers + gets its playlist', async () => {
|
|
||||||
const dev = await provision();
|
|
||||||
const got = await register({ device_id: dev.id, device_token: dev.token, device_info: { app_version: 'test' } });
|
|
||||||
assert.ok(got.registered, 'valid token registers');
|
|
||||||
assert.ok(got.playlist, 'and receives its playlist');
|
|
||||||
assert.ok(!got.authError, 'no auth error');
|
|
||||||
});
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// #143 — server must tell a screen it's paired so it leaves the Connect page. The
|
|
||||||
// app leaves the Connect page ONLY on 'device:paired'. /api/provision/pair pushes
|
|
||||||
// that to a LIVE socket at pair time, but a screen paired-while-disconnected or that
|
|
||||||
// reconnects after pairing never got it and sat on Connect forever (Bold). Fix:
|
|
||||||
// re-emit 'device:paired' on reconnect when the device is paired (user_id set).
|
|
||||||
// Uses the EXISTING client event — no client/protocol change. Unique PORT 3989.
|
|
||||||
|
|
||||||
const { test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { spawn } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const ioClient = require('socket.io-client');
|
|
||||||
|
|
||||||
const PORT = 3989;
|
|
||||||
const BASE = `http://127.0.0.1:${PORT}`;
|
|
||||||
const DATA_DIR = path.join(os.tmpdir(), 'st-pair-' + crypto.randomBytes(4).toString('hex'));
|
|
||||||
const LOG = path.join(os.tmpdir(), 'st-pair-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
||||||
let proc, JWT;
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
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 { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
|
|
||||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
||||||
// first user -> admin (self-hosted), gives a workspace for the pair endpoint
|
|
||||||
const r = await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@test.local', password: 'test12345', name: 'Op' }) });
|
|
||||||
JWT = (await r.json()).token;
|
|
||||||
});
|
|
||||||
after(() => { try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
||||||
|
|
||||||
// First pairing: device registers with a pairing code -> gets its device_id + token.
|
|
||||||
function provisionWithCode(code) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
sock.on('connect', () => sock.emit('device:register', { pairing_code: code }));
|
|
||||||
sock.on('device:registered', (d) => { try { sock.close(); } catch { /* */ } resolve({ id: d.device_id, token: d.device_token }); });
|
|
||||||
setTimeout(() => resolve(null), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Operator pairs the device in the CMS (the device socket is NOT connected now).
|
|
||||||
async function pairViaApi(code, name) {
|
|
||||||
const r = await fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: code, name }) });
|
|
||||||
return r.status;
|
|
||||||
}
|
|
||||||
// A reconnect (device_id + token) — collect what the server pushes.
|
|
||||||
function reconnect(id, token) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
const got = { registered: false, paired: false, pairedName: null, playlist: false };
|
|
||||||
sock.on('connect', () => sock.emit('device:register', { device_id: id, device_token: token, device_info: { app_version: 'test' } }));
|
|
||||||
sock.on('device:registered', () => { got.registered = true; });
|
|
||||||
sock.on('device:paired', (d) => { got.paired = true; got.pairedName = d && d.name; });
|
|
||||||
sock.on('device:playlist-update', () => { got.playlist = true; });
|
|
||||||
setTimeout(() => { try { sock.close(); } catch { /* */ } resolve(got); }, 700);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const rnd = () => String(crypto.randomInt(100000, 1000000));
|
|
||||||
|
|
||||||
test('#143 repro: a device paired server-side, on reconnect, RECEIVES device:paired (leaves Connect page)', async () => {
|
|
||||||
const code = rnd();
|
|
||||||
const dev = await provisionWithCode(code);
|
|
||||||
assert.ok(dev && dev.id, 'provisioned (sits on Connect page, status=provisioning)');
|
|
||||||
assert.equal(await pairViaApi(code, 'Lobby'), 200, 'operator pairs it via the CMS while the device socket is closed');
|
|
||||||
const got = await reconnect(dev.id, dev.token);
|
|
||||||
assert.ok(got.registered, 'device reconnects');
|
|
||||||
assert.ok(got.paired, 'server pushes device:paired on reconnect (the exact event the client waits for)');
|
|
||||||
assert.equal(got.pairedName, 'Lobby', 'with the paired name');
|
|
||||||
assert.ok(got.playlist, 'and its playlist, so it can play');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a device NOT yet paired gets NO device:paired on reconnect (stays on the pairing flow)', async () => {
|
|
||||||
const code = rnd();
|
|
||||||
const dev = await provisionWithCode(code); // provisioned but never paired
|
|
||||||
const got = await reconnect(dev.id, dev.token);
|
|
||||||
assert.ok(got.registered, 'it still registers');
|
|
||||||
assert.ok(!got.paired, 'but is NOT told paired (no false pairing-complete) -> stays on Connect');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('the fix uses the existing client listener: device:paired (no new protocol)', async () => {
|
|
||||||
// The repro test above asserts the client receives 'device:paired' — the same event the
|
|
||||||
// web player (index.html) and Android (ProvisioningActivity.onPaired) already handle. This
|
|
||||||
// test documents that no new client event/protocol was introduced (server-only fix).
|
|
||||||
assert.ok(true);
|
|
||||||
});
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// #143 — fingerprint-reclaim stuck loop. A device gone by every RUNTIME signal
|
|
||||||
// (no live socket + stale heartbeat) must be reclaimable; a genuinely-live device
|
|
||||||
// must still be rejected; the deferral log must not flood. Devices are seeded by
|
|
||||||
// direct SQLite (mimics the real DB state + avoids the disconnect-debounce window
|
|
||||||
// leaving a stale liveConn). Unique PORT 3988 (not 3982-3987).
|
|
||||||
|
|
||||||
const { test, before, after } = require('node:test');
|
|
||||||
const assert = require('node:assert/strict');
|
|
||||||
const { spawn } = require('node:child_process');
|
|
||||||
const path = require('node:path');
|
|
||||||
const os = require('node:os');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const crypto = require('node:crypto');
|
|
||||||
const ioClient = require('socket.io-client');
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
|
|
||||||
const PORT = 3988;
|
|
||||||
const BASE = `http://127.0.0.1:${PORT}`;
|
|
||||||
const DATA_DIR = path.join(os.tmpdir(), 'st-recl-' + crypto.randomBytes(4).toString('hex'));
|
|
||||||
const LOG = path.join(os.tmpdir(), 'st-recl-' + crypto.randomBytes(4).toString('hex') + '.log');
|
|
||||||
const DB_PATH = path.join(DATA_DIR, 'db', 'remote_display.db');
|
|
||||||
let proc, tdb;
|
|
||||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
||||||
|
|
||||||
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', RECLAIM_SETTLE_SECONDS: '300', RECLAIM_REJECT_LOG_WINDOW_MS: '60000' },
|
|
||||||
stdio: ['ignore', logFd, logFd],
|
|
||||||
});
|
|
||||||
let up = false;
|
|
||||||
for (let i = 0; i < 80; i++) { try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ } await sleep(250); }
|
|
||||||
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
|
|
||||||
tdb = new Database(DB_PATH); tdb.pragma('busy_timeout = 3000'); tdb.pragma('foreign_keys = OFF');
|
|
||||||
});
|
|
||||||
after(() => { try { tdb && tdb.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
|
|
||||||
|
|
||||||
// Seed a device + its fingerprint link directly (no socket -> no lingering liveConn).
|
|
||||||
function seedDevice(fp, { token, heartbeatAgo }) {
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
tdb.prepare("INSERT INTO devices (id, status, last_heartbeat, device_token) VALUES (?, 'offline', strftime('%s','now') - ?, ?)").run(id, heartbeatAgo, token);
|
|
||||||
tdb.prepare('INSERT INTO device_fingerprints (fingerprint, device_id) VALUES (?, ?)').run(fp, id);
|
|
||||||
return { id, token };
|
|
||||||
}
|
|
||||||
function staleHeartbeat(id, ago) { tdb.prepare("UPDATE devices SET last_heartbeat = strftime('%s','now') - ? WHERE id = ?").run(ago, id); }
|
|
||||||
|
|
||||||
function attempt(payload) { // one-shot register; resolves and closes
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
const got = { registered: false, newId: null, authError: false, errorMsg: null };
|
|
||||||
const finish = () => { try { sock.close(); } catch { /* */ } resolve(got); };
|
|
||||||
sock.on('connect', () => sock.emit('device:register', payload));
|
|
||||||
sock.on('device:registered', (d) => { got.registered = true; got.newId = d.device_id; setTimeout(finish, 150); });
|
|
||||||
sock.on('device:auth-error', (e) => { got.authError = true; got.errorMsg = e && e.error; finish(); });
|
|
||||||
setTimeout(finish, 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function connectLive(payload) { // keeps the socket open (live connection); caller closes
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const sock = ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
|
|
||||||
sock.on('connect', () => sock.emit('device:register', payload));
|
|
||||||
sock.on('device:registered', () => resolve({ sock, registered: true }));
|
|
||||||
sock.on('device:auth-error', () => resolve({ sock, registered: false }));
|
|
||||||
setTimeout(() => resolve({ sock, registered: false }), 4000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const rnd = () => String(crypto.randomInt(100000, 1000000));
|
|
||||||
|
|
||||||
test('#143 repro: a gone device (no live conn + stale heartbeat) is reclaimable', async () => {
|
|
||||||
const fp = 'fp-gone-' + crypto.randomBytes(4).toString('hex');
|
|
||||||
const dev = seedDevice(fp, { token: 'tok', heartbeatAgo: 99999 }); // ~27h stale, never connected
|
|
||||||
const r = await attempt({ pairing_code: rnd(), fingerprint: fp }); // no device_id -> reclaim path
|
|
||||||
assert.ok(r.registered, 'reclaim SUCCEEDS for a gone device');
|
|
||||||
assert.equal(r.newId, dev.id, 'it reclaims the SAME device identity');
|
|
||||||
assert.ok(!r.authError, 'no rejection');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('no regression: a genuinely live device REJECTS a fingerprint reclaim', async () => {
|
|
||||||
const fp = 'fp-live-' + crypto.randomBytes(4).toString('hex');
|
|
||||||
const dev = seedDevice(fp, { token: 'tok2', heartbeatAgo: 10 });
|
|
||||||
const live = await connectLive({ device_id: dev.id, device_token: 'tok2', device_info: {} });
|
|
||||||
assert.ok(live.registered, 'device is live (has a connection)');
|
|
||||||
const r = await attempt({ pairing_code: rnd(), fingerprint: fp });
|
|
||||||
assert.ok(r.authError && !r.registered, 'reclaim of a LIVE device is rejected (abuse protection intact)');
|
|
||||||
try { live.sock.close(); } catch { /* */ }
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clear-on-leave: after disconnect, liveConn is cleared so a (stale) device reclaims', async () => {
|
|
||||||
const fp = 'fp-leave-' + crypto.randomBytes(4).toString('hex');
|
|
||||||
const dev = seedDevice(fp, { token: 'tok3', heartbeatAgo: 99999 });
|
|
||||||
const live = await connectLive({ device_id: dev.id, device_token: 'tok3', device_info: {} });
|
|
||||||
assert.ok(live.registered);
|
|
||||||
// while live, reclaim is rejected (liveConn present)
|
|
||||||
let r = await attempt({ pairing_code: rnd(), fingerprint: fp });
|
|
||||||
assert.ok(!r.registered, 'rejected while a live connection exists');
|
|
||||||
// leave: close + wait past the 5s offline-debounce so removeConnection runs
|
|
||||||
try { live.sock.close(); } catch { /* */ }
|
|
||||||
await sleep(6000);
|
|
||||||
staleHeartbeat(dev.id, 99999); // the live register bumped last_heartbeat; re-stale it
|
|
||||||
r = await attempt({ pairing_code: rnd(), fingerprint: fp });
|
|
||||||
assert.ok(r.registered, 'after disconnect cleared liveConn, the gone device reclaims');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('log noise: a retried reclaim logs at most once per device per window', async () => {
|
|
||||||
const fp = 'fp-log-' + crypto.randomBytes(4).toString('hex');
|
|
||||||
const dev = seedDevice(fp, { token: 'tok4', heartbeatAgo: 5 }); // recent -> reclaim deferred
|
|
||||||
const live = await connectLive({ device_id: dev.id, device_token: 'tok4', device_info: {} });
|
|
||||||
for (let i = 0; i < 4; i++) { const r = await attempt({ pairing_code: rnd(), fingerprint: fp }); assert.ok(r.authError, 'each retry is deferred'); }
|
|
||||||
try { live.sock.close(); } catch { /* */ }
|
|
||||||
await sleep(200);
|
|
||||||
const lines = fs.readFileSync(LOG, 'utf8').split('\n').filter(l => l.includes('reclaim deferred for ' + dev.id)).length;
|
|
||||||
assert.ok(lines <= 1, `at most one deferral log per window (got ${lines}); no double-log / per-2s flood`);
|
|
||||||
});
|
|
||||||
|
|
@ -7,8 +7,6 @@ const config = require('../config');
|
||||||
const heartbeat = require('../services/heartbeat');
|
const heartbeat = require('../services/heartbeat');
|
||||||
const commandQueue = require('../lib/command-queue');
|
const commandQueue = require('../lib/command-queue');
|
||||||
const reconnectThrottle = require('../lib/reconnect-throttle');
|
const reconnectThrottle = require('../lib/reconnect-throttle');
|
||||||
const contentAckLimiter = require('../lib/content-ack-limiter');
|
|
||||||
const loopLag = require('../services/loop-lag');
|
|
||||||
|
|
||||||
// Debounce window for marking a device offline on socket disconnect. Brief
|
// Debounce window for marking a device offline on socket disconnect. Brief
|
||||||
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
|
// flap (Wi-Fi blip, Engine.IO ping miss, server-side eviction-then-reconnect)
|
||||||
|
|
@ -31,13 +29,11 @@ const OFFLINE_DEBOUNCE_MS = 5000;
|
||||||
const lastPlayLogAt = new Map();
|
const lastPlayLogAt = new Map();
|
||||||
const PLAY_LOG_MIN_GAP_MS = 2000;
|
const PLAY_LOG_MIN_GAP_MS = 2000;
|
||||||
|
|
||||||
// #142 dedup + #143 per-device rate budget + global loop-lag valve for content-acks
|
// #142 content-ack dedup. An older app can spam "content <id>: ready" for the same
|
||||||
// all live in one control: lib/content-ack-limiter.js (required above as
|
// item; each was logged + emitted individually (secondary load). Suppress identical
|
||||||
// contentAckLimiter). Kept out of this file so there is a single limiter on the path.
|
// (device_id, content_id, status) reports within config.contentAckDedupMs. A status
|
||||||
|
// CHANGE has a different key and passes immediately. In-memory; resets on restart.
|
||||||
// #143 fingerprint-reclaim deferral log throttle: deviceId -> last-logged ms, so a
|
const lastContentAck = new Map();
|
||||||
// device retrying reclaim every ~2s logs at most once per reclaimRejectLogWindowMs.
|
|
||||||
const lastReclaimRejectLogAt = new Map();
|
|
||||||
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription');
|
||||||
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
|
// Phase 2.3: deviceRoom() resolves a device_id to its workspace room so
|
||||||
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
|
// dashboardNs.emit can be scoped instead of broadcast platform-wide.
|
||||||
|
|
@ -267,24 +263,6 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
socket.on('device:register', (data) => {
|
socket.on('device:register', (data) => {
|
||||||
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
|
const { pairing_code, device_id, device_token, device_info, fingerprint } = data;
|
||||||
|
|
||||||
// #143 operator KILL SWITCH — the FIRST gate, before the fingerprint block,
|
|
||||||
// the reconnect throttle, any DB writes, or playlist build. A device flagged
|
|
||||||
// `blocked` is refused immediately. Settable by DIRECT SQLite during an outage
|
|
||||||
// (dashboard down): UPDATE devices SET blocked = 1 WHERE id = '<device_id>';
|
|
||||||
// The row is re-read on every register, so a hand-edited UPDATE takes effect on
|
|
||||||
// the device's NEXT reconnect with NO server restart. Unblock: blocked = 0.
|
|
||||||
// Unlike nulling the token (#143: that re-provisioned instead of locking out),
|
|
||||||
// this is an explicit, enforceable block.
|
|
||||||
if (device_id) {
|
|
||||||
const blk = db.prepare('SELECT blocked FROM devices WHERE id = ?').get(device_id);
|
|
||||||
if (blk && blk.blocked) {
|
|
||||||
console.warn(`[blocked] refused device ${device_id} (operator block) from ${getClientIp(socket)}`);
|
|
||||||
socket.emit('device:auth-error', { error: 'Device blocked' });
|
|
||||||
process.nextTick(() => { try { socket.disconnect(true); } catch (_) { /* */ } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track device fingerprint to prevent reinstall abuse
|
// Track device fingerprint to prevent reinstall abuse
|
||||||
if (fingerprint) {
|
if (fingerprint) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -298,31 +276,20 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
const oldDevice = db.prepare('SELECT * FROM devices WHERE id = ?').get(existing.device_id);
|
||||||
if (oldDevice) {
|
if (oldDevice) {
|
||||||
// Fingerprint reclaim guard: a leaked/duplicated fingerprint shouldn't be enough
|
// Fingerprint reclaim guard: a leaked/duplicated fingerprint shouldn't be enough
|
||||||
// to take over a LIVE device. #143: decide "still alive" from RUNTIME signals —
|
// to take over a live device. Reject the reclaim if the device is currently
|
||||||
// a live socket, OR a genuinely recent heartbeat (within the settle window). The
|
// online OR has been online within the last 24h — by then a real reinstall has
|
||||||
// old check used `secondsSince < 24h`, which treated a device merely offline <24h
|
// had plenty of time to come back, but a credential thief is more likely caught.
|
||||||
// as "active": a legitimately-gone device (liveConn=false, status=offline, stale
|
|
||||||
// heartbeat) could never reclaim and retried every ~2s, flooding logs (Bold beta1
|
|
||||||
// / 2febcaa9, 1984694c, 139159eb). NOT a missing clear — liveConn IS removed on
|
|
||||||
// disconnect + the offline-timeout sweep, and status IS set offline on both; the
|
|
||||||
// 24h TIME gate was the cause. A device gone by every runtime signal is reclaimable.
|
|
||||||
const liveConn = heartbeat.getConnection(existing.device_id);
|
const liveConn = heartbeat.getConnection(existing.device_id);
|
||||||
|
const RECLAIM_GRACE_SECONDS = 24 * 60 * 60;
|
||||||
const lastBeat = oldDevice.last_heartbeat || 0;
|
const lastBeat = oldDevice.last_heartbeat || 0;
|
||||||
const secondsSince = Math.floor(Date.now() / 1000) - lastBeat;
|
const secondsSince = Math.floor(Date.now() / 1000) - lastBeat;
|
||||||
const stillAlive = !!liveConn || secondsSince < config.reclaimSettleSeconds;
|
if (liveConn || (oldDevice.status === 'online') || secondsSince < RECLAIM_GRACE_SECONDS) {
|
||||||
if (stillAlive) {
|
console.warn(`Fingerprint reclaim rejected for ${existing.device_id}: device active (status=${oldDevice.status}, ${secondsSince}s since last heartbeat, liveConn=${!!liveConn})`);
|
||||||
// Log at most once per device per window so a retrying/stuck device can't flood stdout.
|
|
||||||
const nowMs = Date.now();
|
|
||||||
if (nowMs - (lastReclaimRejectLogAt.get(existing.device_id) || 0) >= config.reclaimRejectLogWindowMs) {
|
|
||||||
lastReclaimRejectLogAt.set(existing.device_id, nowMs);
|
|
||||||
console.warn(`Fingerprint reclaim deferred for ${existing.device_id}: still settling (status=${oldDevice.status}, ${secondsSince}s since heartbeat, liveConn=${!!liveConn}); reclaimable after ${config.reclaimSettleSeconds}s offline`);
|
|
||||||
}
|
|
||||||
socket.emit('device:auth-error', {
|
socket.emit('device:auth-error', {
|
||||||
error: `This display was recently active. If you reinstalled the app, retry after it has been offline for ${config.reclaimSettleSeconds} seconds.`
|
error: 'This display is currently active. If you reinstalled the app, the original device must be offline for 24 hours before its slot can be reclaimed.'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastReclaimRejectLogAt.delete(existing.device_id); // reclaim proceeding — clear any deferral log state
|
|
||||||
|
|
||||||
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
|
// Fingerprint matched — this is a reinstalled app reconnecting to its old device.
|
||||||
// Issue a fresh token so the app can authenticate going forward.
|
// Issue a fresh token so the app can authenticate going forward.
|
||||||
|
|
@ -386,16 +353,9 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
// reconnected" and reading as connection instability (#134 — there were 1415
|
// reconnected" and reading as connection instability (#134 — there were 1415
|
||||||
// "reconnected" logs against only ~30 real socket connects and 0 heartbeat timeouts).
|
// "reconnected" logs against only ~30 real socket connects and 0 heartbeat timeouts).
|
||||||
const isPlaylistRefresh = currentDeviceId === device_id;
|
const isPlaylistRefresh = currentDeviceId === device_id;
|
||||||
// #143 AUTH FIX: an already-provisioned device (it has a row — every row,
|
// Validate device token (skip for legacy devices that don't have a token yet)
|
||||||
// even `provisioning`, is created WITH a token) presenting a null/empty/
|
if (device.device_token && !validateDeviceToken(device_id, device_token)) {
|
||||||
// invalid token is NOT authenticated — reject and disconnect. The old guard
|
console.warn(`Invalid device token for ${device_id} from ${getClientIp(socket)} — received_len=${(device_token || '').length}, stored_len=${device.device_token.length}, received_prefix=${(device_token || '').substring(0, 8)}, stored_prefix=${device.device_token.substring(0, 8)}`);
|
||||||
// `device.device_token && !validate(...)` short-circuited on a NULL stored
|
|
||||||
// token, so nulling a device's token RE-PROVISIONED it (auth skipped + a
|
|
||||||
// fresh token minted) instead of locking it out (Bold #143 / 75c2a08a).
|
|
||||||
// validateDeviceToken already returns false for null-stored/missing/mismatch.
|
|
||||||
// First pairing is the pairing_code path below (no device_id) — unaffected.
|
|
||||||
if (!validateDeviceToken(device_id, device_token)) {
|
|
||||||
console.warn(`Invalid/missing device token for ${device_id} from ${getClientIp(socket)} — received_len=${(device_token || '').length}, has_stored_token=${!!device.device_token}`);
|
|
||||||
socket.emit('device:auth-error', { error: 'Invalid device token' });
|
socket.emit('device:auth-error', { error: 'Invalid device token' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -428,10 +388,12 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
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') WHERE id = ?")
|
||||||
.run(getClientIp(socket), device_id);
|
.run(getClientIp(socket), device_id);
|
||||||
|
|
||||||
// #143: past the validateDeviceToken gate above the stored token is
|
// Generate token for legacy devices that don't have one yet
|
||||||
// guaranteed non-null, so we just echo it back. The old "mint a token for a
|
let tokenToSend = device.device_token;
|
||||||
// null-token device" path is removed — that was the re-provisioning vector.
|
if (!tokenToSend) {
|
||||||
const tokenToSend = device.device_token;
|
tokenToSend = generateDeviceToken();
|
||||||
|
db.prepare('UPDATE devices SET device_token = ? WHERE id = ?').run(tokenToSend, device_id);
|
||||||
|
}
|
||||||
|
|
||||||
if (device_info) {
|
if (device_info) {
|
||||||
db.prepare(`UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ?,
|
db.prepare(`UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ?, render_width = ?, render_height = ?,
|
||||||
|
|
@ -445,16 +407,6 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
heartbeat.registerConnection(device_id, socket.id);
|
heartbeat.registerConnection(device_id, socket.id);
|
||||||
socket.join(device_id);
|
socket.join(device_id);
|
||||||
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
|
socket.emit('device:registered', { device_id, device_token: tokenToSend, status: 'online' });
|
||||||
// #143: a device paired/claimed server-side (user_id set) that RECONNECTS must be told
|
|
||||||
// it's paired — the app leaves the Connect page ONLY on 'device:paired' (web: hides the
|
|
||||||
// setup screen; Android ProvisioningActivity.onPaired -> MainActivity). The
|
|
||||||
// /api/provision/pair endpoint pushes device:paired to a LIVE socket at pair time
|
|
||||||
// (server.js), but a screen paired while disconnected — or that reconnects after pairing
|
|
||||||
// — never received it and sat on the Connect page forever showing the URL (Bold #143).
|
|
||||||
// Re-send the exact event the client already listens for; no client change needed.
|
|
||||||
if (device.user_id) {
|
|
||||||
socket.emit('device:paired', { device_id, name: device.name || 'Display' });
|
|
||||||
}
|
|
||||||
logDeviceStatus(device_id, 'online');
|
logDeviceStatus(device_id, 'online');
|
||||||
// Flush any commands/playlist-updates queued while this device was offline.
|
// Flush any commands/playlist-updates queued while this device was offline.
|
||||||
commandQueue.flushQueue(deviceNs, device_id, buildPlaylistPayload);
|
commandQueue.flushQueue(deviceNs, device_id, buildPlaylistPayload);
|
||||||
|
|
@ -633,18 +585,13 @@ module.exports = function setupDeviceSocket(io) {
|
||||||
if (!requireDeviceAuth()) return;
|
if (!requireDeviceAuth()) return;
|
||||||
const { device_id, content_id, status } = data;
|
const { device_id, content_id, status } = data;
|
||||||
if (device_id !== currentDeviceId) return;
|
if (device_id !== currentDeviceId) return;
|
||||||
// #142 dedup + #143 per-device rate budget + global critical-lag valve, in one
|
// #142: drop repeats of the same (device, content, status) within the dedup
|
||||||
// control. Anything but 'pass' is dropped BEFORE the log+emit (that per-ack work
|
// window. Only a change (new content/status) or a report after the window
|
||||||
// is the cost we shed). Drops are SILENT except a single line per device per
|
// logs+emits, so a device spamming the same "ready" can't add load.
|
||||||
// window when rate-shedding STARTS (re-logging per drop would recreate the
|
const ackKey = `${device_id}|${content_id}|${status}`;
|
||||||
// flood). The valve's open/close is logged once at the band edge in loop-lag.
|
const nowAck = Date.now();
|
||||||
const verdict = contentAckLimiter.check(device_id, content_id, status, loopLag.getBand());
|
if (nowAck - (lastContentAck.get(ackKey) || 0) < config.contentAckDedupMs) return;
|
||||||
if (verdict.action !== 'pass') {
|
lastContentAck.set(ackKey, nowAck);
|
||||||
if (verdict.action === 'shed-rate' && verdict.logStart) {
|
|
||||||
console.warn(`[content-ack] shedding device ${device_id}: ${verdict.observed}/${verdict.budget} per ${config.contentAckRateWindowMs}ms — flood control engaged`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
console.log(`Device ${device_id} content ${content_id}: ${status}`);
|
||||||
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status });
|
emitToDeviceWorkspace(dashboardNs, device_id, 'dashboard:content-ack', { device_id, content_id, status });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue