@voicethere/client 0.9.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.
- package/CHANGELOG.md +25 -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 +29 -3
- package/dist/browser/browser-voice-session.d.ts.map +1 -1
- package/dist/browser/browser-voice-session.js +370 -17
- package/dist/browser/browser-voice-session.js.map +1 -1
- package/dist/browser/session-provision.d.ts +2 -0
- package/dist/browser/session-provision.d.ts.map +1 -1
- package/dist/browser/session-provision.js +1 -0
- package/dist/browser/session-provision.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/bootstrap.d.ts +3 -1
- package/dist/embed/bootstrap.d.ts.map +1 -1
- package/dist/embed/bootstrap.js +127 -16
- package/dist/embed/bootstrap.js.map +1 -1
- package/dist/embed/hosts.d.ts +7 -0
- package/dist/embed/hosts.d.ts.map +1 -1
- package/dist/embed/hosts.js +23 -3
- package/dist/embed/hosts.js.map +1 -1
- package/dist/embed/index.d.ts +1 -1
- package/dist/embed/index.d.ts.map +1 -1
- package/dist/embed/index.js +48 -3
- 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,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
|
|
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
|
|
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",
|
|
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:
|
|
437
|
-
message:
|
|
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 <
|
|
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()
|
|
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
|
-
|
|
628
|
-
autoReconnectAttempts >=
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1261
|
-
|
|
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;
|