@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.
- package/CHANGELOG.md +19 -0
- package/README.md +24 -0
- package/dist/browser/browser-session.d.ts +4 -3
- package/dist/browser/browser-session.d.ts.map +1 -1
- package/dist/browser/browser-session.js +3 -2
- package/dist/browser/browser-session.js.map +1 -1
- package/dist/browser/browser-voice-session.d.ts +33 -3
- package/dist/browser/browser-voice-session.d.ts.map +1 -1
- package/dist/browser/browser-voice-session.js +374 -17
- package/dist/browser/browser-voice-session.js.map +1 -1
- package/dist/browser/webrtc-connection-status.d.ts +28 -0
- package/dist/browser/webrtc-connection-status.d.ts.map +1 -1
- package/dist/browser/webrtc-connection-status.js +8 -0
- package/dist/browser/webrtc-connection-status.js.map +1 -1
- package/dist/embed/index.js +42 -1
- package/dist/embed/index.js.map +1 -1
- package/dist/node/node-websocket.d.ts +7 -1
- package/dist/node/node-websocket.d.ts.map +1 -1
- package/dist/node/node-websocket.js +14 -2
- package/dist/node/node-websocket.js.map +1 -1
- package/dist/session-errors.d.ts +2 -2
- package/dist/session-errors.d.ts.map +1 -1
- package/dist/session-errors.js +1 -0
- package/dist/session-errors.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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",
|
|
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:
|
|
437
|
-
message:
|
|
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 <
|
|
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()
|
|
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
|
-
|
|
628
|
-
autoReconnectAttempts >=
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1261
|
-
|
|
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;
|