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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.6",
"version": "0.11.7",
"description": "VoiceThere browser/Node client — cloud gateway, browser voice/chat, embed widget",
"type": "module",
"exports": {
Expand Down
98 changes: 98 additions & 0 deletions src/browser/browser-voice-session-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RTCStatsReport> {
const map = new Map<string, Record<string, unknown>>();
(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?.();
Expand Down Expand Up @@ -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();
Expand Down
93 changes: 93 additions & 0 deletions src/browser/browser-voice-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -640,6 +644,9 @@ export async function connectBrowserVoiceSession(
let budgetTimer: ReturnType<typeof setTimeout> | undefined;
let iceDisconnectedTimer: ReturnType<typeof setTimeout> | undefined;
let settleTimer: ReturnType<typeof setTimeout> | undefined;
let deadPairsTimer: ReturnType<typeof setInterval> | 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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
Expand Down Expand Up @@ -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<void> => {
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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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"))
Expand Down
Loading