151 lines
6.3 KiB
JavaScript
151 lines
6.3 KiB
JavaScript
// ICE gathering only reaches 'complete' once every configured STUN/TURN server
|
|||
|
|
// has answered or timed out. On a network that blocks them — a VPN, a captive
|
||
|
|
// portal, an interface the browser cannot route from — that never happens, even
|
||
|
|
// though host candidates are available immediately and are enough to connect on
|
||
|
|
// a LAN.
|
||
|
|
//
|
||
|
|
// The old code waited a flat 10 s and then failed the whole handshake if the SDP
|
||
|
|
// happened to be empty at that instant. That made success a coin flip: the same
|
||
|
|
// device failed one attempt and connected on the next with gathering still in
|
||
|
|
// progress (observed in the field, 11 candidates at 10001 ms).
|
||
|
|
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
|
||
|
|
globalThis.window = { EnhancedSecureCryptoUtils: { secureLog: { log() {} } } };
|
||
|
|
globalThis.CustomEvent = class { constructor(t, i) { this.type = t; this.detail = i?.detail; } };
|
||
|
|
globalThis.document = { dispatchEvent() {} };
|
||
|
|
|
||
|
|
const { EnhancedSecureWebRTCManager } = await import('../src/network/EnhancedSecureWebRTCManager.js');
|
||
|
|
const P = EnhancedSecureWebRTCManager.prototype;
|
||
|
|
const T = EnhancedSecureWebRTCManager.TIMEOUTS;
|
||
|
|
|
||
|
|
// Fake timers so the 10 s / 25 s deadlines can be driven by hand.
|
||
|
|
const realSetTimeout = globalThis.setTimeout;
|
||
|
|
const realClearTimeout = globalThis.clearTimeout;
|
||
|
|
let timers = [];
|
||
|
|
globalThis.setTimeout = (fn, delay) => {
|
||
|
|
const t = { fn, delay, cleared: false };
|
||
|
|
timers.push(t);
|
||
|
|
return t;
|
||
|
|
};
|
||
|
|
globalThis.clearTimeout = (t) => { if (t) t.cleared = true; };
|
||
|
|
const fireDelay = (delay) => {
|
||
|
|
for (const t of timers.filter((x) => !x.cleared && x.delay === delay)) {
|
||
|
|
t.cleared = true;
|
||
|
|
t.fn();
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const sdpWithCandidates = (n) =>
|
||
|
|
'v=0\r\n' + Array.from({ length: n }, (_, i) =>
|
||
|
|
`a=candidate:${i} 1 udp 2122260223 192.168.1.${i + 2} 5000${i} typ host\r\n`).join('');
|
||
|
|
|
||
|
|
function makeManager(candidateCount) {
|
||
|
|
const listeners = [];
|
||
|
|
const ui = [];
|
||
|
|
return {
|
||
|
|
ui,
|
||
|
|
listeners,
|
||
|
|
peerConnection: {
|
||
|
|
iceGatheringState: 'gathering',
|
||
|
|
localDescription: { sdp: sdpWithCandidates(candidateCount) },
|
||
|
|
addEventListener: (name, fn) => listeners.push({ name, fn }),
|
||
|
|
removeEventListener: () => {}
|
||
|
|
},
|
||
|
|
_activeTimers: new Set(),
|
||
|
|
_secureLog() {},
|
||
|
|
deliverMessageToUI: (m) => ui.push(m),
|
||
|
|
_trackActiveTimer: P._trackActiveTimer,
|
||
|
|
_untrackActiveTimer: P._untrackActiveTimer,
|
||
|
|
_summarizeIceCandidatesInSDP: P._summarizeIceCandidatesInSDP,
|
||
|
|
_countIceCandidatesInSDP: P._countIceCandidatesInSDP,
|
||
|
|
waitForIceGathering: P.waitForIceGathering
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
// ── the budget must actually be longer than the soft deadline ────────────
|
||
|
|
assert.ok(T.ICE_GATHERING_HARD_TIMEOUT > T.ICE_GATHERING_TIMEOUT,
|
||
|
|
'the hard ceiling must leave room past the soft deadline');
|
||
|
|
|
||
|
|
// ── already complete: return immediately, no waiting ─────────────────────
|
||
|
|
{
|
||
|
|
timers = [];
|
||
|
|
const mgr = makeManager(3);
|
||
|
|
mgr.peerConnection.iceGatheringState = 'complete';
|
||
|
|
assert.equal(await mgr.waitForIceGathering(), true);
|
||
|
|
assert.deepEqual(timers.filter((t) => !t.cleared), [], 'no timers left behind');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── gathering finishes on its own: resolve true and drop the timers ──────
|
||
|
|
{
|
||
|
|
timers = [];
|
||
|
|
const mgr = makeManager(3);
|
||
|
|
const pending = mgr.waitForIceGathering();
|
||
|
|
mgr.peerConnection.iceGatheringState = 'complete';
|
||
|
|
mgr.listeners.forEach((l) => l.fn());
|
||
|
|
assert.equal(await pending, true);
|
||
|
|
assert.deepEqual(timers.filter((t) => !t.cleared), [],
|
||
|
|
'a completed gather must not leave the hard timer armed');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── soft deadline WITH candidates: stop waiting, report "not complete" ───
|
||
|
|
// This is the common case on a restricted network, and it must succeed: the
|
||
|
|
// caller only refuses to export when there is nothing at all.
|
||
|
|
{
|
||
|
|
timers = [];
|
||
|
|
const mgr = makeManager(11);
|
||
|
|
const pending = mgr.waitForIceGathering();
|
||
|
|
fireDelay(T.ICE_GATHERING_TIMEOUT);
|
||
|
|
assert.equal(await pending, false, 'gathering did not complete...');
|
||
|
|
// ...but the caller's guard is `!completed && count === 0`, so 11
|
||
|
|
// candidates mean the handshake proceeds.
|
||
|
|
assert.ok(mgr._summarizeIceCandidatesInSDP(mgr.peerConnection.localDescription.sdp).total > 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── soft deadline with NOTHING: keep waiting instead of failing ──────────
|
||
|
|
// The regression under test. Previously this resolved at 10 s with an empty
|
||
|
|
// SDP and the handshake threw.
|
||
|
|
{
|
||
|
|
timers = [];
|
||
|
|
const mgr = makeManager(0);
|
||
|
|
let settled = false;
|
||
|
|
const pending = mgr.waitForIceGathering().then((v) => { settled = true; return v; });
|
||
|
|
|
||
|
|
fireDelay(T.ICE_GATHERING_TIMEOUT);
|
||
|
|
await Promise.resolve();
|
||
|
|
assert.equal(settled, false, 'an empty SDP at the soft deadline must not end the wait');
|
||
|
|
|
||
|
|
// A candidate arriving late is exactly what the extra patience buys.
|
||
|
|
mgr.peerConnection.localDescription.sdp = sdpWithCandidates(4);
|
||
|
|
mgr.peerConnection.iceGatheringState = 'complete';
|
||
|
|
mgr.listeners.forEach((l) => l.fn());
|
||
|
|
assert.equal(await pending, true, 'a late completion must still be picked up');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── a genuinely dead network still fails, at the hard ceiling ────────────
|
||
|
|
{
|
||
|
|
timers = [];
|
||
|
|
const mgr = makeManager(0);
|
||
|
|
const pending = mgr.waitForIceGathering();
|
||
|
|
fireDelay(T.ICE_GATHERING_TIMEOUT);
|
||
|
|
fireDelay(T.ICE_GATHERING_HARD_TIMEOUT);
|
||
|
|
assert.equal(await pending, false, 'nothing gathered at all must eventually give up');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── a caller-supplied budget shorter than the hard default is honoured ───
|
||
|
|
// Session recovery passes 4 s and must not silently wait 25 s instead.
|
||
|
|
{
|
||
|
|
timers = [];
|
||
|
|
const mgr = makeManager(0);
|
||
|
|
const pending = mgr.waitForIceGathering(T.ICE_RESTART_GATHERING, T.ICE_RESTART_GATHERING);
|
||
|
|
fireDelay(T.ICE_RESTART_GATHERING);
|
||
|
|
assert.equal(await pending, false, 'the recovery path keeps its short budget');
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('ice-gathering-patience.test.mjs: all assertions passed');
|
||
|
|
} finally {
|
||
|
|
globalThis.setTimeout = realSetTimeout;
|
||
|
|
globalThis.clearTimeout = realClearTimeout;
|
||
|
|
}
|