Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
46 changes: 46 additions & 0 deletions src/browser/browser-voice-session-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
14 changes: 11 additions & 3 deletions src/browser/browser-voice-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,6 +913,12 @@ export async function connectBrowserVoiceSession(
) {
return false;
}
if (
state === "connected" &&
!isWebRtcConnectionReady(connectionSnapshot, readinessProfile)
) {
return true;
}
return ice === "checking" || state === "connecting";
};

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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)
Expand Down
Loading