import assert from 'node:assert/strict'; globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } }; globalThis.CustomEvent = class CustomEvent { constructor(type, init) { this.type = type; this.detail = init?.detail; } }; const dispatched = []; globalThis.document = { dispatchEvent(e) { dispatched.push(e.type); } }; const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js'); const P = EnhancedSecureWebRTCManager.prototype; const T = EnhancedSecureWebRTCManager.MESSAGE_TYPES; const TIMEOUTS = EnhancedSecureWebRTCManager.TIMEOUTS; // Fake timers: the retry cycle is self-rescheduling by design (bounded only by // RECONNECT_MAX_DURATION), so real timers would keep the test process alive. // Driving them by hand also lets the backoff schedule itself be asserted. const realTimers = { setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout, setInterval: globalThis.setInterval, clearInterval: globalThis.clearInterval }; let scheduled = []; globalThis.setTimeout = (callback, delay) => { const timer = { kind: 'timeout', callback, delay, cleared: false }; scheduled.push(timer); return timer; }; globalThis.setInterval = (callback, delay) => { const timer = { kind: 'interval', callback, delay, cleared: false }; scheduled.push(timer); return timer; }; globalThis.clearTimeout = (timer) => { if (timer) timer.cleared = true; }; globalThis.clearInterval = (timer) => { if (timer) timer.cleared = true; }; const pending = () => scheduled.filter((t) => !t.cleared); // A real timeout stops pending once it fires; mirror that so the next lookup // finds the newly scheduled retry rather than the spent one. const fire = (timer) => { timer.cleared = true; return timer.callback(); }; // Let queued microtasks settle: several recovery entry points fire an async // restart without awaiting it. const settle = async () => { for (let i = 0; i < 8; i += 1) await Promise.resolve(); }; const FP_A = 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99'; const FP_B = '99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA'; const sdpWith = (fp) => `v=0\r\no=- 1 1 IN IP4 0.0.0.0\r\ns=-\r\na=fingerprint:sha-256 ${fp}\r\na=setup:actpass\r\n`; // Minimal manager stub carrying only the recovery surface under test. function makeManager(overrides = {}) { const statuses = []; const sent = []; const ui = []; const mgr = { isVerified: true, isInitiator: true, intentionalDisconnect: false, reconnectionFailedNotificationSent: false, // Raw frames written straight to the channel (heartbeats/probes), as // opposed to `sent`, which collects sendSystemMessage payloads. _sentFrames: [], _heartbeatConfig: { enabled: true, lastHeartbeat: 0 }, _livenessProbeAt: 0, _livenessArmed: true, isConnected: () => true, dataChannel: { readyState: 'open', send: (raw) => { mgr._sentFrames.push(JSON.parse(raw)); } }, peerConnection: { connectionState: 'disconnected', signalingState: 'stable', currentRemoteDescription: { sdp: sdpWith(FP_A) }, localDescription: { sdp: sdpWith(FP_B) }, createOffer: async () => ({ type: 'offer', sdp: sdpWith(FP_B) }), createAnswer: async () => ({ type: 'answer', sdp: sdpWith(FP_B) }), setLocalDescription: async () => {}, setRemoteDescription: async (d) => { mgr._applied.push(d.type); } }, _applied: [], _reconnect: { phase: 'idle', attempts: 0, startedAt: 0, graceTimer: null, retryTimer: null, restartTimer: null, pendingRole: null }, _activeTimers: new Set(), _lastInboundAt: 0, _livenessTimer: null, _heartbeatTimer: null, _secureLog() {}, _clearVerificationStates() { mgr._verificationCleared = true; }, _verificationCleared: false, onStatusChange: (s) => statuses.push(s), deliverMessageToUI: (m) => ui.push(m), processMessageQueue() { mgr._queueFlushed = true; }, _queueFlushed: false, waitForIceGathering: async () => true, sendSystemMessage: async (m) => { sent.push(m); return true; }, // Real implementations under test _trackActiveTimer: P._trackActiveTimer, _noteInboundActivity: P._noteInboundActivity, handleHeartbeat: P.handleHeartbeat, _sendHeartbeat: P._sendHeartbeat, _checkLiveness: P._checkLiveness, isReconnecting: P.isReconnecting, _resetReconnectState: P._resetReconnectState, _onPathDegraded: P._onPathDegraded, _onPathLost: P._onPathLost, _onPathRecovered: P._onPathRecovered, _attemptIceRestart: P._attemptIceRestart, _scheduleReconnectRetry: P._scheduleReconnectRetry, _sendIceRestartOffer: P._sendIceRestartOffer, _currentRemoteDtlsFingerprint: P._currentRemoteDtlsFingerprint, _assertSameRemoteIdentity: P._assertSameRemoteIdentity, _handleIceRestartSignal: P._handleIceRestartSignal, _giveUpAutoReconnect: P._giveUpAutoReconnect, _extractDTLSFingerprintFromSDP: P._extractDTLSFingerprintFromSDP, _validateDTLSFingerprint: P._validateDTLSFingerprint, ...overrides }; scheduled = []; return { mgr, statuses, sent, ui }; } try { // ── the heartbeat handler exists and answers probes ────────────────────── // It used to be dispatched to but never defined, so every inbound heartbeat // threw a TypeError and liveness was never actually observed. { assert.equal(typeof P.handleHeartbeat, 'function', 'handleHeartbeat must exist'); // A probe must be answered: that reply is the only thing that proves this // side is alive when its own timers are throttled by a backgrounded tab. const { mgr } = makeManager(); mgr._lastInboundAt = 0; mgr.handleHeartbeat({ type: 'heartbeat', ack: false }); assert.ok(mgr._lastInboundAt > 0, 'heartbeat must refresh the liveness clock'); assert.equal(mgr._sentFrames.length, 1); assert.equal(mgr._sentFrames[0].ack, true, 'a probe must be acked'); // An ack must NOT be acked, or the two sides ping-pong forever. const { mgr: acked } = makeManager(); acked.handleHeartbeat({ type: 'heartbeat', ack: true }); assert.deepEqual(acked._sentFrames, [], 'an ack must not be answered'); } // ── the answerer starts its watchdog even on an already-open channel ───── // `ondatachannel` can hand over a channel that is ALREADY 'open', so the // 'open' event has been dispatched before onopen is assigned. That left the // answering side with no heartbeats and no liveness watchdog at all — the // peer whose network was fine kept showing "connected" indefinitely because // nothing on that side was running to notice the other one had vanished. { const started = []; const base = { isVerified: false, pendingSASCode: null, _secureLog() {}, establishConnection: async () => {}, initializeFileTransfer() {}, _notifyVerificationReadyIfPossible() {}, initiateVerification() {}, processMessageQueue() {}, onStatusChange() {}, _resetReconnectState() {}, _teardownRecoveryLifecycleListeners() {}, _noteInboundActivity() {}, startHeartbeat() { started.push(Date.now()); }, setupDataChannel: P.setupDataChannel }; // Channel already open when handed over: the handler must still run. const already = { ...base }; already.setupDataChannel({ readyState: 'open', send() {} }); await settle(); assert.equal(started.length, 1, 'an already-open channel must still start the watchdog'); // Normal case: the event fires, and the handler must not run twice. started.length = 0; const later = { ...base }; const channel = { readyState: 'connecting', send() {} }; later.setupDataChannel(channel); channel.readyState = 'open'; await channel.onopen(); await settle(); assert.equal(started.length, 1, 'the open handler must run exactly once'); } // ── a peer never heard from is not a dead peer ─────────────────────────── // Right after connecting, the two sides finish SAS verification at different // moments, so one can be probing while the other still cannot answer. // Without a baseline, that silence used to read as death and tore down a // healthy, freshly established session. { const { mgr, statuses } = makeManager({ _livenessArmed: false }); mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000); mgr._checkLiveness(); await settle(); assert.deepEqual(statuses, [], 'silence before the first frame must not start recovery'); assert.deepEqual(mgr._sentFrames, [], 'and must not even probe yet'); // One frame from the peer establishes the baseline; from then on it counts. mgr._noteInboundActivity(); assert.equal(mgr._livenessArmed, true); mgr._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 10_000); mgr._checkLiveness(); assert.equal(mgr._sentFrames.length, 1, 'a known peer that goes quiet is probed'); mgr._resetReconnectState(); } // ── a peer answers probes before it has finished verifying ─────────────── // The heartbeat path is gated on the channel, not on isVerified: a human has // to compare the SAS code, and for that whole window the other side would // otherwise be unable to answer and would be declared dead. { const { mgr } = makeManager({ isVerified: false, isConnected: () => false }); mgr.handleHeartbeat({ type: 'heartbeat', ack: false }); assert.equal(mgr._sentFrames.length, 1, 'an unverified peer must still answer a probe'); assert.equal(mgr._sentFrames[0].ack, true); } // ── liveness: silence prompts a probe, only an unanswered probe kills ──── { // Busy conversation: recent inbound activity must NOT trip the watchdog. const { mgr, statuses } = makeManager(); mgr._lastInboundAt = Date.now(); mgr._checkLiveness(); assert.deepEqual(statuses, [], 'recent traffic must not trigger recovery'); assert.deepEqual(mgr._sentFrames, [], 'no probe while the peer is chatting'); // Silence alone must NOT declare the path dead — a backgrounded tab has // its timers throttled and legitimately goes quiet. It only earns a probe. const { mgr: quiet, statuses: quietStatuses } = makeManager(); quiet._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000); quiet._checkLiveness(); assert.deepEqual(quietStatuses, [], 'silence alone must not start recovery'); assert.equal(quiet._sentFrames.length, 1, 'silence must trigger a probe'); assert.equal(quiet._sentFrames[0].ack, false); assert.ok(quiet._livenessProbeAt > 0); // The peer answers → still alive, no recovery, probe cleared. quiet.handleHeartbeat({ type: 'heartbeat', ack: true }); quiet._checkLiveness(); assert.deepEqual(quietStatuses, [], 'an answered probe proves the path is alive'); assert.equal(quiet._livenessProbeAt, 0); // A peer whose tab the OS froze cannot answer anything — no JavaScript // runs in it at all. But ICE consent checks live in the browser's network // stack, not the page's thread, so a 'connected' ICE state proves the peer // is still reachable and the silence is a sleeping tab. Tearing the // session down here is what broke a healthy chat every time a phone // locked its screen. const { mgr: asleep, statuses: asleepStatuses } = makeManager(); asleep.peerConnection.connectionState = 'connected'; asleep._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000); asleep._checkLiveness(); asleep._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000); asleep._checkLiveness(); await settle(); assert.deepEqual(asleepStatuses, [], 'a silent peer on a healthy ICE path must be left alone'); assert.equal(asleep.isReconnecting(), false); // And since the answer would change nothing, it must not even be asked: // probing here was pure traffic for as long as the peer's phone slept. assert.deepEqual(asleep._sentFrames, [], 'a healthy ICE path must not be probed at all'); assert.equal(asleep._livenessProbeAt, 0); asleep._resetReconnectState(); // Probe goes unanswered past its deadline → the path really is dead. // This is the Wi-Fi → LTE case: nothing closes, nothing errors, packets // just stop and readyState still reads 'open'. const { mgr: dead, statuses: deadStatuses } = makeManager(); dead._lastInboundAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_AFTER + 1000); dead._checkLiveness(); // sends the probe dead._livenessProbeAt = Date.now() - (TIMEOUTS.LIVENESS_PROBE_TIMEOUT + 1000); dead._checkLiveness(); // deadline passed await settle(); assert.ok(deadStatuses.includes('reconnecting'), 'an unanswered probe must start recovery'); dead._resetReconnectState(); } // ── an unverified session is never dragged into recovery ──────────────── { const { mgr, statuses } = makeManager({ isVerified: false }); mgr._lastInboundAt = Date.now() - 10 * 60 * 1000; mgr._checkLiveness(); mgr._onPathDegraded('ice_disconnected'); mgr._onPathLost('ice_failed'); await settle(); assert.deepEqual(statuses, [], 'recovery must not run before verification'); } // ── degraded path: UI says reconnecting, session is NOT torn down ──────── { const { mgr, statuses } = makeManager(); mgr._onPathDegraded('ice_disconnected'); assert.deepEqual(statuses, ['reconnecting']); assert.equal(mgr._verificationCleared, false, 'a transient drop must not clear verification'); assert.equal(mgr.isVerified, true, 'keys and SAS verification survive a path glitch'); assert.equal(mgr.isReconnecting(), true); // The restart is held back for the grace window, because 'disconnected' // usually clears on its own. const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE); assert.ok(grace, 'a grace window must be scheduled before spending a restart'); // A second event while already recovering must not restart the cycle. mgr._onPathDegraded('ice_disconnected'); assert.deepEqual(statuses, ['reconnecting'], 'recovery must not be re-entered'); mgr._resetReconnectState(); assert.equal(grace.cleared, true, 'reset must clear the grace timer'); } // ── the grace window is sized to the browser's own ICE timings ─────────── // 'disconnected' arrives after ~5 s of missed consent responses — ordinary // packet loss reaches that — and the browser then holds it ~25 s before // 'failed'. That window is where self-healing happens, and a phone with its // screen off produces these episodes constantly. Restarting at the start of // it answered every one with a renegotiation that broke a connection about // to recover. https://blog.mozilla.org/webrtc/ice-disconnected-not/ { assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE >= 5000, 'must outlast the ~5 s of loss that produces "disconnected" in the first place'); assert.ok(TIMEOUTS.ICE_DISCONNECT_GRACE + TIMEOUTS.ICE_RESTART_TIMEOUT < 30000, 'but a restart must still complete before the browser gives up at ~30 s'); } // ── the path recovering during the grace window costs no restart ───────── { const { mgr, statuses, sent } = makeManager(); mgr._onPathDegraded('ice_disconnected'); const grace = pending().find((t) => t.delay === TIMEOUTS.ICE_DISCONNECT_GRACE); mgr.peerConnection.connectionState = 'connected'; fire(grace); await settle(); assert.deepEqual(sent, [], 'a self-healing glitch must not renegotiate'); assert.deepEqual(statuses, ['reconnecting', 'connected']); mgr._resetReconnectState(); } // ── role split: the offerer restarts, the answerer asks ───────────────── { const { mgr, sent } = makeManager({ isInitiator: true }); mgr._reconnect.startedAt = Date.now(); await mgr._attemptIceRestart(); assert.equal(sent.length, 1); assert.equal(sent[0].type, T.ICE_RESTART_OFFER, 'offerer drives the restart'); assert.ok(sent[0].sdp, 'restart offer carries SDP'); mgr._resetReconnectState(); // Both sides offering at once is glare, and with no signalling server // there is no referee — so the answerer asks instead of acting. const { mgr: answerer, sent: answererSent } = makeManager({ isInitiator: false }); answerer._reconnect.startedAt = Date.now(); await answerer._attemptIceRestart(); assert.equal(answererSent[0].type, T.ICE_RESTART_REQUEST, 'answerer must not create a competing offer'); answerer._resetReconnectState(); } // ── retries back off, and stop the moment the path is back ────────────── { const { mgr, sent } = makeManager({ isInitiator: true }); mgr._reconnect.startedAt = Date.now(); const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF; await mgr._attemptIceRestart(); let retry = pending().find((t) => t.kind === 'timeout'); assert.equal(retry.delay, backoff[0], 'first retry uses the head of the backoff'); // The round-trip completes but the pair still fails to connect, so the // next retry is a genuine second attempt and backs off further. mgr.peerConnection.signalingState = 'have-local-offer'; await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) }); fire(retry); await settle(); retry = pending().find((t) => t.kind === 'timeout'); assert.equal(retry.delay, backoff[1], 'each failed attempt backs off further'); assert.equal(sent.length, 2, 'each attempt re-sends the restart offer'); mgr.peerConnection.signalingState = 'have-local-offer'; await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) }); const sentAfterTwoAttempts = sent.length; // Path comes back: the pending retry must not fire another restart. mgr.peerConnection.connectionState = 'connected'; fire(retry); await settle(); assert.equal(sent.length, sentAfterTwoAttempts, 'recovery stops as soon as the path is up'); assert.equal(mgr.isReconnecting(), false); mgr._resetReconnectState(); } // ── a healthy connection is never renegotiated ─────────────────────────── // Recovery can be entered on a stale reading — a mobile tab thawing out of // the background is the common one, since its timers were frozen and // connectionState is still catching up. Restarting from there broke a // working session and stranded it in "reconnecting", where sending is // blocked while messages keep arriving. { const { mgr, sent, statuses } = makeManager(); mgr._reconnect.phase = 'grace'; mgr._reconnect.startedAt = Date.now(); mgr.peerConnection.connectionState = 'connected'; await mgr._attemptIceRestart(); assert.deepEqual(sent, [], 'a connected path must not be renegotiated'); assert.equal(mgr.isReconnecting(), false, 'recovery must stand down, not linger'); assert.equal(statuses.at(-1), 'connected'); assert.equal(mgr._queueFlushed, true, 'anything queued meanwhile must go out'); } // ── returning to the foreground asks the peer, it does not accuse it ───── { const { mgr, statuses } = makeManager(); mgr._setupRecoveryLifecycleListeners = P._setupRecoveryLifecycleListeners; const listeners = {}; globalThis.window = { ...globalThis.window, addEventListener: (name, fn) => { listeners[name] = fn; }, removeEventListener: () => {} }; const realDocument = globalThis.document; globalThis.document = { visibilityState: 'visible', addEventListener: (name, fn) => { listeners[name] = fn; }, removeEventListener: () => {}, dispatchEvent: () => {} }; try { mgr._setupRecoveryLifecycleListeners(); // Long silence, but only because the tab's timers were frozen. mgr._lastInboundAt = Date.now() - 10 * 60 * 1000; listeners.visibilitychange(); await settle(); assert.deepEqual(statuses, [], 'coming back must not declare the session broken'); assert.equal(mgr._sentFrames.length, 1, 'it must probe the peer instead'); assert.equal(mgr._sentFrames[0].ack, false); assert.ok(mgr._livenessProbeAt > 0, 'the probe deadline must be armed'); } finally { globalThis.document = realDocument; } mgr._resetReconnectState(); } // ── one round-trip at a time ───────────────────────────────────────────── // A restart takes seconds end to end, far longer than the head of the // backoff. Without this guard each retry replaced the local description out // from under the attempt already in flight, so every try cancelled the last // and recovery never converged — observed as a long run of attempts that // ended in a plain timeout. { const { mgr, sent } = makeManager({ isInitiator: true }); mgr._reconnect.startedAt = Date.now(); await mgr._attemptIceRestart(); assert.equal(sent.length, 1); assert.ok(mgr._reconnect.inFlightAt > 0, 'the round-trip must be marked in flight'); // The retry fires while the round-trip is still running: it must wait, // not launch a competing restart. let retry = pending().find((t) => t.kind === 'timeout'); fire(retry); await settle(); assert.equal(sent.length, 1, 'no competing restart while one is in flight'); assert.ok(pending().some((t) => t.kind === 'timeout'), 'it must keep waiting, not give up'); // Round-trip completes (answer applied) → a further attempt is allowed. mgr.peerConnection.signalingState = 'have-local-offer'; await mgr._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_A) }); assert.equal(mgr._reconnect.inFlightAt, 0, 'a completed round-trip clears the in-flight mark'); retry = pending().find((t) => t.kind === 'timeout'); fire(retry); await settle(); assert.equal(sent.length, 2, 'the next attempt runs once the round-trip is done'); mgr._resetReconnectState(); } // ── a restart gathers candidates on a budget that fits the round-trip ───── { const budget = TIMEOUTS.ICE_RESTART_GATHERING; assert.ok(budget < TIMEOUTS.ICE_GATHERING_TIMEOUT, 'recovery must gather faster than a fresh handshake'); assert.ok(budget * 2 < TIMEOUTS.ICE_RESTART_TIMEOUT, 'both gathering legs must fit inside the round-trip budget'); const { mgr } = makeManager({ isInitiator: true }); const budgets = []; mgr.waitForIceGathering = async (ms) => { budgets.push(ms); return true; }; mgr._reconnect.startedAt = Date.now(); await mgr._attemptIceRestart(); assert.deepEqual(budgets, [budget], 'the restart must pass the short budget'); mgr._resetReconnectState(); } // ── MITM guard: a restart must never re-point the session at a new identity { // Same fingerprint as the live session → accepted, answer goes back. const { mgr, sent } = makeManager({ isInitiator: false }); await mgr._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) }); assert.deepEqual(mgr._applied, ['offer']); assert.equal(sent.at(-1).type, T.ICE_RESTART_ANSWER); mgr._resetReconnectState(); // Different fingerprint → refused before the peer connection is touched. const { mgr: attacked, sent: attackedSent } = makeManager({ isInitiator: false }); await assert.rejects( () => attacked._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_B) }), /mismatch/i, 'a restart offer with a different DTLS fingerprint must be refused' ); assert.deepEqual(attacked._applied, [], 'nothing may be applied to the peer connection'); assert.deepEqual(attackedSent, [], 'no answer may be sent to an unverified identity'); attacked._resetReconnectState(); // Same guard on the answer leg. const { mgr: offerer } = makeManager({ isInitiator: true }); offerer.peerConnection.signalingState = 'have-local-offer'; await assert.rejects( () => offerer._handleIceRestartSignal(T.ICE_RESTART_ANSWER, { sdp: sdpWith(FP_B) }), /mismatch/i, 'a restart answer with a different DTLS fingerprint must be refused' ); assert.deepEqual(offerer._applied, []); offerer._resetReconnectState(); // With no live session to compare against, recovery refuses rather than // accepting an unverifiable identity. const { mgr: blind } = makeManager({ isInitiator: false }); blind.peerConnection.currentRemoteDescription = null; blind.peerConnection.remoteDescription = null; await assert.rejects( () => blind._handleIceRestartSignal(T.ICE_RESTART_OFFER, { sdp: sdpWith(FP_A) }), /identity/i, 'no baseline fingerprint must mean refusal, not blind trust' ); blind._resetReconnectState(); } // ── a restart request is only honoured by the offerer ─────────────────── { const { mgr, sent } = makeManager({ isInitiator: false }); await mgr._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {}); assert.deepEqual(sent, [], 'the answerer must ignore a restart request'); mgr._resetReconnectState(); const { mgr: offerer, sent: offererSent } = makeManager({ isInitiator: true }); await offerer._handleIceRestartSignal(T.ICE_RESTART_REQUEST, {}); assert.equal(offererSent[0].type, T.ICE_RESTART_OFFER); offerer._resetReconnectState(); } // ── recovery completes: the same session resumes, the queue drains ─────── { dispatched.length = 0; const { mgr, statuses } = makeManager(); mgr._onPathDegraded('ice_disconnected'); statuses.length = 0; mgr.peerConnection.connectionState = 'connected'; mgr._onPathRecovered(); assert.deepEqual(statuses, ['connected']); assert.equal(mgr._queueFlushed, true, 'messages sent into the dead path must go out'); assert.equal(mgr.isReconnecting(), false); assert.equal(mgr._reconnect.attempts, 0, 'attempt counter resets for the next drop'); assert.ok(dispatched.includes('connection-recovered')); // Recovering from an idle state is a no-op — no spurious 'connected'. const { mgr: idle, statuses: idleStatuses } = makeManager(); idle._onPathRecovered(); assert.deepEqual(idleStatuses, [], 'no status churn when nothing was broken'); } // ── recovery stops when the channel cannot carry the renegotiation ─────── // Every route out of a broken path runs over the data channel. If the peer // has been completely silent since recovery began, it carries nothing in // either direction and no further attempt can succeed. This matters most for // the answerer, which cannot renegotiate on its own and would otherwise sit // out the whole deadline sending requests nobody can receive — observed as a // long run of attempts ending in a plain timeout. { for (const role of [true, false]) { const { mgr, statuses, ui } = makeManager({ isInitiator: role }); mgr._reconnect.startedAt = Date.now() - 60_000; mgr._lastInboundAt = Date.now() - 60_000; mgr._reconnect.attempts = 3; await mgr._attemptIceRestart(); assert.equal(mgr._reconnect.phase, 'exhausted', `a silent channel must end recovery (${role ? 'offerer' : 'answerer'})`); assert.equal(statuses.at(-1), 'recovery_failed', 'the UI must be told to tear the conversation down, not merely show a drop'); assert.match(ui[0], /wiped/i, 'and the user must be told the chat is being closed'); } // A peer that is still answering keeps recovery alive. const { mgr: alive, sent } = makeManager(); alive._reconnect.startedAt = Date.now() - 60_000; alive._lastInboundAt = Date.now(); // heard from just now alive._reconnect.attempts = 3; await alive._attemptIceRestart(); assert.notEqual(alive._reconnect.phase, 'exhausted', 'a responsive peer must not be abandoned'); assert.equal(sent.length, 1, 'and the attempt goes out'); alive._resetReconnectState(); // Early attempts are never cut short on silence alone — a restart // round-trip has to be given a chance to produce its first reply. const { mgr: early } = makeManager(); early._reconnect.startedAt = Date.now() - 60_000; early._lastInboundAt = Date.now() - 60_000; early._reconnect.attempts = 0; await early._attemptIceRestart(); assert.notEqual(early._reconnect.phase, 'exhausted', 'the first attempt must still be tried'); early._resetReconnectState(); } // ── an ICE agent that cannot gather at all is not worth retrying ───────── // After a network change a PeerConnection is often left bound to interfaces // that no longer exist: every STUN and TURN request times out and each // restart fails with zero candidate pairs. restartIce() cannot rebind it, so // spending the whole two-minute deadline on it is two minutes of the user // watching nothing happen when the outcome was already decided. { const { mgr, statuses, ui } = makeManager(); mgr._noteIceFailureDiagnostics = P._noteIceFailureDiagnostics; mgr._reconnect.phase = 'restarting'; mgr._reconnect.startedAt = Date.now(); // A failure that still produced pairs is an ordinary flaky path. mgr._noteIceFailureDiagnostics({ pairCount: 3 }); assert.equal(mgr._reconnect.barrenFailures, 0); assert.notEqual(statuses.at(-1), 'disconnected'); // Barren failures accumulate; one alone is not enough to conclude. mgr._noteIceFailureDiagnostics({ pairCount: 0 }); assert.equal(mgr.isReconnecting(), true, 'one barren failure must not end recovery'); // A pair appearing resets the count — the agent is evidently alive. mgr._noteIceFailureDiagnostics({ pairCount: 1 }); assert.equal(mgr._reconnect.barrenFailures, 0, 'evidence of life resets the count'); mgr._noteIceFailureDiagnostics({ pairCount: 0 }); mgr._noteIceFailureDiagnostics({ pairCount: 0 }); assert.equal(mgr._reconnect.phase, 'exhausted', 'a barren agent ends recovery promptly'); assert.equal(statuses.at(-1), 'recovery_failed'); assert.equal(ui.length, 1, 'and the user is told once, with the way out'); } // ── a closed data channel cannot be repaired by an ICE restart ─────────── // SCTP goes with it, so there is nothing left to carry the renegotiation. { const { mgr, statuses, ui } = makeManager(); mgr.dataChannel = { readyState: 'closed' }; mgr._reconnect.startedAt = Date.now(); await mgr._attemptIceRestart(); assert.equal(mgr._reconnect.phase, 'exhausted'); assert.equal(statuses.at(-1), 'recovery_failed'); assert.equal(mgr._verificationCleared, true, 'an unrecoverable session clears verification'); assert.equal(ui.length, 1); assert.match(ui[0], /wiped/i, 'the user is told the chat is being closed and wiped'); assert.deepEqual(pending().filter((t) => t.kind === 'timeout'), [], 'giving up leaves no timers behind'); } // ── a device with no network holds the session instead of losing it ────── // A five-minute tunnel must not cost the user their session: no restart can // succeed with the radio off, so the give-up deadline is held open. { // Node exposes navigator as a getter-only global, so swap the descriptor. const realNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); const fakeNavigator = { onLine: false }; Object.defineProperty(globalThis, 'navigator', { value: fakeNavigator, configurable: true }); try { const { mgr, sent, statuses } = makeManager(); mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 60_000); await mgr._attemptIceRestart(); assert.equal(mgr._reconnect.phase, 'waiting', 'recovery waits rather than giving up'); assert.notEqual(statuses.at(-1), 'disconnected', 'an offline device must not end the session'); assert.deepEqual(sent, [], 'nothing is transmitted with no network'); // The hold must schedule a real retry, not a 0 ms spin. const retry = pending().find((t) => t.kind === 'timeout'); assert.ok(retry && retry.delay > 0, 'the offline hold must schedule a sane retry delay'); // Network comes back → retry immediately, deadline starts fresh. fakeNavigator.onLine = true; await mgr._attemptIceRestart(); assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER, 'recovery resumes once the radio is back'); mgr._resetReconnectState(); } finally { if (realNavigator) Object.defineProperty(globalThis, 'navigator', realNavigator); else delete globalThis.navigator; } } // ── giving up is bounded by the overall deadline ───────────────────────── { const { mgr, statuses } = makeManager(); mgr._reconnect.startedAt = Date.now() - (TIMEOUTS.RECONNECT_MAX_DURATION + 1000); await mgr._attemptIceRestart(); assert.equal(mgr._reconnect.phase, 'exhausted'); assert.equal(statuses.at(-1), 'recovery_failed'); } // ── manual retry restarts the cycle with a fresh deadline ─────────────── { const { mgr, sent } = makeManager(); mgr.reconnectionFailedNotificationSent = true; mgr._reconnect.phase = 'exhausted'; const ok = P.attemptReconnection.call(mgr); await settle(); assert.equal(ok, true); assert.equal(mgr.reconnectionFailedNotificationSent, false, 'the user can be told again if it fails again'); assert.equal(sent.at(-1).type, T.ICE_RESTART_OFFER); mgr._resetReconnectState(); // Nothing to restart over: report failure instead of pretending. const { mgr: gone, ui } = makeManager(); gone.dataChannel = { readyState: 'closed' }; assert.equal(P.attemptReconnection.call(gone), false); assert.match(ui[0], /new connection is required/i); } // ── backoff is bounded and monotonic ───────────────────────────────────── { const backoff = EnhancedSecureWebRTCManager.RECONNECT_BACKOFF; assert.ok(backoff.length > 0); for (let i = 1; i < backoff.length; i += 1) { assert.ok(backoff[i] >= backoff[i - 1], 'backoff must not shrink'); } assert.ok(Object.isFrozen(backoff)); } // ── recovery frames never surface as chat messages ─────────────────────── { const manager = { _debugMode: false, _secureLog() {}, getSecurityStatus: () => ({ activeFeaturesCount: 0 }), _checkInboundRateLimit: () => true }; for (const type of [T.ICE_RESTART_OFFER, T.ICE_RESTART_ANSWER, T.ICE_RESTART_REQUEST]) { const result = await P.removeSecurityLayers.call(manager, JSON.stringify({ type, sdp: 'v=0' })); assert.equal(result, 'SYSTEM_MESSAGE_FILTERED', `${type} must not reach the chat log`); } } console.log('session-recovery.test.mjs: all assertions passed'); } finally { Object.assign(globalThis, realTimers); }