@stream-io/video-client 1.54.1-beta.0 → 1.55.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 +21 -0
- package/dist/index.browser.es.js +9700 -8873
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +9707 -8880
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +9708 -8881
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +4 -4
- package/dist/src/StreamSfuClient.d.ts +11 -3
- package/dist/src/coordinator/connection/connection.d.ts +2 -1
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
- package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
- package/dist/src/rtc/Publisher.d.ts +1 -1
- package/dist/src/rtc/Subscriber.d.ts +2 -1
- package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
- package/dist/src/rtc/types.d.ts +3 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
- package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
- package/dist/src/stats/rtc/Tracer.d.ts +9 -2
- package/dist/src/stats/rtc/types.d.ts +10 -4
- package/package.json +5 -3
- package/src/Call.ts +47 -44
- package/src/StreamSfuClient.ts +36 -21
- package/src/__tests__/StreamSfuClient.test.ts +159 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
- package/src/coordinator/connection/__tests__/connection.test.ts +69 -0
- package/src/coordinator/connection/connection.ts +28 -13
- package/src/gen/video/sfu/event/events.ts +0 -1
- package/src/gen/video/sfu/models/models.ts +0 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +0 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
- package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
- package/src/helpers/__tests__/browsers.test.ts +12 -12
- package/src/helpers/browsers.ts +5 -5
- package/src/helpers/client-details.ts +1 -1
- package/src/reporting/ClientEventReporter.ts +17 -12
- package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
- package/src/rtc/BasePeerConnection.ts +15 -34
- package/src/rtc/IceTrickleBuffer.ts +105 -12
- package/src/rtc/Publisher.ts +26 -19
- package/src/rtc/Subscriber.ts +71 -37
- package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
- package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
- package/src/rtc/__tests__/Publisher.test.ts +76 -31
- package/src/rtc/__tests__/Subscriber.test.ts +271 -20
- package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
- package/src/rtc/helpers/degradationPreference.ts +1 -0
- package/src/rtc/helpers/iceCandiates.ts +35 -0
- package/src/rtc/helpers/sdp.ts +3 -2
- package/src/rtc/helpers/tracks.ts +2 -0
- package/src/rtc/types.ts +3 -0
- package/src/stats/SfuStatsReporter.ts +149 -49
- package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
- package/src/stats/rtc/StatsTracer.ts +90 -32
- package/src/stats/rtc/Tracer.ts +23 -2
- package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
- package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
- package/src/stats/rtc/types.ts +11 -4
|
@@ -1,33 +1,126 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Observable, Subject } from 'rxjs';
|
|
2
2
|
import { ICETrickle } from '../gen/video/sfu/event/events';
|
|
3
3
|
import { PeerType } from '../gen/video/sfu/models/models';
|
|
4
4
|
import { videoLoggerSystem } from '../logger';
|
|
5
|
+
import { getCandidateUfrag, parseIceUfrag } from './helpers/iceCandiates';
|
|
6
|
+
import { ensureExhausted } from '../helpers/ensureExhausted';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
8
10
|
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
11
|
+
*
|
|
12
|
+
* The buffer is generation-aware: each peer connection tells it which ICE
|
|
13
|
+
* generation is current via `updateActiveGeneration` (whenever it applies an
|
|
14
|
+
* offer/answer). Candidate streams then emit only candidates of the active
|
|
15
|
+
* generation, hold candidates of a not-yet-applied (future) generation until
|
|
16
|
+
* it becomes active, and drop candidates of a superseded generation so they
|
|
17
|
+
* are never replayed. Candidates with no detectable generation, or before any
|
|
18
|
+
* generation is set, are emitted as-is (fail open).
|
|
9
19
|
*/
|
|
10
20
|
export class IceTrickleBuffer {
|
|
11
|
-
readonly
|
|
12
|
-
readonly
|
|
21
|
+
readonly subscriber = new CandidateGenerationBuffer();
|
|
22
|
+
readonly publisher = new CandidateGenerationBuffer();
|
|
13
23
|
|
|
14
24
|
push = (iceTrickle: ICETrickle) => {
|
|
15
25
|
const iceCandidate = toIceCandidate(iceTrickle);
|
|
16
26
|
if (!iceCandidate) return;
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
const { peerType } = iceTrickle;
|
|
29
|
+
switch (peerType) {
|
|
30
|
+
case PeerType.SUBSCRIBER:
|
|
31
|
+
this.subscriber.push(iceCandidate);
|
|
32
|
+
break;
|
|
33
|
+
case PeerType.PUBLISHER_UNSPECIFIED:
|
|
34
|
+
this.publisher.push(iceCandidate);
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
ensureExhausted(peerType, `ICETrickle, Unknown peer type`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Declares the ICE generation that is now current for the given peer type,
|
|
43
|
+
* derived from the `ice-ufrag` of the just-applied remote description.
|
|
44
|
+
* Candidates of superseded generations are evicted; candidates of the active
|
|
45
|
+
* generation flow to subscribers.
|
|
46
|
+
*/
|
|
47
|
+
updateActiveGeneration = (peerType: PeerType, sdp: string | undefined) => {
|
|
48
|
+
const ufrag = parseIceUfrag(sdp);
|
|
49
|
+
switch (peerType) {
|
|
50
|
+
case PeerType.SUBSCRIBER:
|
|
51
|
+
this.subscriber.updateActiveGeneration(ufrag);
|
|
52
|
+
break;
|
|
53
|
+
case PeerType.PUBLISHER_UNSPECIFIED:
|
|
54
|
+
this.publisher.updateActiveGeneration(ufrag);
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
ensureExhausted(peerType, `updateActiveGeneration, Unknown peer type`);
|
|
25
58
|
}
|
|
26
59
|
};
|
|
27
60
|
|
|
28
61
|
dispose = () => {
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
62
|
+
this.subscriber.dispose();
|
|
63
|
+
this.publisher.dispose();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-peer-connection generation-aware candidate store. Retains trickled
|
|
69
|
+
* candidates and replays the active generation to each new subscriber, then
|
|
70
|
+
* forwards matching live candidates.
|
|
71
|
+
*/
|
|
72
|
+
class CandidateGenerationBuffer {
|
|
73
|
+
private readonly store: RTCIceCandidateInit[] = [];
|
|
74
|
+
private readonly live = new Subject<RTCIceCandidateInit>();
|
|
75
|
+
private readonly seenUfrags = new Set<string>();
|
|
76
|
+
private activeUfrag: string | undefined;
|
|
77
|
+
|
|
78
|
+
readonly candidates = new Observable<RTCIceCandidateInit>((subscriber) => {
|
|
79
|
+
for (const candidate of this.store.slice()) {
|
|
80
|
+
if (this.isCurrent(candidate)) subscriber.next(candidate);
|
|
81
|
+
}
|
|
82
|
+
const subscription = this.live.subscribe((candidate) => {
|
|
83
|
+
if (this.isCurrent(candidate)) subscriber.next(candidate);
|
|
84
|
+
});
|
|
85
|
+
return () => subscription.unsubscribe();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
push = (candidate: RTCIceCandidateInit) => {
|
|
89
|
+
this.store.push(candidate);
|
|
90
|
+
this.live.next(candidate);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
updateActiveGeneration = (ufrag: string | undefined) => {
|
|
94
|
+
if (ufrag) this.seenUfrags.add(ufrag);
|
|
95
|
+
this.activeUfrag = ufrag;
|
|
96
|
+
// evict candidates from superseded generations (a generation we have
|
|
97
|
+
// applied before but is no longer current); keep future generations.
|
|
98
|
+
for (let i = this.store.length - 1; i >= 0; i--) {
|
|
99
|
+
const candidateUfrag = getCandidateUfrag(this.store[i]);
|
|
100
|
+
if (
|
|
101
|
+
candidateUfrag &&
|
|
102
|
+
candidateUfrag !== this.activeUfrag &&
|
|
103
|
+
this.seenUfrags.has(candidateUfrag)
|
|
104
|
+
) {
|
|
105
|
+
this.store.splice(i, 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
dispose = () => {
|
|
111
|
+
this.store.length = 0;
|
|
112
|
+
this.live.complete();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A candidate belongs to the current generation when its ufrag matches the
|
|
117
|
+
* active one. Fail open when either the candidate's generation or the active
|
|
118
|
+
* generation is unknown, so untagged candidates are never withheld.
|
|
119
|
+
*/
|
|
120
|
+
private isCurrent = (candidate: RTCIceCandidateInit): boolean => {
|
|
121
|
+
const candidateUfrag = getCandidateUfrag(candidate);
|
|
122
|
+
if (!candidateUfrag || !this.activeUfrag) return true;
|
|
123
|
+
return candidateUfrag === this.activeUfrag;
|
|
31
124
|
};
|
|
32
125
|
}
|
|
33
126
|
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
BasePeerConnectionOpts,
|
|
4
|
-
PublishBundle,
|
|
5
|
-
TrackPublishOptions,
|
|
6
|
-
} from './types';
|
|
7
|
-
import { NegotiationError } from './NegotiationError';
|
|
8
|
-
import { TransceiverCache } from './TransceiverCache';
|
|
1
|
+
import { VideoSender } from '../gen/video/sfu/event/events';
|
|
9
2
|
import {
|
|
10
3
|
PeerType,
|
|
11
4
|
PublishOption,
|
|
12
5
|
TrackInfo,
|
|
13
6
|
TrackType,
|
|
14
7
|
} from '../gen/video/sfu/models/models';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
toSvcEncodings,
|
|
20
|
-
toVideoLayers,
|
|
21
|
-
} from './layers';
|
|
8
|
+
import { isFirefox } from '../helpers/browsers';
|
|
9
|
+
import { withoutConcurrency } from '../helpers/concurrency';
|
|
10
|
+
import { isReactNative } from '../helpers/platforms';
|
|
11
|
+
import { BasePeerConnection } from './BasePeerConnection';
|
|
22
12
|
import { isSvcCodec } from './codecs';
|
|
23
13
|
import {
|
|
24
14
|
fromRTCDegradationPreference,
|
|
25
15
|
toRTCDegradationPreference,
|
|
26
16
|
} from './helpers/degradationPreference';
|
|
27
|
-
import { isAudioTrackType } from './helpers/tracks';
|
|
28
17
|
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
18
|
+
import { isAudioTrackType } from './helpers/tracks';
|
|
19
|
+
import {
|
|
20
|
+
computeAudioLayers,
|
|
21
|
+
computeVideoLayers,
|
|
22
|
+
toSvcEncodings,
|
|
23
|
+
toVideoLayers,
|
|
24
|
+
} from './layers';
|
|
25
|
+
import { NegotiationError } from './NegotiationError';
|
|
26
|
+
import { TransceiverCache } from './TransceiverCache';
|
|
27
|
+
import type {
|
|
28
|
+
BasePeerConnectionOpts,
|
|
29
|
+
PublishBundle,
|
|
30
|
+
TrackPublishOptions,
|
|
31
|
+
} from './types';
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|
|
@@ -187,6 +187,9 @@ export class Publisher extends BasePeerConnection {
|
|
|
187
187
|
if (isAudioTrackType(trackType)) {
|
|
188
188
|
await this.updateAudioPublishOptions(trackType, options);
|
|
189
189
|
}
|
|
190
|
+
if (track && !bundle.negotiated) {
|
|
191
|
+
await this.negotiate();
|
|
192
|
+
}
|
|
190
193
|
};
|
|
191
194
|
|
|
192
195
|
/**
|
|
@@ -490,6 +493,10 @@ export class Publisher extends BasePeerConnection {
|
|
|
490
493
|
|
|
491
494
|
const { sdp: answerSdp } = response;
|
|
492
495
|
await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
|
496
|
+
|
|
497
|
+
for (const bundle of this.transceiverCache.items()) {
|
|
498
|
+
if (bundle.transceiver.sender.track) bundle.negotiated = true;
|
|
499
|
+
}
|
|
493
500
|
} catch (err) {
|
|
494
501
|
// negotiation failed, rollback to the previous state
|
|
495
502
|
if (this.pc.signalingState === 'have-local-offer') {
|
package/src/rtc/Subscriber.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { BasePeerConnection } from './BasePeerConnection';
|
|
2
|
-
import { BasePeerConnectionOpts } from './types';
|
|
2
|
+
import { BasePeerConnectionOpts, ReconnectReason } from './types';
|
|
3
3
|
import { NegotiationError } from './NegotiationError';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
PeerType,
|
|
6
|
+
TrackType,
|
|
7
|
+
WebsocketReconnectStrategy,
|
|
8
|
+
} from '../gen/video/sfu/models/models';
|
|
5
9
|
import { SubscriberOffer } from '../gen/video/sfu/event/events';
|
|
6
10
|
import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
|
|
7
11
|
import { pushToIfMissing, removeFromIfPresent } from '../helpers/array';
|
|
@@ -20,7 +24,8 @@ export class Subscriber extends BasePeerConnection {
|
|
|
20
24
|
* The map will never contain local streams so we can safely use it to
|
|
21
25
|
* check if the stream is remote and dispose it when needed.
|
|
22
26
|
*/
|
|
23
|
-
private trackedStreams
|
|
27
|
+
private trackedStreams?: WeakSet<MediaStream>;
|
|
28
|
+
private negotiationFailures = 0;
|
|
24
29
|
|
|
25
30
|
/**
|
|
26
31
|
* Constructs a new `Subscriber` instance.
|
|
@@ -30,9 +35,25 @@ export class Subscriber extends BasePeerConnection {
|
|
|
30
35
|
this.pc.addEventListener('track', this.handleOnTrack);
|
|
31
36
|
|
|
32
37
|
this.on('subscriberOffer', async (subscriberOffer) => {
|
|
33
|
-
|
|
34
|
-
this.
|
|
35
|
-
|
|
38
|
+
try {
|
|
39
|
+
const result = await this.negotiate(subscriberOffer);
|
|
40
|
+
this.negotiationFailures = 0;
|
|
41
|
+
return result;
|
|
42
|
+
} catch (err: any) {
|
|
43
|
+
const message = 'subscriber.negotiationFailed';
|
|
44
|
+
this.tracer?.trace(message, err.message);
|
|
45
|
+
this.logger.warn(message, err);
|
|
46
|
+
|
|
47
|
+
const failures = ++this.negotiationFailures;
|
|
48
|
+
if (failures < 3) return this.tryRestartIce();
|
|
49
|
+
|
|
50
|
+
this.logger.error(`negotiation failed ${failures} times, rejoining`);
|
|
51
|
+
this.onReconnectionNeeded?.(
|
|
52
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
53
|
+
ReconnectReason.SUBSCRIBER_NEGOTIATION_FAILED,
|
|
54
|
+
this.peerType,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
36
57
|
});
|
|
37
58
|
}
|
|
38
59
|
|
|
@@ -52,13 +73,11 @@ export class Subscriber extends BasePeerConnection {
|
|
|
52
73
|
restartIce = async () => {
|
|
53
74
|
this.logger.debug('Restarting ICE connection');
|
|
54
75
|
if (this.pc.signalingState === 'have-remote-offer') {
|
|
55
|
-
this.logger.debug('ICE
|
|
76
|
+
this.logger.debug('ICE negotiation is already in progress');
|
|
56
77
|
return;
|
|
57
78
|
}
|
|
58
79
|
if (this.pc.connectionState === 'new') {
|
|
59
|
-
this.logger.debug(
|
|
60
|
-
`ICE connection is not yet established, skipping restart.`,
|
|
61
|
-
);
|
|
80
|
+
this.logger.debug(`ICE connection not yet established, skipping restart`);
|
|
62
81
|
return;
|
|
63
82
|
}
|
|
64
83
|
const previousIsIceRestarting = this.isIceRestarting;
|
|
@@ -118,6 +137,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
118
137
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
119
138
|
|
|
120
139
|
if (isSelfSub) {
|
|
140
|
+
this.trackedStreams ??= new WeakSet<MediaStream>();
|
|
121
141
|
this.trackedStreams.add(primaryStream);
|
|
122
142
|
}
|
|
123
143
|
|
|
@@ -159,7 +179,7 @@ export class Subscriber extends BasePeerConnection {
|
|
|
159
179
|
});
|
|
160
180
|
|
|
161
181
|
if (previousStream) {
|
|
162
|
-
if (isSelfSub && !this.trackedStreams
|
|
182
|
+
if (isSelfSub && !this.trackedStreams?.has(previousStream)) {
|
|
163
183
|
// this is the local capture stream, we don't want to dispose it
|
|
164
184
|
this.logger.debug(
|
|
165
185
|
`[onTrack]: Skipping cleanup of previous ${e.track.kind} stream for userId: ${participantToUpdate.userId} because it is not tracked`,
|
|
@@ -199,34 +219,48 @@ export class Subscriber extends BasePeerConnection {
|
|
|
199
219
|
};
|
|
200
220
|
|
|
201
221
|
private negotiate = async (subscriberOffer: SubscriberOffer) => {
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
// The generation currently committed on the peer connection. If this
|
|
223
|
+
// negotiation fails and rolls back, the buffer is restored to it.
|
|
224
|
+
const previousSdp = this.pc.currentRemoteDescription?.sdp;
|
|
225
|
+
try {
|
|
226
|
+
await this.pc.setRemoteDescription({
|
|
227
|
+
type: 'offer',
|
|
228
|
+
sdp: subscriberOffer.sdp,
|
|
229
|
+
});
|
|
206
230
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const answer = await this.pc.createAnswer();
|
|
210
|
-
if (answer.sdp) {
|
|
211
|
-
answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
|
|
212
|
-
const { dangerouslyForceCodec, subscriberFmtpLine } =
|
|
213
|
-
this.clientPublishOptions || {};
|
|
214
|
-
if (dangerouslyForceCodec) {
|
|
215
|
-
answer.sdp = removeCodecsExcept(
|
|
216
|
-
answer.sdp,
|
|
217
|
-
dangerouslyForceCodec,
|
|
218
|
-
subscriberFmtpLine,
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
await this.pc.setLocalDescription(answer);
|
|
231
|
+
this.addTrickledIceCandidates();
|
|
223
232
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
233
|
+
const answer = await this.pc.createAnswer();
|
|
234
|
+
if (answer.sdp) {
|
|
235
|
+
answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
|
|
236
|
+
const { dangerouslyForceCodec, subscriberFmtpLine } =
|
|
237
|
+
this.clientPublishOptions || {};
|
|
238
|
+
if (dangerouslyForceCodec) {
|
|
239
|
+
answer.sdp = removeCodecsExcept(
|
|
240
|
+
answer.sdp,
|
|
241
|
+
dangerouslyForceCodec,
|
|
242
|
+
subscriberFmtpLine,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
await this.pc.setLocalDescription(answer);
|
|
229
247
|
|
|
230
|
-
|
|
248
|
+
await this.sfuClient.sendAnswer({
|
|
249
|
+
peerType: PeerType.SUBSCRIBER,
|
|
250
|
+
sdp: answer.sdp || '',
|
|
251
|
+
negotiationId: subscriberOffer.negotiationId,
|
|
252
|
+
});
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (this.pc.signalingState === 'have-remote-offer') {
|
|
255
|
+
await this.pc.setRemoteDescription({ type: 'rollback' }).catch((e) => {
|
|
256
|
+
this.logger.warn('Failed to rollback after negotiation error', e);
|
|
257
|
+
});
|
|
258
|
+
const { iceTrickleBuffer } = this.sfuClient;
|
|
259
|
+
iceTrickleBuffer.updateActiveGeneration(this.peerType, previousSdp);
|
|
260
|
+
}
|
|
261
|
+
throw err;
|
|
262
|
+
} finally {
|
|
263
|
+
this.isIceRestarting = false;
|
|
264
|
+
}
|
|
231
265
|
};
|
|
232
266
|
}
|
|
@@ -818,43 +818,6 @@ describe('Call reconnect wiring (PC event → leave)', () => {
|
|
|
818
818
|
expect(publisher.restartIce).toHaveBeenCalled();
|
|
819
819
|
});
|
|
820
820
|
|
|
821
|
-
/**
|
|
822
|
-
* Scenario 4 (manual smoke equivalent: drop only the signal WS while the
|
|
823
|
-
* publisher PC stays `connected`): the FAST path should NOT call
|
|
824
|
-
* `publisher.restartIce()` because the PC is stable.
|
|
825
|
-
*/
|
|
826
|
-
it('FAST path skips publisher.restartIce when publisher PC is stable', async () => {
|
|
827
|
-
const publisher = makePublisherWiredToCall();
|
|
828
|
-
// @ts-expect-error private field
|
|
829
|
-
publisher['pc'].iceConnectionState = 'connected';
|
|
830
|
-
publisher['onIceConnectionStateChange']();
|
|
831
|
-
// @ts-expect-error private field
|
|
832
|
-
publisher['pc'].connectionState = 'connected';
|
|
833
|
-
|
|
834
|
-
// pretend the publisher has tracks so isPublishing() would return true
|
|
835
|
-
vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
|
|
836
|
-
const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
837
|
-
const setSfuSpy = vi.spyOn(publisher, 'setSfuClient');
|
|
838
|
-
call['publisher'] = publisher;
|
|
839
|
-
|
|
840
|
-
// mimic the FAST branch in doJoin: restoreICE is the gateway
|
|
841
|
-
const publisherIsStable = call['publisher']?.isStable() ?? true;
|
|
842
|
-
const includePublisher =
|
|
843
|
-
!!call['publisher']?.isPublishing() && !publisherIsStable;
|
|
844
|
-
await call['restoreICE'](sfuClient, {
|
|
845
|
-
includeSubscriber: false,
|
|
846
|
-
includePublisher,
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
expect(includePublisher).toBe(false);
|
|
850
|
-
expect(setSfuSpy).toHaveBeenCalledWith(sfuClient); // wire still updated
|
|
851
|
-
expect(restartIceSpy).not.toHaveBeenCalled(); // but NO ICE restart
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Counterpart to the above: when the publisher PC is NOT stable (e.g.,
|
|
856
|
-
* `disconnected`), the FAST path SHOULD still issue an ICE restart.
|
|
857
|
-
*/
|
|
858
821
|
it('FAST path DOES call publisher.restartIce when publisher PC is unstable', async () => {
|
|
859
822
|
const publisher = makePublisherWiredToCall();
|
|
860
823
|
// @ts-expect-error private field
|
|
@@ -869,15 +832,8 @@ describe('Call reconnect wiring (PC event → leave)', () => {
|
|
|
869
832
|
const restartIceSpy = vi.spyOn(publisher, 'restartIce').mockResolvedValue();
|
|
870
833
|
call['publisher'] = publisher;
|
|
871
834
|
|
|
872
|
-
|
|
873
|
-
const includePublisher =
|
|
874
|
-
!!call['publisher']?.isPublishing() && !publisherIsStable;
|
|
875
|
-
await call['restoreICE'](sfuClient, {
|
|
876
|
-
includeSubscriber: false,
|
|
877
|
-
includePublisher,
|
|
878
|
-
});
|
|
835
|
+
await call['restoreICE'](sfuClient, { includeSubscriber: false });
|
|
879
836
|
|
|
880
|
-
expect(includePublisher).toBe(true);
|
|
881
837
|
expect(restartIceSpy).toHaveBeenCalled();
|
|
882
838
|
});
|
|
883
839
|
|
|
@@ -998,6 +954,50 @@ describe('Call reconnect wiring (PC event → leave)', () => {
|
|
|
998
954
|
});
|
|
999
955
|
});
|
|
1000
956
|
|
|
957
|
+
/**
|
|
958
|
+
* `handleSfuSignalClose` is the bridge from a dead signal WS to the reconnect
|
|
959
|
+
* loop. A reconnect swaps in a fresh SFU client, but the old socket can still
|
|
960
|
+
* fire a (delayed) `close` later. Such stragglers must be ignored: only the
|
|
961
|
+
* currently-active client may drive a reconnect.
|
|
962
|
+
*/
|
|
963
|
+
describe('Call.handleSfuSignalClose superseded-client guard', () => {
|
|
964
|
+
let call: Call;
|
|
965
|
+
|
|
966
|
+
beforeEach(() => {
|
|
967
|
+
call = makeCall();
|
|
968
|
+
vi.spyOn(call, 'leave').mockResolvedValue(undefined);
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
afterEach(() => {
|
|
972
|
+
vi.clearAllMocks();
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
it('ignores a signal close from a superseded SFU client', () => {
|
|
976
|
+
call.state.setCallingState(CallingState.JOINED);
|
|
977
|
+
const reconnectSpy = vi
|
|
978
|
+
.spyOn(call as unknown as { reconnect: () => Promise<void> }, 'reconnect')
|
|
979
|
+
.mockResolvedValue(undefined);
|
|
980
|
+
|
|
981
|
+
const currentClient = { isLeaving: false, isClosingClean: false };
|
|
982
|
+
const supersededClient = { isLeaving: false, isClosingClean: false };
|
|
983
|
+
(call as unknown as { sfuClient: unknown }).sfuClient = currentClient;
|
|
984
|
+
|
|
985
|
+
// a close from a client that is no longer active must not reconnect
|
|
986
|
+
call['handleSfuSignalClose'](
|
|
987
|
+
supersededClient as unknown as StreamSfuClient,
|
|
988
|
+
'1006 ',
|
|
989
|
+
);
|
|
990
|
+
expect(reconnectSpy).not.toHaveBeenCalled();
|
|
991
|
+
|
|
992
|
+
// the active client's close still drives a reconnect
|
|
993
|
+
call['handleSfuSignalClose'](
|
|
994
|
+
currentClient as unknown as StreamSfuClient,
|
|
995
|
+
'1006 ',
|
|
996
|
+
);
|
|
997
|
+
expect(reconnectSpy).toHaveBeenCalledTimes(1);
|
|
998
|
+
});
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
1001
|
/**
|
|
1002
1002
|
* `leave()` runs after both the success path (end of `joinFlow`) and the
|
|
1003
1003
|
* giveUpAndLeave path. Only the success path resets `reconnectStrategy` /
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { IceTrickleBuffer } from '../IceTrickleBuffer';
|
|
3
|
+
import { PeerType } from '../../gen/video/sfu/models/models';
|
|
4
|
+
|
|
5
|
+
// The generation is carried via `usernameFragment` (the key getCandidateUfrag
|
|
6
|
+
// reads); the candidate-string `ufrag` token path has its own helper tests.
|
|
7
|
+
const trickle = (
|
|
8
|
+
ufrag: string | undefined,
|
|
9
|
+
candidate: string,
|
|
10
|
+
peerType = PeerType.SUBSCRIBER,
|
|
11
|
+
) => ({
|
|
12
|
+
peerType,
|
|
13
|
+
iceCandidate: JSON.stringify(
|
|
14
|
+
ufrag ? { usernameFragment: ufrag, candidate } : { candidate },
|
|
15
|
+
),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const sdp = (ufrag: string) =>
|
|
19
|
+
`v=0\r\na=ice-ufrag:${ufrag}\r\na=ice-pwd:pwd\r\n`;
|
|
20
|
+
|
|
21
|
+
const collect = (
|
|
22
|
+
observable: IceTrickleBuffer['subscriber']['candidates'],
|
|
23
|
+
): RTCIceCandidateInit[] => {
|
|
24
|
+
const seen: RTCIceCandidateInit[] = [];
|
|
25
|
+
observable.subscribe((c) => seen.push(c)).unsubscribe();
|
|
26
|
+
return seen;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('IceTrickleBuffer', () => {
|
|
30
|
+
it('emits buffered candidates of the active generation to a new subscriber', () => {
|
|
31
|
+
const buffer = new IceTrickleBuffer();
|
|
32
|
+
buffer.push(trickle('u1', 'a'));
|
|
33
|
+
buffer.push(trickle('u1', 'b'));
|
|
34
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
35
|
+
|
|
36
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([
|
|
37
|
+
{ usernameFragment: 'u1', candidate: 'a' },
|
|
38
|
+
{ usernameFragment: 'u1', candidate: 'b' },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('emits live candidates of the active generation', () => {
|
|
43
|
+
const buffer = new IceTrickleBuffer();
|
|
44
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
45
|
+
const seen: RTCIceCandidateInit[] = [];
|
|
46
|
+
buffer.subscriber.candidates.subscribe((c) => seen.push(c));
|
|
47
|
+
|
|
48
|
+
buffer.push(trickle('u1', 'a'));
|
|
49
|
+
|
|
50
|
+
expect(seen).toEqual([{ usernameFragment: 'u1', candidate: 'a' }]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('drops superseded-generation candidates once the generation advances', () => {
|
|
54
|
+
const buffer = new IceTrickleBuffer();
|
|
55
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u0'));
|
|
56
|
+
buffer.push(trickle('u0', 'old'));
|
|
57
|
+
|
|
58
|
+
// ICE restart -> new generation
|
|
59
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
60
|
+
buffer.push(trickle('u1', 'new'));
|
|
61
|
+
|
|
62
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([
|
|
63
|
+
{ usernameFragment: 'u1', candidate: 'new' },
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('holds future-generation candidates until their generation becomes active', () => {
|
|
68
|
+
const buffer = new IceTrickleBuffer();
|
|
69
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
70
|
+
// a candidate for a not-yet-applied generation arrives early (trickle race)
|
|
71
|
+
buffer.push(trickle('u2', 'future'));
|
|
72
|
+
|
|
73
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([]);
|
|
74
|
+
|
|
75
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u2'));
|
|
76
|
+
|
|
77
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([
|
|
78
|
+
{ usernameFragment: 'u2', candidate: 'future' },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('emits candidates without a generation marker (fail-open)', () => {
|
|
83
|
+
const buffer = new IceTrickleBuffer();
|
|
84
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
85
|
+
buffer.push(trickle(undefined, 'no-generation'));
|
|
86
|
+
|
|
87
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([
|
|
88
|
+
{ candidate: 'no-generation' },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('emits all candidates when no active generation is set (fail-open)', () => {
|
|
93
|
+
const buffer = new IceTrickleBuffer();
|
|
94
|
+
buffer.push(trickle('u1', 'a'));
|
|
95
|
+
buffer.push(trickle('u2', 'b'));
|
|
96
|
+
|
|
97
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([
|
|
98
|
+
{ usernameFragment: 'u1', candidate: 'a' },
|
|
99
|
+
{ usernameFragment: 'u2', candidate: 'b' },
|
|
100
|
+
]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('keeps subscriber and publisher generations independent', () => {
|
|
104
|
+
const buffer = new IceTrickleBuffer();
|
|
105
|
+
buffer.push(trickle('u1', 'sub', PeerType.SUBSCRIBER));
|
|
106
|
+
buffer.push(trickle('p1', 'pub', PeerType.PUBLISHER_UNSPECIFIED));
|
|
107
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
108
|
+
buffer.updateActiveGeneration(PeerType.PUBLISHER_UNSPECIFIED, sdp('p1'));
|
|
109
|
+
|
|
110
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([
|
|
111
|
+
{ usernameFragment: 'u1', candidate: 'sub' },
|
|
112
|
+
]);
|
|
113
|
+
expect(collect(buffer.publisher.candidates)).toEqual([
|
|
114
|
+
{ usernameFragment: 'p1', candidate: 'pub' },
|
|
115
|
+
]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('dispose clears retained candidates', () => {
|
|
119
|
+
const buffer = new IceTrickleBuffer();
|
|
120
|
+
buffer.updateActiveGeneration(PeerType.SUBSCRIBER, sdp('u1'));
|
|
121
|
+
buffer.push(trickle('u1', 'a'));
|
|
122
|
+
|
|
123
|
+
buffer.dispose();
|
|
124
|
+
|
|
125
|
+
expect(collect(buffer.subscriber.candidates)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|