diff --git a/CHANGELOG.md b/CHANGELOG.md index da24647..2f48a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +## [0.11.7] - 2026-10-08 + +### Fixed + +- A reconnect attempt whose ICE checks all failed (for example because it started while the network was still coming back) is replaced by a new attempt right away, instead of waiting out its settle time with no chance to connect. + ## [0.11.6] - 2026-10-08 ### Fixed diff --git a/package-lock.json b/package-lock.json index a00b0fc..4aa83fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@voicethere/client", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@voicethere/client", - "version": "0.11.6", + "version": "0.11.7", "license": "MIT", "dependencies": { "@node-webrtc-rust/signaling": "0.7.4" diff --git a/package.json b/package.json index d01d142..5eea659 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@voicethere/client", - "version": "0.11.6", + "version": "0.11.7", "description": "VoiceThere browser/Node client — cloud gateway, browser voice/chat, embed widget", "type": "module", "exports": { diff --git a/src/browser/browser-voice-session-recovery.test.ts b/src/browser/browser-voice-session-recovery.test.ts index 7f49498..8adf648 100644 --- a/src/browser/browser-voice-session-recovery.test.ts +++ b/src/browser/browser-voice-session-recovery.test.ts @@ -145,6 +145,22 @@ class MockPeerConnection { this.onconnectionstatechange?.(); } + /** Candidate-pair states returned by getStats; null = getStats has no pairs. */ + pairStates: RTCStatsIceCandidatePairState[] | null = null; + + async getStats(): Promise { + const map = new Map>(); + (this.pairStates ?? []).forEach((state, i) => { + map.set(`pair-${i}`, { + id: `pair-${i}`, + type: "candidate-pair", + state, + nominated: false, + }); + }); + return map as unknown as RTCStatsReport; + } + fail(): void { this.connectionState = "failed"; this.onconnectionstatechange?.(); @@ -472,6 +488,88 @@ describe("connectBrowserVoiceSession time-budgeted recovery", () => { }); }); + const ICE_FAILED_LINE = "session/reconnect_attempt_ice_failed"; + + it("an attempt whose candidate pairs all failed starts the next attempt without waiting for settle", async () => { + vi.useFakeTimers(); + const { session, replacement, errors, debugLines } = + await startCheckingAttempt(); + const wsCount = MockWebSocket.instances.length; + replacement.pairStates = ["failed", "failed", "failed"]; + await vi.advanceTimersByTimeAsync(2_100); + const line = debugLines.find((l) => l.includes(ICE_FAILED_LINE)); + expect(line).toBeDefined(); + expect(line).toContain("pairs=3"); + await vi.advanceTimersByTimeAsync(2_100); + expect(MockWebSocket.instances.length).toBeGreaterThan(wsCount); + await completeReconnect(); + expect(session.getConnectionStatus().recovery?.state).toBe("restored"); + expect(exhausted(errors)).toBe(false); + }); + + it("one probe with all pairs failed does not abandon the attempt", async () => { + vi.useFakeTimers(); + const { replacement, debugLines } = await startCheckingAttempt(); + const wsCount = MockWebSocket.instances.length; + replacement.pairStates = ["failed", "failed"]; + await vi.advanceTimersByTimeAsync(1_050); + replacement.pairStates = ["failed", "in-progress"]; + await vi.advanceTimersByTimeAsync(3_000); + expect(debugLines.some((l) => l.includes(ICE_FAILED_LINE))).toBe(false); + expect(MockWebSocket.instances.length).toBe(wsCount); + }); + + it("pairs still in progress keep the attempt until settle", async () => { + vi.useFakeTimers(); + const { replacement, debugLines } = await startCheckingAttempt(); + const wsCount = MockWebSocket.instances.length; + replacement.pairStates = ["in-progress", "waiting"]; + await vi.advanceTimersByTimeAsync(5_000); + expect(debugLines.some((l) => l.includes(ICE_FAILED_LINE))).toBe(false); + expect(MockWebSocket.instances.length).toBe(wsCount); + }); + + it("a dead attempt during the grace ends recovery at once", async () => { + vi.useFakeTimers(); + const { replacement, errors } = await startCheckingAttempt({ + reconnectBudgetMs: 5_000, + }); + await vi.advanceTimersByTimeAsync(4_000); + const wsCount = MockWebSocket.instances.length; + replacement.pairStates = ["failed", "failed"]; + await vi.advanceTimersByTimeAsync(2_100); + expect(exhausted(errors)).toBe(true); + expect(MockWebSocket.instances.length).toBe(wsCount); + }); + + it("a dead attempt in backoff at budget end gets no grace", async () => { + vi.useFakeTimers(); + const { replacement, errors, debugLines } = await startCheckingAttempt({ + reconnectBudgetMs: 5_000, + }); + replacement.pairStates = ["failed", "failed"]; + await vi.advanceTimersByTimeAsync(2_100); + expect(debugLines.some((l) => l.includes(ICE_FAILED_LINE))).toBe(true); + // Attempt 2 is in its 2 s backoff (fires at ~5.1 s); the budget ends at 5 s. + await vi.advanceTimersByTimeAsync(3_000); + expect(exhausted(errors)).toBe(true); + expect( + debugLines.some((l) => l.includes("session/reconnect_inflight_grace")), + ).toBe(false); + }); + + it("attempt 2 after a dead attempt connects and restores", async () => { + vi.useFakeTimers(); + const { session, replacement, errors } = await startCheckingAttempt(); + replacement.pairStates = ["failed", "failed", "failed"]; + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(2_100); + await completeReconnect(); + expect(session.getConnectionStatus().recovery?.state).toBe("restored"); + await vi.advanceTimersByTimeAsync(20_000); + expect(exhausted(errors)).toBe(false); + }); + it("server window 30000 extends the budget to 30 s", async () => { vi.useFakeTimers(); const { errors, debugLines } = await connectReady(); diff --git a/src/browser/browser-voice-session.ts b/src/browser/browser-voice-session.ts index e20b578..9b2121a 100644 --- a/src/browser/browser-voice-session.ts +++ b/src/browser/browser-voice-session.ts @@ -143,6 +143,10 @@ const ICE_RECOVERY_SETTLE_MS = 5_000; const RECONNECT_SETTLE_MS = 6_000; /** One-time extra settle time for an attempt whose replacement peer is still connecting. */ const RECONNECT_SETTLE_EXTEND_MS = 3_000; +/** How often a reconnect attempt's candidate pairs are checked while it settles. */ +const RECONNECT_DEAD_PAIRS_PROBE_MS = 1_000; +/** Consecutive probes with every candidate pair failed before the attempt counts as dead. */ +const RECONNECT_DEAD_PAIRS_CONFIRM = 2; /** * Extra time an attempt that is in flight when the budget ends gets to connect. * No new attempt starts during it. @@ -640,6 +644,9 @@ export async function connectBrowserVoiceSession( let budgetTimer: ReturnType | undefined; let iceDisconnectedTimer: ReturnType | undefined; let settleTimer: ReturnType | undefined; + let deadPairsTimer: ReturnType | undefined; + /** The current reconnect attempt's candidate pairs have all failed; it cannot connect. */ + let replacementIceDead = false; /** Socket of the current reconnect attempt once it joined (cleared when the next attempt is scheduled). */ let reconnectJoinedWs: WebSocket | null = null; /** True while an in-flight attempt runs on after the budget ended. */ @@ -875,7 +882,14 @@ export async function connectBrowserVoiceSession( options.onControlMessage?.(message); }; + const stopDeadPairsProbe = (): void => { + if (deadPairsTimer) clearInterval(deadPairsTimer); + deadPairsTimer = undefined; + }; + const clearRecoveryTimers = (): void => { + stopDeadPairsProbe(); + replacementIceDead = false; if (budgetTimer) clearTimeout(budgetTimer); if (iceDisconnectedTimer) clearTimeout(iceDisconnectedTimer); if (settleTimer) clearTimeout(settleTimer); @@ -888,6 +902,7 @@ export async function connectBrowserVoiceSession( /** The replacement peer connection of a same-session reconnect is still negotiating ICE. */ const isReplacementPcConnecting = (): boolean => { if (outageRecoveryVia !== "reconnect") return false; + if (replacementIceDead) return false; const ice = connectionSnapshot.iceConnectionState; const state = connectionSnapshot.peerConnectionState; if ( @@ -1060,6 +1075,81 @@ export async function connectBrowserVoiceSession( iceDisconnectedTimer = undefined; }; + /** + * While a reconnect attempt settles, watch its candidate pairs. When every pair + * has failed (twice in a row) the attempt cannot connect: replace it right away. + */ + const startDeadPairsProbe = (): void => { + stopDeadPairsProbe(); + // The rejoin socket opens before the server offer creates the replacement + // peer connection, so at arm time `pc` is still the pre-attempt one. + const startPc = pc; + let targetPc: RTCPeerConnection | null = null; + let deadCount = 0; + let probing = false; + const shouldStop = (): boolean => + gracefulDisconnect || + outageStartMs === null || + outageRecoveryVia !== "reconnect"; + const probe = async (): Promise => { + if (probing) return; + probing = true; + try { + if (shouldStop()) { + stopDeadPairsProbe(); + return; + } + const current = pc; + // Replacement not created yet (or being swapped): nothing to judge. + if (!current || current === startPc) return; + if (current !== targetPc) { + targetPc = current; + deadCount = 0; + } + let diagnostics: WebRtcDiagnostics | null = null; + try { + diagnostics = await collectWebRtcDiagnostics( + current, + buildWebRtcConnectionStatus(connectionSnapshot, readinessProfile), + ); + } catch { + return; + } + if (shouldStop()) { + stopDeadPairsProbe(); + return; + } + if (pc !== current) return; + const s = diagnostics?.stats; + if (s && s.candidatePairs > 0 && s.failedPairs === s.candidatePairs) { + deadCount += 1; + } else { + deadCount = 0; + } + if (!s || deadCount < RECONNECT_DEAD_PAIRS_CONFIRM) return; + stopDeadPairsProbe(); + replacementIceDead = true; + debug?.warn( + "session", + "reconnect_attempt_ice_failed", + `attempt=${autoReconnectAttempts} pairs=${s.candidatePairs}`, + ); + if (inflightGraceActive) { + emitAutoReconnectExhausted("reconnect_ice_failed"); + return; + } + if (settleTimer) clearTimeout(settleTimer); + settleTimer = undefined; + scheduleAutoReconnect("reconnect_ice_failed"); + } finally { + probing = false; + } + }; + deadPairsTimer = setInterval(() => { + void probe(); + }, RECONNECT_DEAD_PAIRS_PROBE_MS); + }; + /** Escalate when a recovery rejoin does not reach readiness in time. */ const armSettleTimer = ( ms: number, @@ -1090,6 +1180,7 @@ export async function connectBrowserVoiceSession( debug?.warn("session", "recovery_settle_timeout", reason); scheduleAutoReconnect(`${reason}_timeout`); }, ms); + if (reason === "reconnect" && canExtend) startDeadPairsProbe(); }; /** ICE/PC `disconnected`: start recovery if it lasts longer than the grace period. */ @@ -1929,6 +2020,7 @@ export async function connectBrowserVoiceSession( if (gracefulDisconnect || reconnectPolicy === "new-session") return; // The budget ended while an attempt was connecting: that attempt gets the grace. if (inflightGraceActive) return; + stopDeadPairsProbe(); reconnectJoinedWs = null; clearIceDisconnectedTimer(); beginOutage(reason, Date.now(), false); @@ -1949,6 +2041,7 @@ export async function connectBrowserVoiceSession( if (settleTimer) clearTimeout(settleTimer); settleTimer = undefined; reconnectTimer = setTimeout(() => { + replacementIceDead = false; outageRecoveryVia = "reconnect"; void reconnectSignaling(true) .then(() => armSettleTimer(RECONNECT_SETTLE_MS, "reconnect"))