@stream-io/video-client 1.55.1 → 1.56.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.
@@ -7,6 +7,7 @@ import { ErrorFromResponse } from '../../coordinator/connection/types';
7
7
  import { SfuTimeoutError } from '../../errors';
8
8
  import type { AxiosResponse } from 'axios';
9
9
  import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
10
+ import type { PeerConnectionStateChangeEvent } from '../../rtc';
10
11
 
11
12
  vi.mock('../../devices', () => ({
12
13
  getAudioBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
@@ -15,6 +16,27 @@ vi.mock('../../devices', () => ({
15
16
 
16
17
  const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
17
18
 
19
+ const peerConnectionEvent = (
20
+ peerType: PeerType,
21
+ state: RTCPeerConnectionState,
22
+ iceConnectionState: RTCIceConnectionState,
23
+ ): PeerConnectionStateChangeEvent => ({
24
+ peerType,
25
+ stateType: 'peerConnection',
26
+ state,
27
+ iceConnectionState,
28
+ });
29
+
30
+ const iceEvent = (
31
+ peerType: PeerType,
32
+ state: RTCIceConnectionState,
33
+ ): PeerConnectionStateChangeEvent => ({
34
+ peerType,
35
+ stateType: 'ice',
36
+ state,
37
+ iceConnectionState: state,
38
+ });
39
+
18
40
  describe('ClientEventReporter', () => {
19
41
  const cid = 'default:call-1';
20
42
  let doAxiosRequest: ReturnType<typeof vi.fn>;
@@ -24,6 +46,21 @@ describe('ClientEventReporter', () => {
24
46
  const postedEvents = (): Array<Record<string, any>> =>
25
47
  doAxiosRequest.mock.calls.map((call) => call[2].events[0]);
26
48
 
49
+ const reportPeerConnectionState = (
50
+ peerType: PeerType,
51
+ state: RTCPeerConnectionState,
52
+ iceConnectionState: RTCIceConnectionState,
53
+ ) => {
54
+ reporter.onPeerConnectionStateChange(
55
+ cid,
56
+ peerConnectionEvent(peerType, state, iceConnectionState),
57
+ );
58
+ };
59
+
60
+ const reportIceState = (peerType: PeerType, state: RTCIceConnectionState) => {
61
+ reporter.onPeerConnectionStateChange(cid, iceEvent(peerType, state));
62
+ };
63
+
27
64
  beforeEach(() => {
28
65
  doAxiosRequest = vi.fn().mockResolvedValue({});
29
66
  const streamClient = fromPartial<StreamClient>({
@@ -465,16 +502,16 @@ describe('ClientEventReporter', () => {
465
502
 
466
503
  it('tracks a publisher peer connection connect', async () => {
467
504
  reporter.startCorrelation(cid, 'first-attempt');
468
- reporter.onPeerConnectionStateChange(cid, {
469
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
470
- stateType: 'peerConnection',
471
- state: 'connecting',
472
- });
473
- reporter.onPeerConnectionStateChange(cid, {
474
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
475
- stateType: 'peerConnection',
476
- state: 'connected',
477
- });
505
+ reportPeerConnectionState(
506
+ PeerType.PUBLISHER_UNSPECIFIED,
507
+ 'connecting',
508
+ 'checking',
509
+ );
510
+ reportPeerConnectionState(
511
+ PeerType.PUBLISHER_UNSPECIFIED,
512
+ 'connected',
513
+ 'connected',
514
+ );
478
515
  await flush();
479
516
 
480
517
  const events = postedEvents().filter(
@@ -497,16 +534,8 @@ describe('ClientEventReporter', () => {
497
534
 
498
535
  it('reports an ICE failure on the peer connection', async () => {
499
536
  reporter.startCorrelation(cid, 'first-attempt');
500
- reporter.onPeerConnectionStateChange(cid, {
501
- peerType: PeerType.SUBSCRIBER,
502
- stateType: 'peerConnection',
503
- state: 'connecting',
504
- });
505
- reporter.onPeerConnectionStateChange(cid, {
506
- peerType: PeerType.SUBSCRIBER,
507
- stateType: 'ice',
508
- state: 'failed',
509
- });
537
+ reportPeerConnectionState(PeerType.SUBSCRIBER, 'connecting', 'checking');
538
+ reportIceState(PeerType.SUBSCRIBER, 'failed');
510
539
  await flush();
511
540
 
512
541
  const completed = postedEvents().filter(
@@ -522,25 +551,111 @@ describe('ClientEventReporter', () => {
522
551
  });
523
552
  });
524
553
 
525
- it('opens a fresh peer connection pair after a new correlation', async () => {
554
+ it('reports a DTLS failure with the real ICE state (connected)', async () => {
526
555
  reporter.startCorrelation(cid, 'first-attempt');
527
- reporter.onPeerConnectionStateChange(cid, {
528
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
529
- stateType: 'peerConnection',
530
- state: 'connecting',
556
+ reportPeerConnectionState(
557
+ PeerType.PUBLISHER_UNSPECIFIED,
558
+ 'connecting',
559
+ 'checking',
560
+ );
561
+ reportIceState(PeerType.PUBLISHER_UNSPECIFIED, 'connected');
562
+ // Aggregate connection fails while ICE stays connected -> genuine DTLS.
563
+ reportPeerConnectionState(
564
+ PeerType.PUBLISHER_UNSPECIFIED,
565
+ 'failed',
566
+ 'connected',
567
+ );
568
+ await flush();
569
+
570
+ const completed = postedEvents().filter(
571
+ (e) =>
572
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
573
+ );
574
+ expect(completed).toHaveLength(1);
575
+ expect(completed[0]).toMatchObject({
576
+ outcome: 'failure',
577
+ peer_connection: 'publish',
578
+ retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
579
+ ice_state: 'CONNECTED',
531
580
  });
581
+ });
532
582
 
533
- reporter.startCorrelation(cid, 'full-rejoin');
534
- reporter.onPeerConnectionStateChange(cid, {
535
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
536
- stateType: 'peerConnection',
537
- state: 'connecting',
583
+ it('reports a DTLS failure with the real ICE state (never connected)', async () => {
584
+ reporter.startCorrelation(cid, 'first-attempt');
585
+ reportPeerConnectionState(
586
+ PeerType.PUBLISHER_UNSPECIFIED,
587
+ 'connecting',
588
+ 'checking',
589
+ );
590
+ // ICE is still `checking` when the aggregate peer-connection state fails:
591
+ // the reported ice_state must be NOT_CONNECTED, not an assumed CONNECTED.
592
+ reportPeerConnectionState(
593
+ PeerType.PUBLISHER_UNSPECIFIED,
594
+ 'failed',
595
+ 'checking',
596
+ );
597
+ await flush();
598
+
599
+ const completed = postedEvents().filter(
600
+ (e) =>
601
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
602
+ );
603
+ expect(completed).toHaveLength(1);
604
+ expect(completed[0]).toMatchObject({
605
+ outcome: 'failure',
606
+ peer_connection: 'publish',
607
+ retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
608
+ ice_state: 'NOT_CONNECTED',
538
609
  });
539
- reporter.onPeerConnectionStateChange(cid, {
540
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
541
- stateType: 'peerConnection',
542
- state: 'connected',
610
+ });
611
+
612
+ it('reports the ICE state captured when the pair opened, with no ICE event', async () => {
613
+ reporter.startCorrelation(cid, 'first-attempt');
614
+ // The pair opens with ICE already connected (carried on the connecting
615
+ // event); no separate ICE event is ever delivered.
616
+ reportPeerConnectionState(
617
+ PeerType.PUBLISHER_UNSPECIFIED,
618
+ 'connecting',
619
+ 'connected',
620
+ );
621
+ reportPeerConnectionState(
622
+ PeerType.PUBLISHER_UNSPECIFIED,
623
+ 'failed',
624
+ 'connected',
625
+ );
626
+ await flush();
627
+
628
+ const completed = postedEvents().filter(
629
+ (e) =>
630
+ e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
631
+ );
632
+ expect(completed).toHaveLength(1);
633
+ expect(completed[0]).toMatchObject({
634
+ outcome: 'failure',
635
+ retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
636
+ ice_state: 'CONNECTED',
543
637
  });
638
+ });
639
+
640
+ it('opens a fresh peer connection pair after a new correlation', async () => {
641
+ reporter.startCorrelation(cid, 'first-attempt');
642
+ reportPeerConnectionState(
643
+ PeerType.PUBLISHER_UNSPECIFIED,
644
+ 'connecting',
645
+ 'checking',
646
+ );
647
+
648
+ reporter.startCorrelation(cid, 'full-rejoin');
649
+ reportPeerConnectionState(
650
+ PeerType.PUBLISHER_UNSPECIFIED,
651
+ 'connecting',
652
+ 'checking',
653
+ );
654
+ reportPeerConnectionState(
655
+ PeerType.PUBLISHER_UNSPECIFIED,
656
+ 'connected',
657
+ 'connected',
658
+ );
544
659
  await flush();
545
660
 
546
661
  const pc = postedEvents().filter(
@@ -566,16 +681,16 @@ describe('ClientEventReporter', () => {
566
681
  it('marks a peer connection as previously connected on reconnect', async () => {
567
682
  reporter.startCorrelation(cid, 'first-attempt');
568
683
  const connect = () => {
569
- reporter.onPeerConnectionStateChange(cid, {
570
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
571
- stateType: 'peerConnection',
572
- state: 'connecting',
573
- });
574
- reporter.onPeerConnectionStateChange(cid, {
575
- peerType: PeerType.PUBLISHER_UNSPECIFIED,
576
- stateType: 'peerConnection',
577
- state: 'connected',
578
- });
684
+ reportPeerConnectionState(
685
+ PeerType.PUBLISHER_UNSPECIFIED,
686
+ 'connecting',
687
+ 'checking',
688
+ );
689
+ reportPeerConnectionState(
690
+ PeerType.PUBLISHER_UNSPECIFIED,
691
+ 'connected',
692
+ 'connected',
693
+ );
579
694
  };
580
695
  connect();
581
696
  connect();
@@ -362,6 +362,7 @@ export abstract class BasePeerConnection {
362
362
  try {
363
363
  this.onPeerConnectionStateChange?.({
364
364
  peerType: this.peerType,
365
+ iceConnectionState: this.pc.iceConnectionState,
365
366
  ...event,
366
367
  });
367
368
  } catch (err) {
@@ -40,6 +40,7 @@ export class Publisher extends BasePeerConnection {
40
40
  private readonly clonedTracks = new Set<MediaStreamTrack>();
41
41
  private publishOptions: PublishOption[];
42
42
  private readonly selfSubEnabled: boolean;
43
+ private readonly transceiverLockKey = `pub.tx.${this.lock}`;
43
44
 
44
45
  /**
45
46
  * Constructs a new `Publisher` instance.
@@ -107,28 +108,31 @@ export class Publisher extends BasePeerConnection {
107
108
  trackType: TrackType,
108
109
  options: TrackPublishOptions = {},
109
110
  ) => {
110
- if (!this.publishOptions.some((o) => o.trackType === trackType)) {
111
- throw new Error(`No publish options found for ${TrackType[trackType]}`);
112
- }
113
-
114
- for (const publishOption of this.publishOptions) {
115
- if (publishOption.trackType !== trackType) continue;
116
-
117
- // create a clone of the track as otherwise the same trackId will
118
- // appear in the SDP in multiple transceivers
119
- const trackToPublish = this.cloneTrack(track);
111
+ return withoutConcurrency(this.transceiverLockKey, async () => {
112
+ const publishOptions = this.publishOptions.filter(
113
+ (o) => o.trackType === trackType,
114
+ );
115
+ if (!publishOptions.length) {
116
+ throw new Error(`No publish options found for ${TrackType[trackType]}`);
117
+ }
120
118
 
121
- const bundle = this.transceiverCache.get(publishOption);
122
- if (!bundle) {
123
- await this.addTransceiver(trackToPublish, publishOption, options);
124
- } else {
125
- const previousTrack = bundle.transceiver.sender.track;
126
- await this.updateTransceiver(bundle, trackToPublish, options);
127
- if (!isReactNative()) {
128
- this.stopTrack(previousTrack);
119
+ for (const publishOption of publishOptions) {
120
+ // create a clone of the track as otherwise the same trackId will
121
+ // appear in the SDP in multiple transceivers
122
+ const trackToPublish = this.cloneTrack(track);
123
+
124
+ const bundle = this.transceiverCache.get(publishOption);
125
+ if (!bundle) {
126
+ await this.addTransceiver(trackToPublish, publishOption, options);
127
+ } else {
128
+ const previousTrack = bundle.transceiver.sender.track;
129
+ await this.updateTransceiver(bundle, trackToPublish, options);
130
+ if (!isReactNative()) {
131
+ this.stopTrack(previousTrack);
132
+ }
129
133
  }
130
134
  }
131
- }
135
+ });
132
136
  };
133
137
 
134
138
  /**
@@ -139,6 +143,7 @@ export class Publisher extends BasePeerConnection {
139
143
  publishOption: PublishOption,
140
144
  options: TrackPublishOptions,
141
145
  ) => {
146
+ const trackType = publishOption.trackType;
142
147
  const encodings = isAudioTrackType(publishOption.trackType)
143
148
  ? computeAudioLayers(publishOption, options)
144
149
  : computeVideoLayers(track, publishOption);
@@ -149,6 +154,12 @@ export class Publisher extends BasePeerConnection {
149
154
  direction: 'sendonly',
150
155
  sendEncodings,
151
156
  });
157
+ // claim the cache entry in the same task that created the transceiver:
158
+ // an await in between would let a concurrent caller add a second one
159
+ // for the same publish option
160
+ this.transceiverCache.add({ publishOption, transceiver, options });
161
+ this.trackIdToTrackType.set(track.id, trackType);
162
+ this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
152
163
 
153
164
  const params = transceiver.sender.getParameters();
154
165
  params.degradationPreference =
@@ -156,11 +167,6 @@ export class Publisher extends BasePeerConnection {
156
167
  'maintain-framerate';
157
168
  await transceiver.sender.setParameters(params);
158
169
 
159
- const trackType = publishOption.trackType;
160
- this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
161
- this.transceiverCache.add({ publishOption, transceiver, options });
162
- this.trackIdToTrackType.set(track.id, trackType);
163
-
164
170
  await this.negotiate();
165
171
  };
166
172
 
@@ -225,38 +231,40 @@ export class Publisher extends BasePeerConnection {
225
231
  * Synchronizes the current Publisher state with the provided publish options.
226
232
  */
227
233
  private syncPublishOptions = async () => {
228
- // enable publishing with new options -> [av1, vp9]
229
- for (const publishOption of this.publishOptions) {
230
- const { trackType } = publishOption;
231
- if (!this.isPublishing(trackType)) continue;
232
- if (this.transceiverCache.has(publishOption)) continue;
233
-
234
- const item = this.transceiverCache.find(
235
- (i) =>
236
- !!i.transceiver.sender.track &&
237
- i.publishOption.trackType === trackType,
238
- );
239
- if (!item) continue;
240
-
241
- // take the track from the existing transceiver for the same track type,
242
- // clone it and publish it with the new publish options
243
- const track = this.cloneTrack(item.transceiver.sender.track!);
244
- await this.addTransceiver(track, publishOption, item.options);
245
- }
234
+ return withoutConcurrency(this.transceiverLockKey, async () => {
235
+ // enable publishing with new options -> [av1, vp9]
236
+ for (const publishOption of this.publishOptions) {
237
+ const { trackType } = publishOption;
238
+ if (!this.isPublishing(trackType)) continue;
239
+ if (this.transceiverCache.has(publishOption)) continue;
240
+
241
+ const item = this.transceiverCache.find(
242
+ (i) =>
243
+ !!i.transceiver.sender.track &&
244
+ i.publishOption.trackType === trackType,
245
+ );
246
+ if (!item) continue;
247
+
248
+ // take the track from the existing transceiver for the same track type,
249
+ // clone it and publish it with the new publish options
250
+ const track = this.cloneTrack(item.transceiver.sender.track!);
251
+ await this.addTransceiver(track, publishOption, item.options);
252
+ }
246
253
 
247
- // stop publishing with options not required anymore -> [vp9]
248
- for (const item of this.transceiverCache.items()) {
249
- const { publishOption, transceiver } = item;
250
- const hasPublishOption = this.publishOptions.some(
251
- (option) =>
252
- option.id === publishOption.id &&
253
- option.trackType === publishOption.trackType,
254
- );
255
- if (hasPublishOption) continue;
256
- // it is safe to stop the track here, it is a clone
257
- this.stopTrack(transceiver.sender.track);
258
- await this.updateTransceiver(item, null);
259
- }
254
+ // stop publishing with options not required anymore -> [vp9]
255
+ for (const item of this.transceiverCache.items()) {
256
+ const { publishOption, transceiver } = item;
257
+ const hasPublishOption = this.publishOptions.some(
258
+ (option) =>
259
+ option.id === publishOption.id &&
260
+ option.trackType === publishOption.trackType,
261
+ );
262
+ if (hasPublishOption) continue;
263
+ // it is safe to stop the track here, it is a clone
264
+ this.stopTrack(transceiver.sender.track);
265
+ await this.updateTransceiver(item, null);
266
+ }
267
+ });
260
268
  };
261
269
 
262
270
  /**
@@ -456,13 +464,25 @@ export class Publisher extends BasePeerConnection {
456
464
  private negotiate = async (options?: RTCOfferOptions): Promise<void> => {
457
465
  return withoutConcurrency(`publisher.negotiate.${this.lock}`, async () => {
458
466
  const offer = await this.pc.createOffer(options);
459
- const tracks = this.getAnnouncedTracks(offer.sdp);
460
- if (!tracks.length) throw new Error(`Can't negotiate without any tracks`);
461
-
462
467
  try {
463
468
  this.isIceRestarting = options?.iceRestart ?? false;
464
469
  await this.pc.setLocalDescription(offer);
465
470
 
471
+ // the announced tracks have to be read after `setLocalDescription`.
472
+ // `transceiver.mid` is null until the offer is applied, and reading the
473
+ // tracks earlier makes `extractMid` guess the mid from the offer SDP.
474
+ // That guess goes stale after a rolled-back negotiation, and a mid that
475
+ // doesn't match the m-section carrying the track stops the SFU from
476
+ // correlating the two: it then answers with every offered codec instead
477
+ // of the announced one, and we publish the first codec of that list.
478
+ // Per https://www.w3.org/TR/webrtc/#set-description, applying a local
479
+ // offer associates the transceiver with its m-section and sets
480
+ // `[[Mid]]` to `[[JsepMid]]`; a rollback disassociates a transceiver
481
+ // that wasn't associated before and resets both slots to null.
482
+ const tracks = this.getAnnouncedTracks(offer.sdp);
483
+ if (!tracks.length)
484
+ throw new Error(`Can't negotiate without any tracks`);
485
+
466
486
  const { sdp: baseSdp = '' } = offer;
467
487
  const {
468
488
  dangerouslyForceCodec,