@voicethere/client 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { createConnectionError, dispatchConnectionError, } from "../connection-errors.js";
2
2
  import { appendJoinToken } from "../resolve-connection.js";
3
3
  import { createLocalSessionError, emitSessionError, isSessionErrorEvent, parseLegacyAgentError, } from "../session-errors.js";
4
- import { buildWebRtcConnectionStatus, isHalfOpenConnection, formatWebRtcConnectTimeoutMessage, isWebRtcConnectionReady, resolveHalfOpenFailFastMs, resolveReadinessProfile, } from "./webrtc-connection-status.js";
4
+ import { buildWebRtcConnectionStatus, CONNECTION_INTERRUPTED_MESSAGE, CONNECTION_LOST_MESSAGE, CONNECTION_RESTORED_MESSAGE, CONNECTION_SESSION_ENDED_MESSAGE, isHalfOpenConnection, formatWebRtcConnectTimeoutMessage, isWebRtcConnectionReady, resolveHalfOpenFailFastMs, resolveReadinessProfile, } from "./webrtc-connection-status.js";
5
5
  import { collectWebRtcDiagnostics, } from "./webrtc-diagnostics.js";
6
6
  import { getDefaultBrowserRuntime, } from "./webrtc-runtime.js";
7
7
  import { closePeerConnectionAwaitable, } from "./peer-connection-close.js";
@@ -58,6 +58,32 @@ function redactSignalingUrlForLog(url) {
58
58
  return url.split("?")[0] ?? url;
59
59
  }
60
60
  }
61
+ /** Default total time from the first transport loss during which the client keeps trying. */
62
+ export const DEFAULT_RECONNECT_BUDGET_MS = 45_000;
63
+ /** ICE `disconnected` must last this long before recovery starts. */
64
+ const ICE_DISCONNECTED_GRACE_MS = 2_000;
65
+ /** Time the relay ICE recovery rejoin gets to reach readiness before escalating. */
66
+ const ICE_RECOVERY_SETTLE_MS = 5_000;
67
+ /** Time a same-session reconnect gets to reach readiness before the next attempt. */
68
+ const RECONNECT_SETTLE_MS = 10_000;
69
+ /** Reconnect signaling WebSocket must open within this time. */
70
+ const SIGNALING_CONNECT_TIMEOUT_MS = 5_000;
71
+ /** Delay before same-session reconnect attempt 1, 2, 3, then every later attempt. */
72
+ const RECONNECT_BACKOFF_MS = [1_000, 2_000, 4_000, 5_000];
73
+ /** The gateway rejected the reconnect token (HTTP 401): the session no longer exists. */
74
+ class SessionEndedDuringReconnectError extends Error {
75
+ constructor() {
76
+ super("signaling rejected the reconnect token (HTTP 401)");
77
+ this.name = "SessionEndedDuringReconnectError";
78
+ }
79
+ }
80
+ /**
81
+ * Sent on the control channel when the integrator ends the session, so the runner
82
+ * closes immediately instead of holding a dropped peer through its network-drop grace.
83
+ */
84
+ export const CLIENT_HANGUP_MESSAGE_TYPE = "client_hangup";
85
+ /** Max wait for the hangup message to leave the send buffer during `disconnectAsync`. */
86
+ const CLIENT_HANGUP_DRAIN_MS = 200;
61
87
  export const VOICE_AGENT_SERVER_PEER_ID = "voice-agent-server";
62
88
  export const VOICE_CONTROL_CHANNEL_LABEL = "voice-control";
63
89
  /** High-frequency binary sync channel (matches `@node-webrtc-rust/sdk/voice`). */
@@ -239,7 +265,15 @@ export async function connectBrowserVoiceSession(options) {
239
265
  }
240
266
  };
241
267
  const reconnectPolicy = options.reconnectPolicy ?? "same-session";
