@stream-io/video-client 1.59.1 → 1.61.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 +18 -0
- package/dist/index.browser.es.js +744 -156
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +744 -155
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +744 -156
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +34 -8
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +272 -108
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +498 -5
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +209 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- package/src/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
- package/src/types.ts +37 -1
package/src/Call.ts
CHANGED
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
EndCallResponse,
|
|
51
51
|
GetCallReportResponse,
|
|
52
52
|
GetCallResponse,
|
|
53
|
+
GetCallRingStateResponse,
|
|
53
54
|
GetCallSessionParticipantStatsDetailsResponse,
|
|
54
55
|
GetOrCreateCallRequest,
|
|
55
56
|
GetOrCreateCallResponse,
|
|
@@ -152,7 +153,7 @@ import {
|
|
|
152
153
|
StatsReporter,
|
|
153
154
|
Tracer,
|
|
154
155
|
} from './stats';
|
|
155
|
-
import type { ClientEventReporter, JoinReason } from './reporting';
|
|
156
|
+
import type { ClientEventReporter, JoinReason, JoinSource } from './reporting';
|
|
156
157
|
import { AudioBindingsWatchdog } from './helpers/AudioBindingsWatchdog';
|
|
157
158
|
import { BlockedAudioTracker } from './helpers/BlockedAudioTracker';
|
|
158
159
|
import { TrackSubscriptionManager } from './helpers/TrackSubscriptionManager';
|
|
@@ -160,6 +161,7 @@ import { DynascaleManager } from './helpers/DynascaleManager';
|
|
|
160
161
|
import { createFirstVideoFrameDetector } from './helpers/firstVideoFrame';
|
|
161
162
|
import { ViewportTracker } from './helpers/ViewportTracker';
|
|
162
163
|
import { PermissionsContext } from './permissions';
|
|
164
|
+
import { RingStatePoller, RingTimeout, resolveOwnRingOutcome } from './ringing';
|
|
163
165
|
import { CallTypes } from './CallType';
|
|
164
166
|
import { StreamClient } from './coordinator/connection/client';
|
|
165
167
|
import { retryInterval, sleep } from './coordinator/connection/utils';
|
|
@@ -302,7 +304,8 @@ export class Call {
|
|
|
302
304
|
private statsReporter?: StatsReporter;
|
|
303
305
|
private sfuStatsReporter?: SfuStatsReporter;
|
|
304
306
|
private lastStatsOptions?: StatsOptions;
|
|
305
|
-
private
|
|
307
|
+
private ringTimeout: RingTimeout | undefined;
|
|
308
|
+
private ringStatePoller: RingStatePoller | undefined;
|
|
306
309
|
|
|
307
310
|
private readonly clientStore: StreamVideoWriteableStateStore;
|
|
308
311
|
public readonly streamClient: StreamClient;
|
|
@@ -537,33 +540,20 @@ export class Call {
|
|
|
537
540
|
createSubscription(this.state.session$, (session) => {
|
|
538
541
|
if (!this.ringing) return;
|
|
539
542
|
|
|
540
|
-
const
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
if (
|
|
547
|
-
this.cancelAutoDrop();
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
const isAcceptedElsewhere =
|
|
551
|
-
isAcceptedByMe && this.state.callingState === CallingState.RINGING;
|
|
543
|
+
const { settledByMe, leaveReason } = resolveOwnRingOutcome({
|
|
544
|
+
session,
|
|
545
|
+
currentUserId: this.currentUserId,
|
|
546
|
+
callingState: this.state.callingState,
|
|
547
|
+
});
|
|
548
|
+
if (settledByMe) this.cancelAutoDrop();
|
|
549
|
+
if (!leaveReason || hasPending(this.joinLeaveConcurrencyTag)) return;
|
|
552
550
|
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
globalThis.streamRNVideoSDK?.callingX?.endCall(
|
|
558
|
-
this,
|
|
559
|
-
isAcceptedElsewhere ? 'answeredElsewhere' : 'rejected',
|
|
551
|
+
globalThis.streamRNVideoSDK?.callingX?.endCall(this, leaveReason);
|
|
552
|
+
this.leave().catch(() => {
|
|
553
|
+
this.logger.error(
|
|
554
|
+
'Could not leave a call that was accepted or rejected elsewhere',
|
|
560
555
|
);
|
|
561
|
-
|
|
562
|
-
this.logger.error(
|
|
563
|
-
'Could not leave a call that was accepted or rejected elsewhere',
|
|
564
|
-
);
|
|
565
|
-
});
|
|
566
|
-
}
|
|
556
|
+
});
|
|
567
557
|
}),
|
|
568
558
|
);
|
|
569
559
|
};
|
|
@@ -608,6 +598,7 @@ export class Call {
|
|
|
608
598
|
this.state.setCallingState(CallingState.RINGING);
|
|
609
599
|
}
|
|
610
600
|
this.scheduleAutoDrop();
|
|
601
|
+
this.scheduleRingStatePolling();
|
|
611
602
|
this.leaveCallHooks.add(registerRingingCallEventHandlers(this));
|
|
612
603
|
}
|
|
613
604
|
};
|
|
@@ -713,6 +704,13 @@ export class Call {
|
|
|
713
704
|
throw new Error('Cannot leave call that has already been left.');
|
|
714
705
|
}
|
|
715
706
|
|
|
707
|
+
// before the first await: the calling state stays RINGING well into the
|
|
708
|
+
// teardown, so pause both watchdogs before they can race this leave. They
|
|
709
|
+
// keep their deadlines, so a failed leave resumes them without handing the
|
|
710
|
+
// ring another window.
|
|
711
|
+
this.ringTimeout?.pause();
|
|
712
|
+
this.ringStatePoller?.pause();
|
|
713
|
+
|
|
716
714
|
await withoutConcurrency(this.joinLeaveConcurrencyTag, async () => {
|
|
717
715
|
const callingState = this.state.callingState;
|
|
718
716
|
|
|
@@ -796,6 +794,11 @@ export class Call {
|
|
|
796
794
|
await this.dynascaleManager?.dispose();
|
|
797
795
|
|
|
798
796
|
this.state.setCallingState(CallingState.LEFT);
|
|
797
|
+
// `ringingSubject` is cleared further down, so this still reads true for
|
|
798
|
+
// a call that was ringing.
|
|
799
|
+
if (this.ringing) {
|
|
800
|
+
globalThis.streamRNVideoSDK?.ringingCallLifecycle?.onLeave(this);
|
|
801
|
+
}
|
|
799
802
|
this.state.setParticipants([]);
|
|
800
803
|
this.state.dispose();
|
|
801
804
|
|
|
@@ -821,6 +824,7 @@ export class Call {
|
|
|
821
824
|
this.unifiedSessionId = undefined;
|
|
822
825
|
this.ringingSubject.next(false);
|
|
823
826
|
this.cancelAutoDrop();
|
|
827
|
+
this.cancelRingStatePolling();
|
|
824
828
|
this.clientStore.unregisterCall(this);
|
|
825
829
|
|
|
826
830
|
globalThis.streamRNVideoSDK?.callManager.stop({
|
|
@@ -863,6 +867,17 @@ export class Call {
|
|
|
863
867
|
this.logger.warn('Failed to dispose media engine', err);
|
|
864
868
|
});
|
|
865
869
|
}
|
|
870
|
+
}).catch((err) => {
|
|
871
|
+
if (
|
|
872
|
+
!hasPending(this.joinLeaveConcurrencyTag) &&
|
|
873
|
+
this.state.callingState === CallingState.RINGING
|
|
874
|
+
) {
|
|
875
|
+
// resume, never re-arm: a fresh watchdog would restart the ring
|
|
876
|
+
// window this leave was already most of the way through
|
|
877
|
+
this.ringTimeout?.start();
|
|
878
|
+
this.ringStatePoller?.resume();
|
|
879
|
+
}
|
|
880
|
+
throw err;
|
|
866
881
|
});
|
|
867
882
|
};
|
|
868
883
|
|
|
@@ -973,12 +988,14 @@ export class Call {
|
|
|
973
988
|
members_limit?: number;
|
|
974
989
|
video?: boolean;
|
|
975
990
|
}): Promise<GetCallResponse> => {
|
|
991
|
+
const getLeaveGeneration = this.leaveGeneration;
|
|
976
992
|
await this.setup();
|
|
977
993
|
|
|
978
994
|
const response = await this.streamClient.get<GetCallResponse>(
|
|
979
995
|
this.streamClientBasePath,
|
|
980
996
|
params,
|
|
981
997
|
);
|
|
998
|
+
if (this.leaveGeneration !== getLeaveGeneration) return response;
|
|
982
999
|
|
|
983
1000
|
this.updateFromCallStateResponse(response);
|
|
984
1001
|
|
|
@@ -1056,6 +1073,27 @@ export class Call {
|
|
|
1056
1073
|
);
|
|
1057
1074
|
};
|
|
1058
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Returns who accepted, rejected or missed the ring for a call session.
|
|
1078
|
+
* Safe to poll: it performs no writes and emits no events.
|
|
1079
|
+
*
|
|
1080
|
+
* @param callSessionId the call session to read. Defaults to the current one.
|
|
1081
|
+
* Pass it explicitly to read a session that has already ended, as ending a
|
|
1082
|
+
* call clears its current session.
|
|
1083
|
+
*/
|
|
1084
|
+
getRingState = async (
|
|
1085
|
+
callSessionId?: string,
|
|
1086
|
+
): Promise<GetCallRingStateResponse> => {
|
|
1087
|
+
const sessionId = callSessionId ?? this.state.session?.id;
|
|
1088
|
+
if (!sessionId) {
|
|
1089
|
+
throw new Error('Cannot read the ring state: the call has no session');
|
|
1090
|
+
}
|
|
1091
|
+
return this.streamClient.get<GetCallRingStateResponse>(
|
|
1092
|
+
`${this.streamClientBasePath}/ring_state`,
|
|
1093
|
+
{ call_session_id: sessionId },
|
|
1094
|
+
);
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1059
1097
|
/**
|
|
1060
1098
|
* A shortcut for {@link Call.get} with `notify` parameter set to `true`.
|
|
1061
1099
|
* Will send a `call.notification` event to the call members.
|
|
@@ -1104,6 +1142,10 @@ export class Call {
|
|
|
1104
1142
|
/**
|
|
1105
1143
|
* Will start to watch for call related WebSocket events and initiate a call session with the server.
|
|
1106
1144
|
*
|
|
1145
|
+
* One instance is one call flow: discard it after leaving, cancelling, or a
|
|
1146
|
+
* failed join, and create a fresh one for a later flow. Reconnection inside a
|
|
1147
|
+
* live call is handled here and needs no new instance.
|
|
1148
|
+
*
|
|
1107
1149
|
* @returns a promise which resolves once the call join-flow has finished.
|
|
1108
1150
|
*/
|
|
1109
1151
|
join = singleFlight(
|
|
@@ -1112,19 +1154,30 @@ export class Call {
|
|
|
1112
1154
|
joinResponseTimeout,
|
|
1113
1155
|
rpcRequestTimeout,
|
|
1114
1156
|
allowOwnTracksLoopback = false,
|
|
1157
|
+
joinSource,
|
|
1115
1158
|
...data
|
|
1116
1159
|
}: JoinCallData & {
|
|
1117
1160
|
maxJoinRetries?: number;
|
|
1118
1161
|
joinResponseTimeout?: number;
|
|
1119
1162
|
rpcRequestTimeout?: number;
|
|
1120
1163
|
allowOwnTracksLoopback?: boolean;
|
|
1164
|
+
joinSource?: JoinSource;
|
|
1121
1165
|
} = {}): Promise<void> => {
|
|
1122
1166
|
const callingState = this.state.callingState;
|
|
1123
1167
|
|
|
1168
|
+
// Ahead of the failure boundary below on purpose: a duplicate join on a
|
|
1169
|
+
// live call is refused without tearing that call down.
|
|
1124
1170
|
if ([CallingState.JOINED, CallingState.JOINING].includes(callingState)) {
|
|
1125
1171
|
throw new Error(`Illegal State: call.join() shall be called only once`);
|
|
1126
1172
|
}
|
|
1127
1173
|
|
|
1174
|
+
// snapshot before the first await: a leave() landing at any point from
|
|
1175
|
+
// here on supersedes this join, and the retry loop must bail out instead
|
|
1176
|
+
// of resurrecting a call that leave() already tore down.
|
|
1177
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1178
|
+
const supersededByLeave = () =>
|
|
1179
|
+
this.leaveGeneration !== joinLeaveGeneration;
|
|
1180
|
+
|
|
1128
1181
|
// we need this to be set before the callingx.joinCall() is
|
|
1129
1182
|
// called to avoid registering the test call in the CallKit/Telecom
|
|
1130
1183
|
this.allowOwnTracksLoopback = allowOwnTracksLoopback;
|
|
@@ -1133,35 +1186,67 @@ export class Call {
|
|
|
1133
1186
|
this.ringingSubject.next(true);
|
|
1134
1187
|
}
|
|
1135
1188
|
|
|
1189
|
+
// A ringing call is joined by the SDK rather than by app code, so React
|
|
1190
|
+
// Native prepares it from in here - there is no earlier point at which the
|
|
1191
|
+
// app holds the call. Read after `data.ring` above, which is what makes an
|
|
1192
|
+
// outgoing call ringing in the first place.
|
|
1193
|
+
const ringingLifecycle = this.ringing
|
|
1194
|
+
? globalThis.streamRNVideoSDK?.ringingCallLifecycle
|
|
1195
|
+
: undefined;
|
|
1136
1196
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1197
|
+
try {
|
|
1198
|
+
if (ringingLifecycle) {
|
|
1199
|
+
await ringingLifecycle.beforeJoin(this);
|
|
1200
|
+
if (supersededByLeave()) {
|
|
1201
|
+
this.logger.debug('Join superseded by leave; not joining');
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (callingX) {
|
|
1207
|
+
// for Android/iOS, we need to start the call in the callingx library as soon as possible
|
|
1208
|
+
await callingX.joinCall(
|
|
1209
|
+
this,
|
|
1210
|
+
this.clientStore.calls,
|
|
1211
|
+
supersededByLeave,
|
|
1212
|
+
);
|
|
1213
|
+
if (supersededByLeave()) {
|
|
1214
|
+
this.logger.debug('Join superseded by leave; not setting up');
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1141
1218
|
|
|
1142
|
-
|
|
1219
|
+
await this.setup();
|
|
1220
|
+
if (supersededByLeave()) {
|
|
1221
|
+
this.logger.debug('Join superseded by leave; not registering');
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1143
1224
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1225
|
+
this.clientEventReporter.registerCall(this.cid, {
|
|
1226
|
+
callType: this.type,
|
|
1227
|
+
callId: this.id,
|
|
1228
|
+
getCallSessionId: () => this.state.session?.id ?? '',
|
|
1229
|
+
getSfuId: () => this.credentials?.server.edge_name ?? '',
|
|
1230
|
+
});
|
|
1150
1231
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
try {
|
|
1232
|
+
this.joinResponseTimeout = joinResponseTimeout;
|
|
1233
|
+
this.rpcRequestTimeout = rpcRequestTimeout;
|
|
1234
|
+
// we will count the number of join failures per SFU.
|
|
1235
|
+
// once the number of failures reaches 2, we will piggyback on the `migrating_from`
|
|
1236
|
+
// field to force the coordinator to provide us another SFU
|
|
1237
|
+
const sfuJoinFailures = new Map<string, number>();
|
|
1238
|
+
const joinData: JoinCallData = data;
|
|
1239
|
+
maxJoinRetries = Math.max(maxJoinRetries, 1);
|
|
1160
1240
|
await this.clientEventReporter.withJoinLifecycle(
|
|
1161
1241
|
this.cid,
|
|
1162
|
-
'first-attempt',
|
|
1242
|
+
{ joinReason: 'first-attempt', joinSource },
|
|
1163
1243
|
async () => {
|
|
1164
1244
|
for (let attempt = 0; attempt < maxJoinRetries; attempt++) {
|
|
1245
|
+
// Also covers a leave that lands during the backoff below.
|
|
1246
|
+
if (supersededByLeave()) {
|
|
1247
|
+
this.logger.debug('Join superseded by leave; not attempting');
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1165
1250
|
try {
|
|
1166
1251
|
this.logger.trace(`Joining call (${attempt})`, this.cid);
|
|
1167
1252
|
await this.doJoin(data);
|
|
@@ -1169,6 +1254,10 @@ export class Call {
|
|
|
1169
1254
|
delete joinData.migrating_from_list;
|
|
1170
1255
|
return;
|
|
1171
1256
|
} catch (err) {
|
|
1257
|
+
if (supersededByLeave()) {
|
|
1258
|
+
this.logger.debug('Join superseded by leave; not retrying');
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1172
1261
|
this.logger.warn(`Failed to join call (${attempt})`, this.cid);
|
|
1173
1262
|
if (
|
|
1174
1263
|
(err instanceof ErrorFromResponse && err.unrecoverable) ||
|
|
@@ -1209,6 +1298,9 @@ export class Call {
|
|
|
1209
1298
|
);
|
|
1210
1299
|
} catch (error) {
|
|
1211
1300
|
callingX?.endCall(this, 'error');
|
|
1301
|
+
// Ends the failed ringing flow and releases what its setup installed.
|
|
1302
|
+
// Never rejects, so `error` is what the caller sees.
|
|
1303
|
+
await ringingLifecycle?.onJoinFailed(this);
|
|
1212
1304
|
throw error;
|
|
1213
1305
|
}
|
|
1214
1306
|
},
|
|
@@ -1237,6 +1329,10 @@ export class Call {
|
|
|
1237
1329
|
// globals resolve to the call's factory. Idempotent across
|
|
1238
1330
|
// reconnect/migration attempts.
|
|
1239
1331
|
await this.ensureMediaFactory();
|
|
1332
|
+
if (supersededByLeave()) {
|
|
1333
|
+
this.logger.debug('Join superseded by leave; not wiring media');
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1240
1336
|
|
|
1241
1337
|
const callingX = globalThis.streamRNVideoSDK?.callingX;
|
|
1242
1338
|
if (callingX) {
|
|
@@ -1264,10 +1360,17 @@ export class Call {
|
|
|
1264
1360
|
'CoordinatorJoin',
|
|
1265
1361
|
() => this.doJoinRequest(data),
|
|
1266
1362
|
);
|
|
1363
|
+
if (!joinResponse || supersededByLeave()) {
|
|
1364
|
+
this.logger.debug(
|
|
1365
|
+
'Join superseded by leave; not creating SFU client',
|
|
1366
|
+
);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1267
1369
|
this.credentials = joinResponse.credentials;
|
|
1268
1370
|
statsOptions = joinResponse.stats_options;
|
|
1269
1371
|
this.lastStatsOptions = statsOptions;
|
|
1270
1372
|
} catch (error) {
|
|
1373
|
+
if (supersededByLeave()) return;
|
|
1271
1374
|
// prevent triggering reconnect flow if the state is OFFLINE
|
|
1272
1375
|
const avoidRestoreState =
|
|
1273
1376
|
this.state.callingState === CallingState.OFFLINE;
|
|
@@ -1322,6 +1425,10 @@ export class Call {
|
|
|
1322
1425
|
getGenericSdp('recvonly', dangerouslyForceCodec, subscriberFmtpLine),
|
|
1323
1426
|
getGenericSdp('sendonly', dangerouslyForceCodec, fmtpLine),
|
|
1324
1427
|
]);
|
|
1428
|
+
if (supersededByLeave()) {
|
|
1429
|
+
this.logger.debug('Join superseded by leave; not joining SFU');
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1325
1432
|
const isReconnecting =
|
|
1326
1433
|
this.reconnectStrategy !== WebsocketReconnectStrategy.UNSPECIFIED;
|
|
1327
1434
|
const reconnectDetails = isReconnecting
|
|
@@ -1355,6 +1462,12 @@ export class Call {
|
|
|
1355
1462
|
source: ParticipantSource.WEBRTC_UNSPECIFIED,
|
|
1356
1463
|
}),
|
|
1357
1464
|
);
|
|
1465
|
+
if (supersededByLeave()) {
|
|
1466
|
+
this.logger.debug(
|
|
1467
|
+
'Join superseded by leave; not applying SFU response',
|
|
1468
|
+
);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1358
1471
|
|
|
1359
1472
|
this.currentPublishOptions = publishOptions;
|
|
1360
1473
|
this.fastReconnectDeadlineSeconds = fastReconnectDeadlineSeconds;
|
|
@@ -1366,6 +1479,10 @@ export class Call {
|
|
|
1366
1479
|
);
|
|
1367
1480
|
}
|
|
1368
1481
|
} catch (error) {
|
|
1482
|
+
if (supersededByLeave()) {
|
|
1483
|
+
this.logger.debug('Join superseded by leave; ignoring SFU failure');
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1369
1486
|
this.logger.warn('Join SFU request failed', error);
|
|
1370
1487
|
sfuClient.close(
|
|
1371
1488
|
StreamSfuClient.JOIN_FAILED,
|
|
@@ -1377,8 +1494,8 @@ export class Call {
|
|
|
1377
1494
|
}
|
|
1378
1495
|
}
|
|
1379
1496
|
|
|
1380
|
-
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
1381
|
-
// peer-connection setup below
|
|
1497
|
+
// If the user left while this join was in flight, bail before re-setting JOINED
|
|
1498
|
+
// or starting peer-connection setup below.
|
|
1382
1499
|
if (supersededByLeave()) {
|
|
1383
1500
|
this.logger.debug('Join superseded by leave; aborting join flow');
|
|
1384
1501
|
return;
|
|
@@ -1408,6 +1525,10 @@ export class Call {
|
|
|
1408
1525
|
unifiedSessionId: this.unifiedSessionId,
|
|
1409
1526
|
});
|
|
1410
1527
|
}
|
|
1528
|
+
if (supersededByLeave()) {
|
|
1529
|
+
this.logger.debug('Join superseded by leave; not completing join flow');
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1411
1532
|
|
|
1412
1533
|
// make sure we only track connection timing if we are not calling this method as part of a reconnection flow
|
|
1413
1534
|
if (!performingRejoin && !performingFastReconnect && !performingMigration) {
|
|
@@ -1593,6 +1714,7 @@ export class Call {
|
|
|
1593
1714
|
closePreviousInstances: boolean;
|
|
1594
1715
|
unifiedSessionId: string;
|
|
1595
1716
|
}) => {
|
|
1717
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1596
1718
|
const {
|
|
1597
1719
|
sfuClient,
|
|
1598
1720
|
connectionConfig,
|
|
@@ -1609,10 +1731,12 @@ export class Call {
|
|
|
1609
1731
|
// Flush the previous reporter's final sample while its peer connections are
|
|
1610
1732
|
// still alive, before we dispose them below. Awaits only the sampling step.
|
|
1611
1733
|
await this.sfuStatsReporter?.flush();
|
|
1734
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return;
|
|
1612
1735
|
this.sfuStatsReporter?.stop();
|
|
1613
1736
|
this.sfuStatsReporter = undefined;
|
|
1614
1737
|
if (closePreviousInstances && this.subscriber) {
|
|
1615
1738
|
await this.subscriber.dispose();
|
|
1739
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return;
|
|
1616
1740
|
this.state.removeAllOrphanedTracks();
|
|
1617
1741
|
}
|
|
1618
1742
|
const basePeerConnectionOptions: BasePeerConnectionOpts = {
|
|
@@ -1659,6 +1783,7 @@ export class Call {
|
|
|
1659
1783
|
if (!isAnonymous) {
|
|
1660
1784
|
if (closePreviousInstances && this.publisher) {
|
|
1661
1785
|
await this.publisher.dispose();
|
|
1786
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return;
|
|
1662
1787
|
}
|
|
1663
1788
|
this.publisher = new Publisher(
|
|
1664
1789
|
basePeerConnectionOptions,
|
|
@@ -1702,9 +1827,19 @@ export class Call {
|
|
|
1702
1827
|
*
|
|
1703
1828
|
* @internal
|
|
1704
1829
|
* @param data the join call data.
|
|
1830
|
+
* @returns The coordinator response, or undefined if leave superseded the request before it was sent.
|
|
1705
1831
|
*/
|
|
1706
|
-
doJoinRequest = async (
|
|
1832
|
+
doJoinRequest = async (
|
|
1833
|
+
data?: JoinCallData,
|
|
1834
|
+
): Promise<JoinCallResponse | undefined> => {
|
|
1835
|
+
const joinLeaveGeneration = this.leaveGeneration;
|
|
1707
1836
|
const location = await this.streamClient.getLocationHint();
|
|
1837
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
1838
|
+
this.logger.debug(
|
|
1839
|
+
'Join superseded by leave; not sending coordinator request',
|
|
1840
|
+
);
|
|
1841
|
+
return;
|
|
1842
|
+
}
|
|
1708
1843
|
const e2ee = !!this.e2eeManager;
|
|
1709
1844
|
const request: JoinCallRequest = { ...data, location, e2ee };
|
|
1710
1845
|
const joinResponse = await this.streamClient.post<
|
|
@@ -1712,6 +1847,13 @@ export class Call {
|
|
|
1712
1847
|
JoinCallRequest
|
|
1713
1848
|
>(`${this.streamClientBasePath}/join`, request);
|
|
1714
1849
|
|
|
1850
|
+
if (this.leaveGeneration !== joinLeaveGeneration) {
|
|
1851
|
+
this.logger.debug(
|
|
1852
|
+
'Join superseded by leave; not applying coordinator response',
|
|
1853
|
+
);
|
|
1854
|
+
return joinResponse;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1715
1857
|
this.state.updateFromCallResponse(joinResponse.call);
|
|
1716
1858
|
this.state.setMembers(joinResponse.members);
|
|
1717
1859
|
this.state.setOwnCapabilities(joinResponse.own_capabilities);
|
|
@@ -1726,6 +1868,7 @@ export class Call {
|
|
|
1726
1868
|
if (!isReconnecting && this.ringing && !this.isCreatedByMe) {
|
|
1727
1869
|
// signals other users that I have accepted the incoming call.
|
|
1728
1870
|
await this.accept();
|
|
1871
|
+
if (this.leaveGeneration !== joinLeaveGeneration) return joinResponse;
|
|
1729
1872
|
}
|
|
1730
1873
|
|
|
1731
1874
|
if (this.streamClient._hasConnectionID()) {
|
|
@@ -1830,6 +1973,7 @@ export class Call {
|
|
|
1830
1973
|
callingState === CallingState.JOINING ||
|
|
1831
1974
|
callingState === CallingState.RECONNECTING ||
|
|
1832
1975
|
callingState === CallingState.MIGRATING ||
|
|
1976
|
+
callingState === CallingState.LEFT ||
|
|
1833
1977
|
callingState === CallingState.RECONNECTING_FAILED
|
|
1834
1978
|
)
|
|
1835
1979
|
return;
|
|
@@ -1840,19 +1984,26 @@ export class Call {
|
|
|
1840
1984
|
// `POST /join` per entry) once the call is already healthy again.
|
|
1841
1985
|
if (hasPending(this.reconnectConcurrencyTag)) return;
|
|
1842
1986
|
|
|
1987
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
1988
|
+
const supersededByLeave = () =>
|
|
1989
|
+
this.leaveGeneration !== reconnectLeaveGeneration;
|
|
1843
1990
|
return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
|
|
1991
|
+
if (supersededByLeave()) return;
|
|
1844
1992
|
const reconnectStartTime = Date.now();
|
|
1845
1993
|
this.reconnectStrategy = strategy;
|
|
1846
1994
|
this.reconnectReason = reason;
|
|
1847
1995
|
const sfuRejoinFailures = new Map<string, number>();
|
|
1848
1996
|
|
|
1849
1997
|
const markAsReconnectingFailed = async () => {
|
|
1998
|
+
if (supersededByLeave()) return;
|
|
1850
1999
|
try {
|
|
1851
2000
|
// attempt to fetch the call data from the server, as the call
|
|
1852
2001
|
// state might have changed while we were reconnecting or were offline
|
|
1853
2002
|
await this.get();
|
|
1854
2003
|
} finally {
|
|
1855
|
-
|
|
2004
|
+
if (!supersededByLeave()) {
|
|
2005
|
+
this.state.setCallingState(CallingState.RECONNECTING_FAILED);
|
|
2006
|
+
}
|
|
1856
2007
|
}
|
|
1857
2008
|
};
|
|
1858
2009
|
|
|
@@ -1922,6 +2073,7 @@ export class Call {
|
|
|
1922
2073
|
try {
|
|
1923
2074
|
// wait until the network is available
|
|
1924
2075
|
await this.networkAvailableTask?.promise;
|
|
2076
|
+
if (supersededByLeave()) return;
|
|
1925
2077
|
|
|
1926
2078
|
this.logger.info(
|
|
1927
2079
|
`[Reconnect] Reconnecting with strategy ${
|
|
@@ -1972,6 +2124,7 @@ export class Call {
|
|
|
1972
2124
|
this.consecutiveNegotiationFailures = 0;
|
|
1973
2125
|
break; // do-while loop, reconnection worked, exit the loop
|
|
1974
2126
|
} catch (error) {
|
|
2127
|
+
if (supersededByLeave()) return;
|
|
1975
2128
|
if (attemptedStrategy === WebsocketReconnectStrategy.REJOIN) {
|
|
1976
2129
|
const failedSfu = this.credentials?.server.edge_name;
|
|
1977
2130
|
if (failedSfu) {
|
|
@@ -2015,6 +2168,7 @@ export class Call {
|
|
|
2015
2168
|
|
|
2016
2169
|
// exponential backoff with jitter, capped at 5 s
|
|
2017
2170
|
await sleep(retryInterval(attempt));
|
|
2171
|
+
if (supersededByLeave()) return;
|
|
2018
2172
|
|
|
2019
2173
|
const wasMigrating =
|
|
2020
2174
|
this.reconnectStrategy === WebsocketReconnectStrategy.MIGRATE;
|
|
@@ -2045,6 +2199,7 @@ export class Call {
|
|
|
2045
2199
|
);
|
|
2046
2200
|
}
|
|
2047
2201
|
} while (
|
|
2202
|
+
!supersededByLeave() &&
|
|
2048
2203
|
this.state.callingState !== CallingState.JOINED &&
|
|
2049
2204
|
this.state.callingState !== CallingState.RECONNECTING_FAILED &&
|
|
2050
2205
|
this.state.callingState !== CallingState.LEFT
|
|
@@ -2058,10 +2213,12 @@ export class Call {
|
|
|
2058
2213
|
* @internal
|
|
2059
2214
|
*/
|
|
2060
2215
|
private reconnectFast = async () => {
|
|
2216
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
2061
2217
|
const reconnectStartTime = Date.now();
|
|
2062
2218
|
this.reconnectStrategy = WebsocketReconnectStrategy.FAST;
|
|
2063
2219
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
2064
2220
|
await this.doJoin(this.joinCallData);
|
|
2221
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2065
2222
|
await this.get(); // fetch the latest call state, as it might have changed
|
|
2066
2223
|
this.sfuStatsReporter?.sendReconnectionTime(
|
|
2067
2224
|
WebsocketReconnectStrategy.FAST,
|
|
@@ -2074,6 +2231,7 @@ export class Call {
|
|
|
2074
2231
|
* @internal
|
|
2075
2232
|
*/
|
|
2076
2233
|
private reconnectRejoin = async () => {
|
|
2234
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
2077
2235
|
const reconnectStartTime = Date.now();
|
|
2078
2236
|
this.reconnectStrategy = WebsocketReconnectStrategy.REJOIN;
|
|
2079
2237
|
this.state.setCallingState(CallingState.RECONNECTING);
|
|
@@ -2081,10 +2239,14 @@ export class Call {
|
|
|
2081
2239
|
this.reconnectReason === ReconnectReason.NETWORK_BACK_ONLINE
|
|
2082
2240
|
? 'network-available'
|
|
2083
2241
|
: 'full-rejoin';
|
|
2084
|
-
await this.clientEventReporter.withJoinLifecycle(
|
|
2085
|
-
this.
|
|
2242
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
2243
|
+
this.cid,
|
|
2244
|
+
{ joinReason },
|
|
2245
|
+
() => this.doJoin(this.joinCallData),
|
|
2086
2246
|
);
|
|
2247
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2087
2248
|
await this.restorePublishedTracks();
|
|
2249
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2088
2250
|
this.restoreSubscribedTracks();
|
|
2089
2251
|
this.sfuStatsReporter?.sendReconnectionTime(
|
|
2090
2252
|
WebsocketReconnectStrategy.REJOIN,
|
|
@@ -2097,6 +2259,7 @@ export class Call {
|
|
|
2097
2259
|
* @internal
|
|
2098
2260
|
*/
|
|
2099
2261
|
private reconnectMigrate = async () => {
|
|
2262
|
+
const reconnectLeaveGeneration = this.leaveGeneration;
|
|
2100
2263
|
const reconnectStartTime = Date.now();
|
|
2101
2264
|
const currentSfuClient = this.sfuClient;
|
|
2102
2265
|
if (!currentSfuClient) {
|
|
@@ -2114,32 +2277,35 @@ export class Call {
|
|
|
2114
2277
|
const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
|
|
2115
2278
|
|
|
2116
2279
|
try {
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
this.
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2280
|
+
try {
|
|
2281
|
+
const currentSfu = currentSfuClient.edgeName;
|
|
2282
|
+
await this.clientEventReporter.withJoinLifecycle(
|
|
2283
|
+
this.cid,
|
|
2284
|
+
{ joinReason: 'migration' },
|
|
2285
|
+
() =>
|
|
2286
|
+
this.doJoin({
|
|
2287
|
+
...this.joinCallData,
|
|
2288
|
+
migrating_from: currentSfu,
|
|
2289
|
+
migrating_from_list: [currentSfu],
|
|
2290
|
+
}),
|
|
2291
|
+
);
|
|
2292
|
+
} finally {
|
|
2293
|
+
// cleanup the migration_from field after the migration is complete or failed
|
|
2294
|
+
// as we don't want to keep dirty data in the join call data
|
|
2295
|
+
delete this.joinCallData?.migrating_from;
|
|
2296
|
+
delete this.joinCallData?.migrating_from_list;
|
|
2297
|
+
}
|
|
2134
2298
|
|
|
2135
|
-
|
|
2136
|
-
|
|
2299
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2300
|
+
await this.restorePublishedTracks();
|
|
2301
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2302
|
+
this.restoreSubscribedTracks();
|
|
2137
2303
|
|
|
2138
|
-
try {
|
|
2139
2304
|
// Wait for the migration to complete, then close the previous SFU client
|
|
2140
2305
|
// and the peer connection instances. In case of failure, the migration
|
|
2141
2306
|
// task would throw an error and REJOIN would be attempted.
|
|
2142
2307
|
await migrationTask();
|
|
2308
|
+
if (this.leaveGeneration !== reconnectLeaveGeneration) return;
|
|
2143
2309
|
|
|
2144
2310
|
// in MIGRATE, we can consider the call as joined only after
|
|
2145
2311
|
// `participantMigrationComplete` event is received, signaled by
|
|
@@ -2473,10 +2639,11 @@ export class Call {
|
|
|
2473
2639
|
* Must be called before {@link join} so the RTCPeerConnection can be
|
|
2474
2640
|
* configured for E2EE.
|
|
2475
2641
|
*
|
|
2476
|
-
* The
|
|
2477
|
-
*
|
|
2478
|
-
*
|
|
2479
|
-
*
|
|
2642
|
+
* The application owns the manager's lifetime - the SDK never disposes it, and
|
|
2643
|
+
* closing the peer connections does not release it. Discard the manager
|
|
2644
|
+
* together with this call instance once its flow has ended, and create a fresh
|
|
2645
|
+
* pair for a later flow: a disposed manager throws from `encrypt`/`decrypt`
|
|
2646
|
+
* rather than silently publishing nothing.
|
|
2480
2647
|
*
|
|
2481
2648
|
* @param e2ee - Any `E2EEManager`. Use `EncryptionManager.create()` for the
|
|
2482
2649
|
* built-in AES-GCM scheme, or pass your own implementation.
|
|
@@ -3114,42 +3281,39 @@ export class Call {
|
|
|
3114
3281
|
*/
|
|
3115
3282
|
private scheduleAutoDrop = () => {
|
|
3116
3283
|
this.cancelAutoDrop();
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
if (!settings) return;
|
|
3120
|
-
// ignore if the call is not ringing
|
|
3121
|
-
if (this.state.callingState !== CallingState.RINGING) return;
|
|
3122
|
-
|
|
3123
|
-
const timeoutInMs = this.isCreatedByMe
|
|
3124
|
-
? settings.ring.auto_cancel_timeout_ms
|
|
3125
|
-
: settings.ring.incoming_call_timeout_ms;
|
|
3126
|
-
|
|
3127
|
-
// 0 means no auto-drop
|
|
3128
|
-
if (timeoutInMs <= 0) return;
|
|
3129
|
-
this.dropTimeout = setTimeout(() => {
|
|
3130
|
-
// the call might have stopped ringing by this point,
|
|
3131
|
-
// e.g. it was already accepted and joined
|
|
3132
|
-
if (this.state.callingState !== CallingState.RINGING) return;
|
|
3133
|
-
this.leave({
|
|
3134
|
-
reject: true,
|
|
3135
|
-
reason: 'timeout',
|
|
3136
|
-
message: `ringing timeout - ${
|
|
3137
|
-
this.isCreatedByMe
|
|
3138
|
-
? 'no one accepted'
|
|
3139
|
-
: `user didn't interact with incoming call screen`
|
|
3140
|
-
}`,
|
|
3141
|
-
}).catch((err) => {
|
|
3142
|
-
this.logger.error('Failed to drop call', err);
|
|
3143
|
-
});
|
|
3144
|
-
}, timeoutInMs);
|
|
3284
|
+
this.ringTimeout = new RingTimeout(this);
|
|
3285
|
+
this.ringTimeout.start();
|
|
3145
3286
|
};
|
|
3146
3287
|
|
|
3147
3288
|
/**
|
|
3148
3289
|
* Cancels a scheduled auto-drop timeout.
|
|
3149
3290
|
*/
|
|
3150
3291
|
private cancelAutoDrop = () => {
|
|
3151
|
-
|
|
3152
|
-
this.
|
|
3292
|
+
this.ringTimeout?.stop();
|
|
3293
|
+
this.ringTimeout = undefined;
|
|
3294
|
+
};
|
|
3295
|
+
|
|
3296
|
+
/**
|
|
3297
|
+
* Starts polling for the ring outcome. Applicable only to ringing calls the
|
|
3298
|
+
* current user created.
|
|
3299
|
+
*/
|
|
3300
|
+
private scheduleRingStatePolling = () => {
|
|
3301
|
+
this.cancelRingStatePolling();
|
|
3302
|
+
|
|
3303
|
+
if (!this.isCreatedByMe) return;
|
|
3304
|
+
const options = this.streamClient.options.ringStatePolling;
|
|
3305
|
+
if (options === false) return;
|
|
3306
|
+
|
|
3307
|
+
this.ringStatePoller = new RingStatePoller(this, options);
|
|
3308
|
+
this.ringStatePoller.start();
|
|
3309
|
+
};
|
|
3310
|
+
|
|
3311
|
+
/**
|
|
3312
|
+
* Cancels the ring state polling.
|
|
3313
|
+
*/
|
|
3314
|
+
private cancelRingStatePolling = () => {
|
|
3315
|
+
this.ringStatePoller?.stop();
|
|
3316
|
+
this.ringStatePoller = undefined;
|
|
3153
3317
|
};
|
|
3154
3318
|
|
|
3155
3319
|
/**
|