@anam-ai/js-sdk 4.17.0 → 4.18.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.
Files changed (36) hide show
  1. package/dist/main/lib/ClientMetrics.d.ts +10 -2
  2. package/dist/main/lib/ClientMetrics.d.ts.map +1 -1
  3. package/dist/main/lib/ClientMetrics.js +46 -31
  4. package/dist/main/lib/ClientMetrics.js.map +1 -1
  5. package/dist/main/lib/ConnectionMilestones.d.ts +2 -2
  6. package/dist/main/lib/ConnectionMilestones.d.ts.map +1 -1
  7. package/dist/main/lib/ConnectionMilestones.js +16 -5
  8. package/dist/main/lib/ConnectionMilestones.js.map +1 -1
  9. package/dist/main/lib/constants.js +1 -1
  10. package/dist/main/modules/SignallingClient.d.ts +23 -0
  11. package/dist/main/modules/SignallingClient.d.ts.map +1 -1
  12. package/dist/main/modules/SignallingClient.js +107 -14
  13. package/dist/main/modules/SignallingClient.js.map +1 -1
  14. package/dist/main/modules/StreamingClient.d.ts +36 -0
  15. package/dist/main/modules/StreamingClient.d.ts.map +1 -1
  16. package/dist/main/modules/StreamingClient.js +363 -25
  17. package/dist/main/modules/StreamingClient.js.map +1 -1
  18. package/dist/module/lib/ClientMetrics.d.ts +10 -2
  19. package/dist/module/lib/ClientMetrics.d.ts.map +1 -1
  20. package/dist/module/lib/ClientMetrics.js +43 -29
  21. package/dist/module/lib/ClientMetrics.js.map +1 -1
  22. package/dist/module/lib/ConnectionMilestones.d.ts +2 -2
  23. package/dist/module/lib/ConnectionMilestones.d.ts.map +1 -1
  24. package/dist/module/lib/ConnectionMilestones.js +17 -6
  25. package/dist/module/lib/ConnectionMilestones.js.map +1 -1
  26. package/dist/module/lib/constants.js +1 -1
  27. package/dist/module/modules/SignallingClient.d.ts +23 -0
  28. package/dist/module/modules/SignallingClient.d.ts.map +1 -1
  29. package/dist/module/modules/SignallingClient.js +107 -14
  30. package/dist/module/modules/SignallingClient.js.map +1 -1
  31. package/dist/module/modules/StreamingClient.d.ts +36 -0
  32. package/dist/module/modules/StreamingClient.d.ts.map +1 -1
  33. package/dist/module/modules/StreamingClient.js +363 -25
  34. package/dist/module/modules/StreamingClient.js.map +1 -1
  35. package/dist/umd/anam.js +1 -1
  36. package/package.json +1 -1
