screentinker/server/test/session-settle.test.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

42 lines
1.6 KiB
JavaScript

'use strict';
// #148 patch2 — session-settle DECISION unit tests. The liveness safeguard is the load-bearing
// one: a dead incumbent must NEVER be held (else we recreate #148 by stranding the device).
process.env.SESSION_SETTLE_WINDOW_MS = '2500';
const { test, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const ss = require('../lib/session-settle');
beforeEach(() => ss.reset());
test('HOLD: live incumbent + a socket accepted within the window -> refuse the duplicate', () => {
ss.accepted('dev', 1000);
assert.equal(ss.shouldHold('dev', true, 1500), true); // 500ms into the 2500ms window, incumbent alive
});
test('LIVENESS SAFEGUARD (critical): a DEAD incumbent is NEVER held -> accept the new socket', () => {
ss.accepted('dev', 1000);
// Within the window, but the caller reports the incumbent is not alive -> must NOT hold,
// so the new socket is accepted and the corpse evicted (device never stranded offline).
assert.equal(ss.shouldHold('dev', false, 1500), false);
});
test('window elapsed -> accept (a genuine move to a new socket still works)', () => {
ss.accepted('dev', 1000);
assert.equal(ss.shouldHold('dev', true, 1000 + 2500), false); // exactly at the edge
assert.equal(ss.shouldHold('dev', true, 1000 + 5000), false);
});
test('first connection (no prior accept) -> accept', () => {
assert.equal(ss.shouldHold('fresh', true, 9999), false);
});
test('bounded: sweep drops entries idle past 4x the window', () => {
ss.accepted('a', 1000);
ss.accepted('b', 1000);
assert.equal(ss._size(), 2);
ss.sweep(1000 + 2500 * 4 + 1);
assert.equal(ss._size(), 0);
});