242
- const maxAutoReconnectAttempts = options.maxAutoReconnectAttempts ?? 4;
268
+ const reconnectBudgetMs = options.reconnectBudgetMs ?? DEFAULT_RECONNECT_BUDGET_MS;
269
+ const statusMessages = {
270
+ interrupted: options.connectionStatusMessages?.interrupted ??
271
+ CONNECTION_INTERRUPTED_MESSAGE,
272
+ restored: options.connectionStatusMessages?.restored ?? CONNECTION_RESTORED_MESSAGE,
273
+ lost: options.connectionStatusMessages?.lost ?? CONNECTION_LOST_MESSAGE,
274
+ sessionEnded: options.connectionStatusMessages?.sessionEnded ??
275
+ CONNECTION_SESSION_ENDED_MESSAGE,
276
+ };
243
277
  const maxIceRecoveryAttempts = options.maxIceRecoveryAttempts ?? 1;
244
278
  const iceRecoveryStuckCheckingMs = options.iceRecoveryStuckCheckingMs ?? 12_000;
245
279
  let effectiveIceTransportPolicy = options.iceTransportPolicy;
@@ -249,6 +283,17 @@ export async function connectBrowserVoiceSession(options) {
249
283
  let iceStuckWatchTimer;
250
284
  let iceCheckingSinceMs = null;
251
285
  let hasReachedReadyOnce = false;
286
+ /** Cap on same-session reconnect attempts: explicit option, else budget-driven once ready. */
287
+ const maxAutoReconnectAttemptsLimit = () => options.maxAutoReconnectAttempts ??
288
+ (hasReachedReadyOnce ? Number.POSITIVE_INFINITY : 4);
289
+ /** Wall-clock start of the current transport outage (null when healthy). */
290
+ let outageStartMs = null;
291
+ let outageRecoveryVia = "ice-restart";
292
+ /** True once the live PC is gone or ICE itself was lost, so "ready" means recovered. */
293
+ let outageRestoreEligible = false;
294
+ let budgetTimer;
295
+ let iceDisconnectedTimer;
296
+ let settleTimer;
252
297
  let awaitingReconnectedCallback = false;
253
298
  let lastReconnectAttemptForCallback = 0;
254
299
  let lastReconnectReason;
@@ -358,6 +403,7 @@ export async function connectBrowserVoiceSession(options) {
358
403
  };
359
404
  const updateConnectionSnapshot = (patch) => {
360
405
  Object.assign(connectionSnapshot, patch);
406
+ markRestoredIfRecovered();
361
407
  publishConnectionStatus();
362
408
  tryResolveConnected();
363
409
  };
@@ -426,27 +472,170 @@ export async function connectBrowserVoiceSession(options) {
426
472
  }
427
473
  options.onControlMessage?.(message);
428
474
  };
