screentinker/server/lib/session-settle.js
ScreenTinker e1ce36b2a8 fix(#148) patch2: per-device session-settle debounce — absorb duplicate-socket storms
Field-safe SERVER net. A device opening duplicate/rapid sockets (the APK duplicate-socket bug,
separate track) currently churns through evictions during the reconnect-throttle's 30s
post-restart WARM-UP (only the hard ceiling 20 applies then, so an 8-in-9s burst passes
undamped and each new socket evicts the prior). This makes the server absorb it: a thrashing
PAIRED device converges to ONE stable connection and stays online.

- lib/session-settle.js (decision only; bounded, swept): shouldHold(deviceId, incumbentAlive)
  — true only when a socket was accepted for this device within SESSION_SETTLE_WINDOW_MS
  (config, default 2500ms) AND the incumbent is alive. Warm-up-independent.
- deviceSocket register gate (just before evictPriorSocket): if a LIVE incumbent exists and
  we're inside the window, SOFT-REFUSE the new socket (device:throttled reason=session_settle
  + disconnect) and keep the incumbent; else accept + evict + (re)arm the window.
- LIVENESS SAFEGUARD (load-bearing): only hold when the incumbent socket is actually in the
  /device namespace — a dead/half-open incumbent is replaced, NEVER stranding the device (max
  hold is the 2.5s window from the incumbent's accept, then any new socket is accepted).
- Soft refusal, NEVER a quarantine (reuses patch1's paired-safe philosophy); single-session
  enforcement intact for a legitimate move; unpaired/abusive flapping still caught by the
  existing limiters. O(1), no loop impact.

Tests (liveness first-class): live incumbent holds + DEAD incumbent replaced (not stranded);
storm of 6 sockets converges to ONE, stays online, not quarantined (during warm-up); single-
session move past the window replaces cleanly; unit decision + bounded sweep. The
evicted-socket-rearm test shrinks its settle window so it still exercises the eviction path.
Suite 336/336.
2026-07-02 19:12:46 -05:00

49 lines
2.2 KiB
JavaScript

'use strict';
// #148 patch2 — per-device SESSION-SETTLE debounce (the field-safe net). When a device opens
// duplicate/rapid sockets in a burst, keep it on ONE live incumbent connection and soft-refuse
// the duplicates, so it converges and STAYS ONLINE — instead of churning through evictions.
// This closes the gap the reconnect-throttle's 30s post-restart warm-up leaves open (during
// warm-up only the hard ceiling applies, so an 8-in-9s duplicate burst passes undamped and
// each new socket evicts the prior). This debounce is warm-up-INDEPENDENT.
//
// DECISION ONLY: this module never touches sockets or the DB. The caller supplies whether the
// incumbent is actually alive (the LIVENESS SAFEGUARD) and does the refuse/disconnect. Bounded:
// one small timestamp per device_id, idle entries swept.
const config = require('../config');
const state = new Map(); // device_id -> lastAcceptedMs
// Should this NEW socket be soft-refused (keep the incumbent)? TRUE only when a socket was
// accepted for this device within the settle window AND the incumbent is genuinely alive.
// incumbentAlive is the load-bearing safeguard: if the incumbent is dead/half-open the caller
// passes false and we return false -> accept the new socket, never stranding the device.
function shouldHold(deviceId, incumbentAlive, now = Date.now()) {
if (!incumbentAlive) return false;
const last = state.get(deviceId) || 0;
return (now - last) < config.sessionSettleWindowMs;
}
// Record that a socket was ACCEPTED (evicted+registered) for this device — (re)arms the window.
function accepted(deviceId, now = Date.now()) { state.set(deviceId, now); }
// Bounded: drop entries idle well past the window.
function sweep(now = Date.now()) {
let n = 0;
for (const [k, t] of state) if (now - t > config.sessionSettleWindowMs * 4) { state.delete(k); n++; }
return n;
}
let sweepTimer = null;
function startSweep() {
if (sweepTimer) return sweepTimer;
sweepTimer = setInterval(() => sweep(), 60000);
if (sweepTimer.unref) sweepTimer.unref();
return sweepTimer;
}
function reset() { state.clear(); }
function _size() { return state.size; }
module.exports = { shouldHold, accepted, sweep, startSweep, reset, _size };