@voicethere/client 0.10.0 → 0.11.1

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