429
- const emitAutoReconnectExhausted = (reason) => {
475
+ const clearRecoveryTimers = () => {
476
+ if (budgetTimer)
477
+ clearTimeout(budgetTimer);
478
+ if (iceDisconnectedTimer)
479
+ clearTimeout(iceDisconnectedTimer);
480
+ if (settleTimer)
481
+ clearTimeout(settleTimer);
482
+ budgetTimer = undefined;
483
+ iceDisconnectedTimer = undefined;
484
+ settleTimer = undefined;
485
+ };
486
+ /** Terminal recovery failure: stop retrying, tell the integrator, publish `lost`. */
487
+ const failRecovery = (input) => {
430
488
  if (gracefulDisconnect)
431
489
  return;
432
490
  awaitingReconnectedCallback = false;
433
- debug?.warn("session", "auto_reconnect_exhausted", reason);
491
+ debug?.warn("session", input.logName, input.logDetail);
434
492
  gracefulDisconnect = true;
493
+ clearRecoveryTimers();
494
+ if (reconnectTimer)
495
+ clearTimeout(reconnectTimer);
496
+ reconnectTimer = undefined;
497
+ // Invalidate in-flight reconnect flights and their WebSocket handlers.
498
+ signalingEpoch += 1;
435
499
  notifySessionError(createLocalSessionError({
436
- code: "WEBRTC_RECONNECT_EXHAUSTED",
437
- message: `Auto-reconnect exhausted after ${maxAutoReconnectAttempts} attempts (${reason})`,
500
+ code: input.code,
501
+ message: input.errorMessage,
438
502
  sessionId: orchestratorSessionId,
439
503
  recoverable: false,
440
504
  }));
505
+ rejectConnectedWait(new Error(input.errorMessage), false);
441
506
  updateConnectionSnapshot({
442
507
  signalingJoined: false,
443
508
  controlChannelOpen: false,
444
509
  syncChannelOpen: false,
510
+ recovery: {
511
+ state: "lost",
512
+ reason: input.code,
513
+ message: input.statusMessage,
514
+ },
445
515
  ...(connectionSnapshot.peerConnectionState !== "closed"
446
516
  ? { peerConnectionState: "closed" }
447
517
  : {}),
448
518
  });
449
519
  };
520
+ const emitAutoReconnectExhausted = (reason) => {
521
+ const elapsedMs = outageStartMs !== null ? Date.now() - outageStartMs : undefined;
522
+ failRecovery({
523
+ code: "WEBRTC_RECONNECT_EXHAUSTED",
524
+ errorMessage: `Auto-reconnect exhausted after ${autoReconnectAttempts} attempts${elapsedMs !== undefined ? ` in ${elapsedMs} ms` : ""} (${reason})`,
525
+ statusMessage: statusMessages.lost,
526
+ logName: "auto_reconnect_exhausted",
527
+ logDetail: reason,
528
+ });
529
+ };
530
+ const endSessionDuringReconnect = () => {
531
+ failRecovery({
532
+ code: "SESSION_ENDED_DURING_RECONNECT",
533
+ errorMessage: statusMessages.sessionEnded,
534
+ statusMessage: statusMessages.sessionEnded,
535
+ logName: "session_ended_during_reconnect",
536
+ logDetail: "reconnect token rejected with HTTP 401",
537
+ });
538
+ };
539
+ /**
540
+ * Start tracking a transport outage (only after the session was ready once).
541
+ * The budget runs from the first loss, not from when recovery work begins.
542
+ */
543
+ const beginOutage = (reason, lossAtMs, iceLost) => {
544
+ if (!hasReachedReadyOnce || gracefulDisconnect)
545
+ return;
546
+ if (reconnectPolicy === "new-session")
547
+ return;
548
+ if (outageStartMs !== null)
549
+ return;
550
+ outageStartMs = lossAtMs;
551
+ outageRecoveryVia = "ice-restart";
552
+ outageRestoreEligible = iceLost;
553
+ const remainingMs = Math.max(0, reconnectBudgetMs - (Date.now() - lossAtMs));
554
+ budgetTimer = setTimeout(() => {
555
+ budgetTimer = undefined;
556
+ emitAutoReconnectExhausted("budget_exhausted");
557
+ }, remainingMs);
558
+ debug?.warn("session", "transport_lost", reason);
559
+ updateConnectionSnapshot({
560
+ recovery: {
561
+ state: "interrupted",
562
+ sinceMs: Date.now() - lossAtMs,
563
+ message: statusMessages.interrupted,
564
+ },
565
+ });
566
+ };
567
+ /** Called from {@link updateConnectionSnapshot}; assigns `recovery` without publishing. */
568
+ const markRestoredIfRecovered = () => {
569
+ if (outageStartMs === null || gracefulDisconnect)
570
+ return;
571
+ if (!outageRestoreEligible)
572
+ return;
573
+ if (!isWebRtcConnectionReady(connectionSnapshot, readinessProfile))
574
+ return;
575
+ const ice = connectionSnapshot.iceConnectionState;
576
+ if (ice === "disconnected" || ice === "failed" || ice === "closed")
577
+ return;
578
+ const downtimeMs = Date.now() - outageStartMs;
579
+ outageStartMs = null;
580
+ outageRestoreEligible = false;
581
+ clearRecoveryTimers();
582
+ debug?.info("session", "transport_restored", `downtime_ms=${downtimeMs} via=${outageRecoveryVia}`);
583
+ connectionSnapshot.recovery = {
584
+ state: "restored",
585
+ downtimeMs,
586
+ via: outageRecoveryVia,
587
+ message: statusMessages.restored,
588
+ };
589
+ };
590
+ const clearIceDisconnectedTimer = () => {
591
+ if (iceDisconnectedTimer)
592
+ clearTimeout(iceDisconnectedTimer);
593
+ iceDisconnectedTimer = undefined;
594
+ };
595
+ /** Escalate when a recovery rejoin does not reach readiness in time. */
596
+ const armSettleTimer = (ms, reason) => {
597
+ if (outageStartMs === null || gracefulDisconnect)
598
+ return;
599
+ if (settleTimer)
600
+ clearTimeout(settleTimer);
601
+ settleTimer = setTimeout(() => {
602
+ settleTimer = undefined;
603
+ if (outageStartMs === null || gracefulDisconnect)
604
+ return;
605
+ debug?.warn("session", "recovery_settle_timeout", reason);
606
+ scheduleAutoReconnect(`${reason}_timeout`);
607
+ }, ms);
608
+ };
609
+ /** ICE/PC `disconnected`: start recovery if it lasts longer than the grace period. */
610
+ const noteIceDisconnected = (localPc) => {
611
+ if (!hasReachedReadyOnce || gracefulDisconnect)
612
+ return;
613
+ if (reconnectPolicy === "new-session")
614
+ return;
615
+ if (iceDisconnectedTimer || outageStartMs !== null)
616
+ return;
617
+ const lossAtMs = Date.now();
618
+ iceDisconnectedTimer = setTimeout(() => {
619
+ iceDisconnectedTimer = undefined;
620
+ if (pc !== localPc || gracefulDisconnect)
621
+ return;
622
+ beginOutage("ice_disconnected", lossAtMs, true);
623
+ startRecovery("ice_disconnected");
624
+ }, ICE_DISCONNECTED_GRACE_MS);
625
+ };
626
+ const startRecovery = (reason) => {
627
+ if (gracefulDisconnect || reconnectPolicy === "new-session")
628
+ return;
629
+ if (canIceRecover()) {
630
+ scheduleIceRecovery(reason);
631
+ }
632
+ else if (canAutoReconnectTransport()) {
633
+ scheduleAutoReconnect(reason);
634
+ }
635
+ else {
636
+ emitAutoReconnectExhausted(reason);
637
+ }
638
+ };
450
639
  const ensureConnectedPromise = () => {
451
640
  if (!connectedPromise) {
452
641
  connectedPromise = new Promise((resolve, reject) => {
@@ -465,7 +654,7 @@ export async function connectBrowserVoiceSession(options) {
465
654
  const canAutoReconnectTransport = () => {
466
655
  if (gracefulDisconnect || reconnectPolicy === "new-session")
467
656
  return false;
468
- return autoReconnectAttempts < maxAutoReconnectAttempts;
657
+ return autoReconnectAttempts < maxAutoReconnectAttemptsLimit();
469
658
  };
470
659
  const reconnectInfo = (reason) => ({
471
660
  reason: reason ?? lastReconnectReason ?? "unknown",
@@ -504,7 +693,13 @@ export async function connectBrowserVoiceSession(options) {
504
693
  if (reconnectTimer)
505
694
  clearTimeout(reconnectTimer);
506
695
  reconnectTimer = setTimeout(() => {
507
- void reconnectSignaling().catch((error) => {
696
+ void reconnectSignaling()
697
+ .then(() => armSettleTimer(ICE_RECOVERY_SETTLE_MS, "ice_recovery"))
698
+ .catch((error) => {
699
+ if (error instanceof SessionEndedDuringReconnectError) {
700
+ endSessionDuringReconnect();
701
+ return;
702
+ }
508
703
  debug?.warn("session", "ice_recovery_failed", error instanceof Error ? error.message : String(error));
509
704
  if (canAutoReconnectTransport()) {
510
705
  scheduleAutoReconnect(reason);
@@ -592,6 +787,8 @@ export async function connectBrowserVoiceSession(options) {
592
787
  const handleTransportFailure = (state, reconnectReason) => {
593
788
  stopMicPump?.();
594
789
  stopMicPump = null;
790
+ clearIceDisconnectedTimer();
791
+ beginOutage(reconnectReason, Date.now(), state === "failed");
595
792
  if (!gracefulDisconnect) {
596
793
  if (state === "failed") {
597
794
  notifySessionError({
@@ -624,8 +821,8 @@ export async function connectBrowserVoiceSession(options) {
624
821
  scheduleAutoReconnect(reconnectReason);
625
822
  }
626
823
  else if (!gracefulDisconnect &&
627
- maxAutoReconnectAttempts > 0 &&
628
- autoReconnectAttempts >= maxAutoReconnectAttempts) {
824
+ maxAutoReconnectAttemptsLimit() > 0 &&
825
+ autoReconnectAttempts >= maxAutoReconnectAttemptsLimit()) {
629
826
  emitAutoReconnectExhausted(reconnectReason);
630
827
  }
631
828
  };
@@ -715,6 +912,9 @@ export async function connectBrowserVoiceSession(options) {
715
912
  }
716
913
  stopMicPump?.();
717
914
  stopMicPump = null;
915
+ clearIceDisconnectedTimer();
916
+ if (outageStartMs !== null)
917
+ outageRestoreEligible = true;
718
918
  controlChannel = null;
719
919
  syncChannel = null;
720
920
  const localPc = pc;
@@ -931,6 +1131,7 @@ export async function connectBrowserVoiceSession(options) {
931
1131
  if (connectionState === "connected") {
932
1132
  if (!isPcCurrent())
933
1133
  return;
1134
+ clearIceDisconnectedTimer();
934
1135
  autoReconnectAttempts = 0;
935
1136
  iceRecoveryAttempts = 0;
936
1137
  stopIceStuckWatch();
@@ -941,6 +1142,11 @@ export async function connectBrowserVoiceSession(options) {
941
1142
  }
942
1143
  syncOutboundAudioTrack();
943
1144
  }
1145
+ else if (connectionState === "disconnected") {
1146
+ if (!isPcCurrent())
1147
+ return;
1148
+ noteIceDisconnected(localPc);
1149
+ }
944
1150
  else if (connectionState === "failed") {
945
1151
  if (!isPcCurrent())
946
1152
  return;
@@ -963,6 +1169,13 @@ export async function connectBrowserVoiceSession(options) {
963
1169
  return;
964
1170
  const iceConnectionState = localPc.iceConnectionState ?? "new";
965
1171
  debug?.info("webrtc", "ice_connection_state", iceConnectionState);
1172
+ if (iceConnectionState === "disconnected") {
1173
+ noteIceDisconnected(localPc);
1174
+ }
1175
+ else if (iceConnectionState === "connected" ||
1176
+ iceConnectionState === "completed") {
1177
+ clearIceDisconnectedTimer();
1178
+ }
966
1179
  updateConnectionSnapshot({ iceConnectionState });
967
1180
  };
968
1181
  localPc.onicegatheringstatechange = () => {
@@ -1107,7 +1320,8 @@ export async function connectBrowserVoiceSession(options) {
1107
1320
  const scheduleAutoReconnect = (reason) => {
1108
1321
  if (gracefulDisconnect || reconnectPolicy === "new-session")
1109
1322
  return;
1110
- if (autoReconnectAttempts >= maxAutoReconnectAttempts) {
1323
+ beginOutage(reason, Date.now(), false);
1324
+ if (autoReconnectAttempts >= maxAutoReconnectAttemptsLimit()) {
1111
1325
  emitAutoReconnectExhausted(reason);
1112
1326
  return;
1113
1327
  }
@@ -1116,16 +1330,37 @@ export async function connectBrowserVoiceSession(options) {
1116
1330
  awaitingReconnectedCallback = true;
1117
1331
  lastReconnectReason = reason;
1118
1332
  options.onReconnecting?.(autoReconnectAttempts, reconnectInfo(reason));
1119
- const delayMs = Math.min(1000 * 2 ** (autoReconnectAttempts - 1), 8000);
1333
+ const delayMs = RECONNECT_BACKOFF_MS[Math.min(autoReconnectAttempts - 1, RECONNECT_BACKOFF_MS.length - 1)];
1120
1334
  if (reconnectTimer)
1121
1335
  clearTimeout(reconnectTimer);
1336
+ if (settleTimer)
1337
+ clearTimeout(settleTimer);
1338
+ settleTimer = undefined;
1122
1339
  reconnectTimer = setTimeout(() => {
1123
- void reconnectSignaling().catch((error) => {
1340
+ outageRecoveryVia = "reconnect";
1341
+ void reconnectSignaling()
1342
+ .then(() => armSettleTimer(RECONNECT_SETTLE_MS, "reconnect"))
1343
+ .catch((error) => {
1344
+ if (error instanceof SessionEndedDuringReconnectError) {
1345
+ endSessionDuringReconnect();
1346
+ return;
1347
+ }
1124
1348
  debug?.warn("session", "auto_reconnect_failed", error instanceof Error ? error.message : String(error));
1125
1349
  scheduleAutoReconnect(reason);
1126
1350
  });
1127
1351
  }, delayMs);
1128
1352
  };
1353
+ /**
1354
+ * One structured line per signaling WebSocket failure (token redacted).
1355
+ * `httpStatus` is only known on runtimes that expose the upgrade response (Node `ws`).
1356
+ */
1357
+ const logSignalingFailure = (input) => {
1358
+ const elapsedMs = Date.now() - (outageStartMs ?? input.startedAtMs);
1359
+ const reason = input.close?.reason && input.close.reason.length > 0
1360
+ ? input.close.reason
1361
+ : "n/a";
1362
+ debug?.error("signaling", "ws_error", `signaling ws failed attempt=${autoReconnectAttempts} elapsed=${elapsedMs} phase=${input.phase} code=${input.close?.code ?? "n/a"} reason=${reason} httpStatus=${input.httpStatus ?? "n/a"} wasClean=${input.close?.wasClean ?? "n/a"} url=${redactSignalingUrlForLog(input.url)}`);
1363
+ };
1129
1364
  const attachWsHandlers = (localWs, boundEpoch) => {
1130
1365
  const isCurrentWs = () => ws === localWs && boundEpoch === signalingEpoch && !gracefulDisconnect;
1131
1366
  localWs.onmessage = (event) => {
@@ -1171,17 +1406,39 @@ export async function connectBrowserVoiceSession(options) {
1171
1406
  break;
1172
1407
  }
1173
1408
  };
1174
- localWs.onclose = () => {
1409
+ localWs.onclose = (event) => {
1175
1410
  if (!isCurrentWs())
1176
1411
  return;
1177
1412
  if (gracefulDisconnect || reconnectPolicy === "new-session")
1178
1413
  return;
1414
+ logSignalingFailure({
1415
+ phase: "open",
1416
+ url: signalingUrl,
1417
+ startedAtMs: Date.now(),
1418
+ close: event,
1419
+ });
1179
1420
  scheduleAutoReconnect("signaling_closed");
1180
1421
  };
1181
1422
  };
1182
1423
  const joinSignalingRoom = async (isReconnect, boundEpoch) => {
1183
1424
  if (isReconnect) {
1184
1425
  assertReplacementAllowed();
1426
+ // Close the old socket ourselves first; never wait for it to notice the outage.
1427
+ const staleWs = ws;
1428
+ if (staleWs) {
1429
+ staleWs.onclose = null;
1430
+ staleWs.onmessage = null;
1431
+ staleWs.onerror = null;
1432
+ try {
1433
+ staleWs.close();
1434
+ }
1435
+ catch {
1436
+ /* ignore */
1437
+ }
1438
+ if (ws === staleWs) {
1439
+ ws = null;
1440
+ }
1441
+ }
1185
1442
  const closeResult = await retirePeerConnection({
1186
1443
  preserveConnectedWait: true,
1187
1444
  });
@@ -1222,7 +1479,9 @@ export async function connectBrowserVoiceSession(options) {
1222
1479
  return;
1223
1480
  }
1224
1481
  assertReplacementAllowed();
1225
- const nextWs = new runtime.WebSocket(signalingUrl);
1482
+ const connectUrl = signalingUrl;
1483
+ const connectStartedAtMs = Date.now();
1484
+ const nextWs = new runtime.WebSocket(connectUrl);
1226
1485
  if (boundEpoch !== signalingEpoch) {
1227
1486
  try {
1228
1487
  nextWs.close();
@@ -1233,8 +1492,63 @@ export async function connectBrowserVoiceSession(options) {
1233
1492
  return;
1234
1493
  }
1235
1494
  ws = nextWs;
1495
+ const httpStatusOf = () => {
1496
+ const status = nextWs.httpStatus;
1497
+ return typeof status === "number" ? status : undefined;
1498
+ };
1236
1499
  await new Promise((resolve, reject) => {
1500
+ let connectTimer;
1501
+ let done = false;
1502
+ /** `error` is followed by `close` (with code/reason) in the next task; wait for it briefly. */
1503
+ let errorTimer;
1504
+ const settle = () => {
1505
+ if (connectTimer)
1506
+ clearTimeout(connectTimer);
1507
+ connectTimer = undefined;
1508
+ if (errorTimer)
1509
+ clearTimeout(errorTimer);
1510
+ errorTimer = undefined;
1511
+ };
1512
+ /** Connect-phase failure: log once, then reject (401 on reconnect = session gone). */
1513
+ const failConnect = (close, error) => {
1514
+ if (done)
1515
+ return;
1516
+ done = true;
1517
+ settle();
1518
+ const httpStatus = httpStatusOf();
1519
+ logSignalingFailure({
1520
+ phase: "connect",
1521
+ url: connectUrl,
1522
+ startedAtMs: connectStartedAtMs,
1523
+ httpStatus,
1524
+ close,
1525
+ });
1526
+ reject(isReconnect && httpStatus === 401
1527
+ ? new SessionEndedDuringReconnectError()
1528
+ : error);
1529
+ };
1530
+ if (isReconnect) {
1531
+ connectTimer = setTimeout(() => {
1532
+ connectTimer = undefined;
1533
+ if (ws !== nextWs || boundEpoch !== signalingEpoch) {
1534
+ reject(new Error("WebSocket superseded during reconnect"));
1535
+ return;
1536
+ }
1537
+ nextWs.onopen = null;
1538
+ nextWs.onerror = null;
1539
+ nextWs.onclose = null;
1540
+ try {
1541
+ nextWs.close();
1542
+ }
1543
+ catch {
1544
+ /* ignore */
1545
+ }
1546
+ failConnect(undefined, new Error(`WebSocket did not open within ${SIGNALING_CONNECT_TIMEOUT_MS}ms`));
1547
+ }, SIGNALING_CONNECT_TIMEOUT_MS);
1548
+ }
1237
1549
  nextWs.onopen = () => {
1550
+ done = true;
1551
+ settle();
1238
1552
  if (ws !== nextWs || boundEpoch !== signalingEpoch) {
1239
1553
  reject(new Error("WebSocket superseded during reconnect"));
1240
1554
  return;
@@ -1248,6 +1562,7 @@ export async function connectBrowserVoiceSession(options) {
1248
1562
  };
1249
1563
  nextWs.onerror = () => {
1250
1564
  if (ws !== nextWs || boundEpoch !== signalingEpoch) {
1565
+ settle();
1251
1566
  reject(new Error("WebSocket superseded during reconnect"));
1252
1567
  return;
1253
1568
  }
@@ -1257,8 +1572,16 @@ export async function connectBrowserVoiceSession(options) {
1257
1572
  peerId,
1258
1573
  kind: "signaling-ws",
1259
1574
  }), { fallbackLog: false });
1260
- debug?.error("signaling", "ws_error", redactSignalingUrlForLog(signalingUrl));
1261
- reject(new Error("WebSocket error"));
1575
+ errorTimer ??= setTimeout(() => {
1576
+ errorTimer = undefined;
1577
+ failConnect(undefined, new Error("WebSocket error"));
1578
+ }, 0);
1579
+ };
1580
+ // Close before open (with or without a preceding `error`) carries code/reason.
1581
+ nextWs.onclose = (event) => {
1582
+ if (ws !== nextWs || boundEpoch !== signalingEpoch)
1583
+ return;
1584
+ failConnect(event, new Error("WebSocket closed before open"));
1262
1585
  };
1263
1586
  });
1264
1587
  if (boundEpoch !== signalingEpoch) {
@@ -1344,6 +1667,23 @@ export async function connectBrowserVoiceSession(options) {
1344
1667
  }
1345
1668
  return syncChannel;
1346
1669
  };
1670
+ /**
1671
+ * Best-effort explicit hangup on the control channel. Only for integrator-initiated
1672
+ * ends, never for reconnects or unintentional loss.
1673
+ */
1674
+ const sendClientHangup = () => {
1675
+ const channel = controlChannel;
1676
+ if (!channel || channel.readyState !== "open")
1677
+ return null;
1678
+ try {
1679
+ channel.send(JSON.stringify({ type: CLIENT_HANGUP_MESSAGE_TYPE }));
1680
+ debug?.info("session", "client_hangup_sent");
1681
+ return channel;
1682
+ }
1683
+ catch {
1684
+ return null;
1685
+ }
1686
+ };
1347
1687
  const waitForConnected = async (timeoutMs = 60_000) => {
1348
1688
  const deadlineMs = Date.now() + timeoutMs;
1349
1689
  const halfOpenFailFastMs = resolveHalfOpenFailFastMs(readinessProfile, timeoutMs);
@@ -1538,6 +1878,8 @@ export async function connectBrowserVoiceSession(options) {
1538
1878
  },
1539
1879
  disconnect: () => {
1540
1880
  // Sync terminal invalidation — must not await native close (reconnect opens WS promptly).
1881
+ if (!gracefulDisconnect)
1882
+ sendClientHangup();
1541
1883
  gracefulDisconnect = true;
1542
1884
  negotiationGeneration += 1;
1543
1885
  activePcGeneration = 0;
@@ -1547,6 +1889,7 @@ export async function connectBrowserVoiceSession(options) {
1547
1889
  if (reconnectTimer)
1548
1890
  clearTimeout(reconnectTimer);
1549
1891
  reconnectTimer = undefined;
1892
+ clearRecoveryTimers();
1550
1893
  awaitingReconnectedCallback = false;
1551
1894
  stopMicPump?.();
1552
1895
  stopMicPump = null;
@@ -1636,6 +1979,15 @@ export async function connectBrowserVoiceSession(options) {
1636
1979
  return disconnectAsyncInFlight;
1637
1980
  }
1638
1981
  disconnectAsyncInFlight = (async () => {
1982
+ const hangupChannel = gracefulDisconnect ? null : sendClientHangup();
1983
+ if (hangupChannel) {
1984
+ const drainDeadline = Date.now() + CLIENT_HANGUP_DRAIN_MS;
1985
+ while (hangupChannel.readyState === "open" &&
1986
+ (hangupChannel.bufferedAmount ?? 0) > 0 &&
1987
+ Date.now() < drainDeadline) {
1988
+ await new Promise((resolve) => setTimeout(resolve, 10));
1989
+ }
1990
+ }
1639
1991
  gracefulDisconnect = true;
1640
1992
  negotiationGeneration += 1;
1641
1993
  activePcGeneration = 0;
@@ -1644,6 +1996,7 @@ export async function connectBrowserVoiceSession(options) {
1644
1996
  if (reconnectTimer)
1645
1997
  clearTimeout(reconnectTimer);
1646
1998
  reconnectTimer = undefined;
1999
+ clearRecoveryTimers();
1647
2000
  awaitingReconnectedCallback = false;
1648
2001
  stopMicPump?.();
1649
2002
  stopMicPump = null;