@@ -14,8 +14,21 @@ export declare class StreamingClient {
14
14
  private rtcConfiguration;
15
15
  private apiGatewayConfig;
16
16
  private peerConnection;
17
+ private connectionEstablishedEmitted;
18
+ private iceRestartInProgress;
19
+ private iceRestartAttempts;
20
+ private iceRestartAwaitedAnswer;
21
+ private iceRestartEpisodeStartMs;
22
+ private iceRestartEpisodeTrigger;
23
+ private iceRestartStopped;
24
+ private iceDisconnectedGraceTimer;
25
+ private iceRestartWatchdogTimer;
26
+ private pendingWsOpenListener;
27
+ private pendingWsOpenTimeout;
28
+ private pendingWsOpenReject;
17
29
  private connectionReceivedAnswer;
18
30
  private remoteIceCandidateBuffer;
31
+ private iceRestartCandidateBuffer;
19
32
  private inputAudioStream;
20
33
  private dataChannel;
21
34
  private videoElement;
@@ -83,6 +96,29 @@ export declare class StreamingClient {
83
96
  */
84
97
  private onIceCandidate;
85
98
  private onIceConnectionStateChange;
99
+ private clearIceDisconnectedGraceTimer;
100
+ private clearIceRestartWatchdog;
101
+ private sendIceRestartMetric;
102
+ private isAwaitingRestartAnswer;
103
+ private onAnswerAccepted;
104
+ private clearPendingWsOpenWait;
105
+ private cancelIceRestart;
106
+ private flushIceRestartCandidateBuffer;
107
+ private scheduleIceRestartAfterGrace;
108
+ /**
109
+ * Resolve once the signalling WebSocket is open (it auto-reconnects on close),
110
+ * or reject if it is terminally closed / times out. Restart offers must never
111
+ * be sent on a dead socket. Timeout + listener are stored so shutdown can clear
112
+ * them (see clearPendingWsOpenWait).
113
+ */
114
+ private ensureSignallingConnected;
115
+ /**
116
+ * Automatic ICE restart. Mints a new offer with fresh ICE credentials and
117
+ * sends it over the existing channel. Bounded retries via a watchdog; on
118
+ * exhaustion or terminal signalling, falls back to the WebRTC-failure path.
119
+ */
120
+ private restartIce;
121
+ private onIceRestartWatchdog;
86
122
  private onConnectionStateChange;
87
123
  private handleWebrtcFailure;
88
124
  private onTrackEventHandler;
@@ -1 +1 @@
1
- {"version":3,"file":"StreamingClient.d.ts","sourceRoot":"","sources":["../../../src/modules/StreamingClient.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"StreamingClient.d.ts","sourceRoot":"","sources":["../../../src/modules/StreamingClient.ts"],"names":[],"mappings":"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"}
@@ -19,12 +19,34 @@ const ToolCallManager_1 = require("./ToolCallManager");
19
19
  const SUCCESS_METRIC_POLLING_TIMEOUT_MS = 15000; // After this time we will stop polling for the first frame and consider the session a failure.
20
20
  const STATS_COLLECTION_INTERVAL_MS = 5000;
21
21
  const ICE_CANDIDATE_POOL_SIZE = 2; // Optimisation to speed up connection time
22
+ const MAX_ICE_RESTART_ATTEMPTS = 3;
23
+ const ICE_DISCONNECTED_GRACE_MS = 2000;
24
+ const ICE_RESTART_WATCHDOG_MS = 3000;
25
+ const ENSURE_WS_OPEN_TIMEOUT_MS = 5000;
22
26
  class StreamingClient {
23
27
  constructor(sessionId, options, publicEventEmitter, internalEventEmitter, toolCallManager, connectionMilestones) {
24
28
  var _a, _b, _c, _d;
25
29
  this.peerConnection = null;
30
+ this.connectionEstablishedEmitted = false;
31
+ this.iceRestartInProgress = false;
32
+ this.iceRestartAttempts = 0;
33
+ this.iceRestartAwaitedAnswer = false;
34
+ // Restart-episode telemetry (see sendIceRestartMetric). null = no episode.
35
+ this.iceRestartEpisodeStartMs = null;
36
+ this.iceRestartEpisodeTrigger = null;
37
+ this.iceRestartStopped = false; // set on shutdown; halts all restart activity
38
+ this.iceDisconnectedGraceTimer = null;
39
+ this.iceRestartWatchdogTimer = null;
40
+ this.pendingWsOpenListener = null;
41
+ this.pendingWsOpenTimeout = null;
42
+ this.pendingWsOpenReject = null;
26
43
  this.connectionReceivedAnswer = false;
27
44
  this.remoteIceCandidateBuffer = [];
45
+ // While a re-offer is being minted and sent, local candidates are buffered here
46
+ // so they reach the server AFTER the restart offer (which carries the new ICE
47
+ // credentials); otherwise the engine adds them against the old generation and
48
+ // drops them. null = not buffering (normal path).
49
+ this.iceRestartCandidateBuffer = null;
28
50
  this.inputAudioStream = null;
29
51
  this.dataChannel = null;
30
52
  this.videoElement = null;
@@ -431,9 +453,30 @@ class StreamingClient {
431
453
  case types_1.SignalMessageAction.ANSWER: {
432
454
  (_a = this.connectionMilestones) === null || _a === void 0 ? void 0 : _a.record('answer_received');
433
455
  const answer = signalMessage.payload;
434
- yield this.peerConnection.setRemoteDescription(answer);
456
+ if (this.peerConnection.signalingState !== 'have-local-offer') {
457
+ // Late answer to a superseded / rolled-back restart offer — ignore it so
458
+ // it cannot be applied in a stable state or against a different offer.
459
+ break;
460
+ }
461
+ try {
462
+ yield this.peerConnection.setRemoteDescription(answer);
463
+ }
464
+ catch (err) {
465
+ console.error('StreamingClient - setRemoteDescription(answer) failed', err);
466
+ if (!this.connectionEstablishedEmitted &&
467
+ !this.isAwaitingRestartAnswer()) {
468
+ // Initial-connection answer: no restart watchdog will retry, so
469
+ // surface the failure instead of hanging the connection. Once the
470
+ // session has been established, any answer is a restart answer
471
+ // (even if ICE recovered on its own and cleared the restart flags
472
+ // before it arrived) — ICE-failure handling owns recovery then.
473
+ this.handleWebrtcFailure(err);
474
+ }
475
+ break; // restart answers: let the ICE restart watchdog retry
476
+ }
435
477
  (_b = this.connectionMilestones) === null || _b === void 0 ? void 0 : _b.record('remote_description_set');
436
478
  this.connectionReceivedAnswer = true;
479
+ this.onAnswerAccepted();
437
480
  // flush the remote buffer
438
481
  yield this.flushRemoteIceCandidateBuffer();
439
482
  break;
@@ -455,6 +498,8 @@ class StreamingClient {
455
498
  }
456
499
  case types_1.SignalMessageAction.END_SESSION:
457
500
  const reason = signalMessage.payload;
501
+ // Server-ended session mid-restart counts as aborted; user hang-up doesn't.
502
+ this.sendIceRestartMetric('aborted');
458
503
  (_d = this.connectionMilestones) === null || _d === void 0 ? void 0 : _d.publishFailure({
459
504
  failureStage: 'server_closed_connection',
460
505
  });
@@ -546,6 +591,11 @@ class StreamingClient {
546
591
  this.firstLocalIceCandidateSent = true;
547
592
  (_a = this.connectionMilestones) === null || _a === void 0 ? void 0 : _a.record('first_local_ice_candidate_sent', getIceCandidateMilestoneTags(event.candidate));
548
593
  }
594
+ if (this.iceRestartCandidateBuffer) {
595
+ // Hold until the re-offer is sent (see restartIce).
596
+ this.iceRestartCandidateBuffer.push(event.candidate);
597
+ return;
598
+ }
549
599
  this.signallingClient.sendIceCandidate(event.candidate);
550
600
  }
551
601
  else {
@@ -553,31 +603,307 @@ class StreamingClient {
553
603
  }
554
604
  }
555
605
  onIceConnectionStateChange() {
556
- var _a, _b, _c, _d, _e, _f, _g;
557
- const iceConnectionState = (_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.iceConnectionState;
558
- if (iceConnectionState) {
606
+ var _a, _b, _c;
607
+ const state = (_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.iceConnectionState;
608
+ if (state) {
559
609
  (_b = this.connectionMilestones) === null || _b === void 0 ? void 0 : _b.record('ice_connection_state_changed', {
560
- iceConnectionState,
610
+ iceConnectionState: state,
561
611
  });
562
612
  }
563
- if (((_c = this.peerConnection) === null || _c === void 0 ? void 0 : _c.iceConnectionState) === 'connected' ||
564
- ((_d = this.peerConnection) === null || _d === void 0 ? void 0 : _d.iceConnectionState) === 'completed') {
565
- if (!this.connectionEstablishedMilestoneRecorded) {
566
- this.connectionEstablishedMilestoneRecorded = true;
567
- (_e = this.connectionMilestones) === null || _e === void 0 ? void 0 : _e.record('client_connection_established', {
568
- iceConnectionState: this.peerConnection.iceConnectionState,
569
- });
613
+ switch (state) {
614
+ case 'connected':
615
+ case 'completed':
616
+ this.clearIceDisconnectedGraceTimer();
617
+ this.clearIceRestartWatchdog();
618
+ this.iceRestartInProgress = false;
619
+ this.sendIceRestartMetric('recovered'); // before the attempts reset below
620
+ this.iceRestartAttempts = 0;
621
+ this.signallingClient.endIceRestartReconnect();
622
+ if (!this.connectionEstablishedMilestoneRecorded) {
623
+ this.connectionEstablishedMilestoneRecorded = true;
624
+ (_c = this.connectionMilestones) === null || _c === void 0 ? void 0 : _c.record('client_connection_established', {
625
+ iceConnectionState: state,
626
+ });
627
+ }
628
+ if (!this.connectionEstablishedEmitted) {
629
+ this.connectionEstablishedEmitted = true;
630
+ this.publicEventEmitter.emit(types_1.AnamEvent.CONNECTION_ESTABLISHED);
631
+ }
632
+ this.startStatsCollection(); // idempotent (guards on statsCollectionInterval)
633
+ break;
634
+ case 'disconnected':
635
+ this.scheduleIceRestartAfterGrace();
636
+ break;
637
+ case 'failed':
638
+ this.clearIceDisconnectedGraceTimer();
639
+ void this.restartIce();
640
+ break;
641
+ }
642
+ }
643
+ clearIceDisconnectedGraceTimer() {
644
+ if (this.iceDisconnectedGraceTimer) {
645
+ clearTimeout(this.iceDisconnectedGraceTimer);
646
+ this.iceDisconnectedGraceTimer = null;
647
+ }
648
+ }
649
+ clearIceRestartWatchdog() {
650
+ if (this.iceRestartWatchdogTimer) {
651
+ clearTimeout(this.iceRestartWatchdogTimer);
652
+ this.iceRestartWatchdogTimer = null;
653
+ }
654
+ }
655
+ // One metric per restart episode, sent at episode end. No-op when no
656
+ // episode is active; deliberately silent on user shutdown mid-episode.
657
+ sendIceRestartMetric(outcome) {
658
+ var _a;
659
+ if (this.iceRestartEpisodeStartMs === null) {
660
+ return;
661
+ }
662
+ const durationMs = Math.round(performance.now() - this.iceRestartEpisodeStartMs);
663
+ const trigger = (_a = this.iceRestartEpisodeTrigger) !== null && _a !== void 0 ? _a : 'unknown';
664
+ this.iceRestartEpisodeStartMs = null;
665
+ this.iceRestartEpisodeTrigger = null;
666
+ // durationMs is the value, not a tag: near-unique tag values explode
667
+ // InfluxDB series cardinality. Episode count = count of points.
668
+ (0, ClientMetrics_1.sendClientMetric)(ClientMetrics_1.ClientMetricMeasurement.CLIENT_METRIC_MEASUREMENT_ICE_RESTART, durationMs, {
669
+ outcome,
670
+ attempts: this.iceRestartAttempts,
671
+ trigger,
672
+ });
673
+ }
674
+ // True while a restart offer is outstanding (in-progress gate, awaited-answer
675
+ // grace cycle, or armed watchdog) — i.e. an incoming answer belongs to an ICE
676
+ // restart rather than the initial connection.
677
+ isAwaitingRestartAnswer() {
678
+ return (this.iceRestartInProgress ||
679
+ this.iceRestartAwaitedAnswer ||
680
+ this.iceRestartWatchdogTimer !== null);
681
+ }
682
+ onAnswerAccepted() {
683
+ var _a;
684
+ const wasAwaitingRestartAnswer = this.isAwaitingRestartAnswer();
685
+ this.clearIceRestartWatchdog();
686
+ this.iceRestartAwaitedAnswer = false;
687
+ if (!wasAwaitingRestartAnswer) {
688
+ return;
689
+ }
690
+ this.iceRestartInProgress = false;
691
+ const state = (_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.iceConnectionState;
692
+ if (state === 'disconnected' || state === 'failed') {
693
+ this.scheduleIceRestartAfterGrace();
694
+ }
695
+ }
696
+ // Clears the pending ensureSignallingConnected wait (timeout + listener).
697
+ clearPendingWsOpenWait() {
698
+ if (this.pendingWsOpenTimeout) {
699
+ clearTimeout(this.pendingWsOpenTimeout);
700
+ this.pendingWsOpenTimeout = null;
701
+ }
702
+ if (this.pendingWsOpenListener) {
703
+ this.internalEventEmitter.removeListener(types_1.InternalEvent.WEB_SOCKET_OPEN, this.pendingWsOpenListener);
704
+ this.pendingWsOpenListener = null;
705
+ }
706
+ this.pendingWsOpenReject = null;
707
+ }
708
+ // Stops all restart activity: timers, pending WS wait, and the in-progress gate.
709
+ cancelIceRestart() {
710
+ this.clearIceDisconnectedGraceTimer();
711
+ this.clearIceRestartWatchdog();
712
+ const reject = this.pendingWsOpenReject;
713
+ this.clearPendingWsOpenWait();
714
+ if (reject)
715
+ reject(new Error('ice restart cancelled'));
716
+ this.iceRestartCandidateBuffer = null;
717
+ this.iceRestartInProgress = false;
718
+ // End the metric episode: a late END_SESSION after shutdown must not
719
+ // count the hang-up as an aborted restart.
720
+ this.iceRestartEpisodeStartMs = null;
721
+ this.iceRestartEpisodeTrigger = null;
722
+ this.signallingClient.endIceRestartReconnect();
723
+ }
724
+ // Send any candidates buffered while the re-offer was minted+sent, then stop
725
+ // buffering (see restartIce/onIceCandidate).
726
+ flushIceRestartCandidateBuffer() {
727
+ const buffered = this.iceRestartCandidateBuffer;
728
+ this.iceRestartCandidateBuffer = null;
729
+ if (!buffered)
730
+ return;
731
+ for (const candidate of buffered) {
732
+ this.signallingClient.sendIceCandidate(candidate);
733
+ }
734
+ }
735
+ scheduleIceRestartAfterGrace() {
736
+ if (this.iceRestartStopped ||
737
+ this.iceRestartInProgress ||
738
+ this.iceDisconnectedGraceTimer) {
739
+ return;
740
+ }
741
+ this.iceDisconnectedGraceTimer = setTimeout(() => {
742
+ var _a;
743
+ this.iceDisconnectedGraceTimer = null;
744
+ if (this.iceRestartStopped)
745
+ return;
746
+ const state = (_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.iceConnectionState;
747
+ if (state === 'disconnected' || state === 'failed') {
748
+ void this.restartIce();
570
749
  }
571
- this.publicEventEmitter.emit(types_1.AnamEvent.CONNECTION_ESTABLISHED);
572
- // Start collecting stats every 5 seconds
573
- this.startStatsCollection();
750
+ }, ICE_DISCONNECTED_GRACE_MS);
751
+ }
752
+ /**
753
+ * Resolve once the signalling WebSocket is open (it auto-reconnects on close),
754
+ * or reject if it is terminally closed / times out. Restart offers must never
755
+ * be sent on a dead socket. Timeout + listener are stored so shutdown can clear
756
+ * them (see clearPendingWsOpenWait).
757
+ */
758
+ ensureSignallingConnected() {
759
+ if (this.signallingClient.isConnected()) {
760
+ return Promise.resolve();
574
761
  }
575
- else if (((_f = this.peerConnection) === null || _f === void 0 ? void 0 : _f.iceConnectionState) === 'failed') {
576
- (_g = this.connectionMilestones) === null || _g === void 0 ? void 0 : _g.publishFailure({
577
- failureStage: 'ice_connection',
578
- iceConnectionState: this.peerConnection.iceConnectionState,
579
- });
762
+ if (this.signallingClient.isPermanentlyClosed()) {
763
+ return Promise.reject(new Error('signalling permanently closed'));
580
764
  }
765
+ return new Promise((resolve, reject) => {
766
+ this.pendingWsOpenReject = reject;
767
+ this.pendingWsOpenTimeout = setTimeout(() => {
768
+ this.clearPendingWsOpenWait();
769
+ reject(new Error('timed out waiting for signalling WebSocket'));
770
+ }, ENSURE_WS_OPEN_TIMEOUT_MS);
771
+ const onOpen = () => {
772
+ this.clearPendingWsOpenWait();
773
+ resolve();
774
+ };
775
+ this.pendingWsOpenListener = onOpen;
776
+ this.internalEventEmitter.addListener(types_1.InternalEvent.WEB_SOCKET_OPEN, onOpen);
777
+ });
778
+ }
779
+ /**
780
+ * Automatic ICE restart. Mints a new offer with fresh ICE credentials and
781
+ * sends it over the existing channel. Bounded retries via a watchdog; on
782
+ * exhaustion or terminal signalling, falls back to the WebRTC-failure path.
783
+ */
784
+ restartIce() {
785
+ return __awaiter(this, void 0, void 0, function* () {
786
+ var _a, _b;
787
+ if (!this.peerConnection ||
788
+ this.iceRestartInProgress ||
789
+ this.iceRestartStopped) {
790
+ return;
791
+ }
792
+ if (this.signallingClient.isPermanentlyClosed()) {
793
+ // Session is already ending via the signalling layer; stop spinning.
794
+ this.sendIceRestartMetric('aborted');
795
+ this.cancelIceRestart();
796
+ return;
797
+ }
798
+ if (this.iceRestartAttempts >= MAX_ICE_RESTART_ATTEMPTS) {
799
+ console.error('StreamingClient - restartIce: exhausted attempts');
800
+ (_a = this.connectionMilestones) === null || _a === void 0 ? void 0 : _a.publishFailure({
801
+ failureStage: 'ice_connection',
802
+ iceConnectionState: (_b = this.peerConnection) === null || _b === void 0 ? void 0 : _b.iceConnectionState,
803
+ });
804
+ this.sendIceRestartMetric('exhausted');
805
+ this.cancelIceRestart();
806
+ this.handleWebrtcFailure('The connection to our servers was lost. Please try again.');
807
+ return;
808
+ }
809
+ if (this.iceRestartAttempts === 0) {
810
+ this.iceRestartEpisodeStartMs = performance.now();
811
+ this.iceRestartEpisodeTrigger = this.peerConnection.iceConnectionState;
812
+ }
813
+ // Set the in-progress gate BEFORE any await so a concurrent trigger
814
+ // (watchdog retry + ICE 'failed' event) cannot start a second restart.
815
+ this.iceRestartInProgress = true;
816
+ this.iceRestartAttempts += 1;
817
+ try {
818
+ // Avoid offer glare: only start a fresh offer from a stable signalling state.
819
+ if (this.peerConnection.signalingState !== 'stable') {
820
+ yield this.peerConnection.setLocalDescription({ type: 'rollback' });
821
+ }
822
+ // On the first attempt of a restart episode, force a fresh signalling
823
+ // socket. A network switch can leave the old one half-open (readyState
824
+ // still OPEN, no close event), so the offer would be sent into a dead
825
+ // socket and never reach the server. Retries (attempt > 1) reuse the
826
+ // now-live socket so an in-flight answer isn't dropped.
827
+ if (this.iceRestartAttempts === 1) {
828
+ this.signallingClient.reconnectForIceRestart();
829
+ }
830
+ yield this.ensureSignallingConnected();
831
+ if (this.iceRestartStopped)
832
+ return;
833
+ // ICE may have recovered on its own while we waited for signalling.
834
+ const currentIceState = this.peerConnection.iceConnectionState;
835
+ if (currentIceState === 'connected' || currentIceState === 'completed') {
836
+ this.iceRestartInProgress = false;
837
+ // No-ops if the connected handler already closed the episode.
838
+ this.sendIceRestartMetric('recovered');
839
+ this.signallingClient.endIceRestartReconnect();
840
+ return;
841
+ }
842
+ this.connectionReceivedAnswer = false;
843
+ this.remoteIceCandidateBuffer = [];
844
+ const offer = yield this.peerConnection.createOffer({ iceRestart: true });
845
+ // Buffer local candidates that gather from setLocalDescription until the
846
+ // re-offer is sent. The engine queues remote candidates only while it has
847
+ // no remote description; during a restart it still holds the OLD ICE
848
+ // credentials, so a candidate arriving before the re-offer is applied would
849
+ // be added against the old generation and dropped. WS messages are handled
850
+ // in order, so sending the offer first pins the new credentials server-side.
851
+ this.iceRestartCandidateBuffer = [];
852
+ yield this.peerConnection.setLocalDescription(offer);
853
+ if (!this.peerConnection.localDescription) {
854
+ throw new Error('null local description after ICE restart offer');
855
+ }
856
+ yield this.signallingClient.sendOffer(this.peerConnection.localDescription);
857
+ this.flushIceRestartCandidateBuffer();
858
+ this.iceRestartAwaitedAnswer = false;
859
+ this.clearIceRestartWatchdog();
860
+ this.iceRestartWatchdogTimer = setTimeout(() => this.onIceRestartWatchdog(), ICE_RESTART_WATCHDOG_MS);
861
+ }
862
+ catch (err) {
863
+ // The offer never made it out; drop candidates buffered for it (a retry
864
+ // mints a fresh offer and gathers again).
865
+ this.iceRestartCandidateBuffer = null;
866
+ console.error('StreamingClient - restartIce: error', err);
867
+ this.iceRestartInProgress = false;
868
+ if (this.iceRestartStopped) {
869
+ this.cancelIceRestart();
870
+ return;
871
+ }
872
+ if (this.signallingClient.isPermanentlyClosed()) {
873
+ this.sendIceRestartMetric('aborted');
874
+ this.cancelIceRestart();
875
+ return; // signalling layer already emitted CONNECTION_CLOSED
876
+ }
877
+ this.clearIceRestartWatchdog();
878
+ this.iceRestartWatchdogTimer = setTimeout(() => {
879
+ this.iceRestartWatchdogTimer = null;
880
+ void this.restartIce();
881
+ }, ICE_RESTART_WATCHDOG_MS);
882
+ }
883
+ });
884
+ }
885
+ // Watchdog for an outstanding restart offer. Only rolls back and re-offers
886
+ // once the current offer is resolved, so a second offer is never minted while
887
+ // the first offer's answer is still in flight (the ANSWER handler could
888
+ // otherwise apply it against the wrong offer).
889
+ onIceRestartWatchdog() {
890
+ var _a;
891
+ this.iceRestartWatchdogTimer = null;
892
+ this.iceRestartInProgress = false;
893
+ if (this.iceRestartStopped)
894
+ return;
895
+ const state = (_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.iceConnectionState;
896
+ if (state === 'connected' || state === 'completed')
897
+ return;
898
+ if (!this.connectionReceivedAnswer && !this.iceRestartAwaitedAnswer) {
899
+ // Offer still unanswered: give the in-flight answer one more cycle before
900
+ // rolling it back and re-offering.
901
+ this.iceRestartAwaitedAnswer = true;
902
+ this.iceRestartInProgress = true;
903
+ this.iceRestartWatchdogTimer = setTimeout(() => this.onIceRestartWatchdog(), ICE_RESTART_WATCHDOG_MS);
904
+ return;
905
+ }
906
+ void this.restartIce();
581
907
  }
582
908
  onConnectionStateChange() {
583
909
  var _a, _b, _c, _d, _e;
@@ -587,11 +913,21 @@ class StreamingClient {
587
913
  connectionState,
588
914
  });
589
915
  }
590
- if (((_c = this.peerConnection) === null || _c === void 0 ? void 0 : _c.connectionState) === 'failed') {
591
- (_d = this.connectionMilestones) === null || _d === void 0 ? void 0 : _d.publishFailure({
592
- failureStage: 'webrtc_connection',
593
- connectionState: this.peerConnection.connectionState,
594
- });
916
+ if (connectionState === 'failed') {
917
+ const iceState = (_c = this.peerConnection) === null || _c === void 0 ? void 0 : _c.iceConnectionState;
918
+ const recovering = !this.iceRestartStopped &&
919
+ (iceState === 'disconnected' || iceState === 'failed');
920
+ // A recoverable ICE failure also flips the aggregate state to 'failed'.
921
+ // The recorder is first-call-wins, so don't finalize failure telemetry
922
+ // while the ICE restart is still recovering; the terminal publish comes
923
+ // from restartIce (exhausted) or the 'closed' branch below. A genuine
924
+ // non-ICE failure (e.g. DTLS with ICE still connected) still publishes.
925
+ if (!recovering) {
926
+ (_d = this.connectionMilestones) === null || _d === void 0 ? void 0 : _d.publishFailure({
927
+ failureStage: 'webrtc_connection',
928
+ connectionState,
929
+ });
930
+ }
595
931
  }
596
932
  if (((_e = this.peerConnection) === null || _e === void 0 ? void 0 : _e.connectionState) === 'closed') {
597
933
  console.error('StreamingClient - onConnectionStateChange: Connection closed');
@@ -854,6 +1190,8 @@ class StreamingClient {
854
1190
  shutdown() {
855
1191
  return __awaiter(this, void 0, void 0, function* () {
856
1192
  var _a;
1193
+ this.iceRestartStopped = true;
1194
+ this.cancelIceRestart();
857
1195
  if (this.showPeerConnectionStatsReport) {
858
1196
  const stats = yield ((_a = this.peerConnection) === null || _a === void 0 ? void 0 : _a.getStats());
859
1197
  if (stats) {