From 0d7d209303fed7173078e013aebe4fbcf2c63776 Mon Sep 17 00:00:00 2001 From: akirilyuk Date: Thu, 8 Oct 2026 22:02:34 +0700 Subject: [PATCH] fix(session): cancel any queued reconnect once restored; let a connected attempt finish; release 0.11.9 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++ package-lock.json | 4 +- package.json | 2 +- .../browser-voice-session-recovery.test.ts | 46 +++++++++++++++++++ src/browser/browser-voice-session.ts | 14 ++++-- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb37c16..ed3006d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +## [0.11.9] - 2026-10-08 + +### Fixed + +- Recovery: once the session is restored, any reconnect attempt still waiting to start is cancelled, whichever way the connection came back. +- Recovery: a reconnect attempt whose connection is up but still opening its data channel is given time to finish instead of being replaced. + ## [0.11.8] - 2026-10-08 ### Fixed diff --git a/package-lock.json b/package-lock.json index 9c9621b..17b236a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@voicethere/client", - "version": "0.11.8", + "version": "0.11.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@voicethere/client", - "version": "0.11.8", + "version": "0.11.9", "license": "MIT", "dependencies": { "@node-webrtc-rust/signaling": "0.7.4" diff --git a/package.json b/package.json index f9ebe2d..519c66b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@voicethere/client", - "version": "0.11.8", + "version": "0.11.9", "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 10f4fa4..c9a9d1f 100644 --- a/src/browser/browser-voice-session-recovery.test.ts +++ b/src/browser/browser-voice-session-recovery.test.ts @@ -485,6 +485,52 @@ describe("connectBrowserVoiceSession time-budgeted recovery", () => { expect(MockWebSocket.instances.length).toBe(wsCount); }); + it("a queued attempt is cancelled when a reconnect attempt restores the session", async () => { + vi.useFakeTimers(); + const onReconnected = vi.fn(); + const { session, replacement, debugLines } = await startCheckingAttempt({ + onReconnected, + }); + // The replacement drops, which queues attempt 2 behind its backoff ... + replacement.fail(); + await flush(); + const wsCount = MockWebSocket.instances.length; + // ... then comes back and opens its channels before that attempt starts. + setIce(replacement, "connected"); + openDataChannels(replacement); + await flush(); + expect(session.getConnectionStatus().recovery?.state).toBe("restored"); + await vi.advanceTimersByTimeAsync(5_000); + expect(MockWebSocket.instances.length).toBe(wsCount); + expect( + debugLines.some( + (l) => l.includes("reconnect_cancelled") && l.includes("via=reconnect"), + ), + ).toBe(true); + expect(session.getConnectionStatus().recovery?.state).toBe("restored"); + }); + + it("settle timer extends while the replacement is connected but its data channel is not open", async () => { + vi.useFakeTimers(); + const onReconnected = vi.fn(); + const { session, replacement, debugLines } = await startCheckingAttempt({ + onReconnected, + }); + setIce(replacement, "connected"); + replacement.connect(); + const wsCount = MockWebSocket.instances.length; + await vi.advanceTimersByTimeAsync(6_500); + expect( + debugLines.some((l) => l.includes("reconnect_settle_extended")), + ).toBe(true); + expect(MockWebSocket.instances.length).toBe(wsCount); + openDataChannels(replacement); + await flush(); + expect(session.getConnectionStatus().recovery?.state).toBe("restored"); + expect(onReconnected).toHaveBeenCalledTimes(1); + expect(MockWebSocket.instances.length).toBe(wsCount); + }); + it("an attempt in flight at budget end gets the grace and restores", async () => { vi.useFakeTimers(); const { session, replacement, errors } = await startCheckingAttempt({ diff --git a/src/browser/browser-voice-session.ts b/src/browser/browser-voice-session.ts index 22d632b..79a4a31 100644 --- a/src/browser/browser-voice-session.ts +++ b/src/browser/browser-voice-session.ts @@ -913,6 +913,12 @@ export async function connectBrowserVoiceSession( ) { return false; } + if ( + state === "connected" && + !isWebRtcConnectionReady(connectionSnapshot, readinessProfile) + ) { + return true; + } return ice === "checking" || state === "connecting"; }; @@ -1057,14 +1063,14 @@ export async function connectBrowserVoiceSession( outageStartMs = null; outageRestoreEligible = false; clearRecoveryTimers(); - // The original connection came back before the queued attempt started: drop it. - if (outageRecoveryVia === "ice-restart" && reconnectTimer) { + // The session is back: an attempt still waiting to start would replace a healthy connection. + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = undefined; debug?.info( "session", "reconnect_cancelled", - `restored_before_attempt attempt=${autoReconnectAttempts}`, + `restored_before_attempt via=${outageRecoveryVia} attempt=${autoReconnectAttempts}`, ); } debug?.info( @@ -1294,6 +1300,7 @@ export async function connectBrowserVoiceSession( ); if (reconnectTimer) clearTimeout(reconnectTimer); reconnectTimer = setTimeout(() => { + reconnectTimer = undefined; void reconnectSignaling(true) .then(() => armSettleTimer(ICE_RECOVERY_SETTLE_MS, "ice_recovery")) .catch((error: unknown) => { @@ -2054,6 +2061,7 @@ export async function connectBrowserVoiceSession( if (settleTimer) clearTimeout(settleTimer); settleTimer = undefined; reconnectTimer = setTimeout(() => { + reconnectTimer = undefined; replacementIceDead = false; outageRecoveryVia = "reconnect"; void reconnectSignaling(true)