@webex/plugin-meetings 3.12.0-next.91 → 3.12.0-next.93

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 (38) hide show
  1. package/dist/aiEnableRequest/index.js +1 -1
  2. package/dist/breakouts/breakout.js +1 -1
  3. package/dist/breakouts/index.js +1 -1
  4. package/dist/common/errors/webex-errors.js +47 -5
  5. package/dist/common/errors/webex-errors.js.map +1 -1
  6. package/dist/interpretation/index.js +1 -1
  7. package/dist/interpretation/siLanguage.js +1 -1
  8. package/dist/locus-info/index.js +14 -5
  9. package/dist/locus-info/index.js.map +1 -1
  10. package/dist/media/MediaConnectionAwaiter.js +15 -2
  11. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  12. package/dist/media/index.js +5 -1
  13. package/dist/media/index.js.map +1 -1
  14. package/dist/meeting/index.js +458 -365
  15. package/dist/meeting/index.js.map +1 -1
  16. package/dist/reachability/clusterReachability.js.map +1 -1
  17. package/dist/reachability/index.js +111 -70
  18. package/dist/reachability/index.js.map +1 -1
  19. package/dist/types/common/errors/webex-errors.d.ts +31 -2
  20. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  21. package/dist/types/meeting/index.d.ts +18 -3
  22. package/dist/types/reachability/clusterReachability.d.ts +3 -3
  23. package/dist/types/reachability/index.d.ts +9 -2
  24. package/dist/webinar/index.js +1 -1
  25. package/package.json +3 -3
  26. package/src/common/errors/webex-errors.ts +47 -3
  27. package/src/locus-info/index.ts +15 -5
  28. package/src/media/MediaConnectionAwaiter.ts +21 -4
  29. package/src/media/index.ts +7 -1
  30. package/src/meeting/index.ts +100 -27
  31. package/src/reachability/clusterReachability.ts +3 -2
  32. package/src/reachability/index.ts +28 -1
  33. package/test/unit/spec/common/errors/webex-errors.ts +62 -0
  34. package/test/unit/spec/locus-info/index.js +44 -0
  35. package/test/unit/spec/media/MediaConnectionAwaiter.ts +55 -0
  36. package/test/unit/spec/media/index.ts +61 -0
  37. package/test/unit/spec/meeting/index.js +290 -8
  38. package/test/unit/spec/reachability/index.ts +69 -0
@@ -157,19 +157,63 @@ WebExMeetingsErrors[IceGatheringFailed.CODE] = IceGatheringFailed;
157
157
  class AddMediaFailed extends WebexMeetingsError {
158
158
  static CODE = 30203;
159
159
  cause?: Error;
160
+ connectionType?: string;
161
+ selectedCandidatePairChanges?: number;
162
+ numTransports?: number;
163
+ iceConnected?: boolean;
160
164
 
161
165
  /**
162
166
  * Creates a new AddMediaFailed error
163
- * @param {Error} [cause] - The underlying error that caused the media addition to fail
167
+ * @param {Object} options
168
+ * @param {Error} [options.cause] - The underlying error that caused the media addition to fail
169
+ * @param {string} [options.connectionType] - The ICE connection type at the time of failure (e.g. 'UDP', 'TURN-TLS')
170
+ * @param {number} [options.selectedCandidatePairChanges] - Number of times the selected candidate pair changed
171
+ * @param {number} [options.numTransports] - Number of ICE transports
172
+ * @param {boolean} [options.iceConnected] - Whether ICE connection was established before the failure
164
173
  */
165
- constructor(cause?: Error) {
174
+ constructor(
175
+ options: {
176
+ cause?: Error;
177
+ connectionType?: string;
178
+ selectedCandidatePairChanges?: number;
179
+ numTransports?: number;
180
+ iceConnected?: boolean;
181
+ } = {}
182
+ ) {
166
183
  super(AddMediaFailed.CODE, 'Failed to add media');
167
- this.cause = cause;
184
+ this.cause = options.cause;
185
+ this.connectionType = options.connectionType;
186
+ this.selectedCandidatePairChanges = options.selectedCandidatePairChanges;
187
+ this.numTransports = options.numTransports;
188
+ this.iceConnected = options.iceConnected;
189
+ }
190
+
191
+ /**
192
+ * Returns true if the failure was due to a DTLS handshake failure
193
+ * (ICE connected successfully but the overall media connection failed).
194
+ */
195
+ get isDtlsHandshakeFailure(): boolean {
196
+ return this.iceConnected === true;
168
197
  }
169
198
  }
170
199
  export {AddMediaFailed};
171
200
  WebExMeetingsErrors[AddMediaFailed.CODE] = AddMediaFailed;
172
201
 
202
+ /**
203
+ * @class MediaConnectionTimedOutError
204
+ * @classdesc Internal error thrown when the media connection times out waiting to connect.
205
+ */
206
+ class MediaConnectionTimedOutError extends Error {
207
+ iceConnected: boolean;
208
+
209
+ constructor(message: string, iceConnected: boolean) {
210
+ super(message);
211
+ this.name = 'MediaConnectionTimedOutError';
212
+ this.iceConnected = iceConnected;
213
+ }
214
+ }
215
+ export {MediaConnectionTimedOutError};
216
+
173
217
  /**
174
218
  * @class SdpResponseTimeoutError
175
219
  * @classdesc Raised whenever we timeout waiting for remote SDP answer
@@ -518,7 +518,7 @@ export default class LocusInfo extends EventsScope {
518
518
  this.updateControls(locus.controls, locus.self);
519
519
  this.updateLocusUrl(locus.url, ControlsUtils.isMainSessionDTO(locus));
520
520
  this.updateFullState(locus.fullState);
521
- this.updateMeetingInfo(locus.info);
521
+ this.updateMeetingInfo(locus.info, locus.self);
522
522
  this.updateEmbeddedApps(locus.embeddedApps);
523
523
  // self and participants generate sipUrl for 1:1 meeting
524
524
  this.updateSelf(locus.self);
@@ -2491,9 +2491,19 @@ export default class LocusInfo extends EventsScope {
2491
2491
  */
2492
2492
  updateMeetingInfo(info: object, self?: object) {
2493
2493
  const roles = self ? SelfUtils.getRoles(self) : this.parsedLocus.self?.roles || [];
2494
- if ((info && !isEqual(this.info, info)) || (!isEqual(this.roles, roles) && info)) {
2495
- const isJoined = SelfUtils.isJoined(self || this.parsedLocus.self);
2496
- const parsedInfo = InfoUtils.getInfos(this.parsedLocus.info, info, roles, isJoined);
2494
+ const isJoined = SelfUtils.isJoined(self || this.parsedLocus.self);
2495
+
2496
+ // The parsed userDisplayHints depend on info, roles and isJoined, so we must recompute
2497
+ // whenever any of them changes. A common case is self transitioning to JOINED via a delta
2498
+ // that doesn't carry an info section - in that case we fall back to the previously stored
2499
+ // info so the hints get reparsed with the new joined state (e.g. VIEW_THE_PARTICIPANT_LIST).
2500
+ const infoToParse = info || this.info;
2501
+ const infoChanged = info && !isEqual(this.info, info);
2502
+ const rolesChanged = !isEqual(this.roles, roles);
2503
+ const isJoinedChanged = SelfUtils.isJoined(this.parsedLocus.self) !== isJoined;
2504
+
2505
+ if (infoToParse && (infoChanged || rolesChanged || isJoinedChanged)) {
2506
+ const parsedInfo = InfoUtils.getInfos(this.parsedLocus.info, infoToParse, roles, isJoined);
2497
2507
 
2498
2508
  if (parsedInfo.updates.isLocked) {
2499
2509
  this.emitScoped(
@@ -2516,7 +2526,7 @@ export default class LocusInfo extends EventsScope {
2516
2526
  );
2517
2527
  }
2518
2528
 
2519
- this.info = info;
2529
+ this.info = infoToParse;
2520
2530
  this.parsedLocus.info = parsedInfo.current;
2521
2531
  // Parses the info and adds necessary values
2522
2532
  this.updateMeeting(parsedInfo.current);
@@ -10,6 +10,10 @@ export interface MediaConnectionAwaiterProps {
10
10
  correlationId: string;
11
11
  }
12
12
 
13
+ export interface FailureResult {
14
+ iceConnected: boolean;
15
+ }
16
+
13
17
  /**
14
18
  * @class MediaConnectionAwaiter
15
19
  */
@@ -58,6 +62,17 @@ export default class MediaConnectionAwaiter {
58
62
  return this.webrtcMediaConnection.getConnectionState() === ConnectionState.Failed;
59
63
  }
60
64
 
65
+ /**
66
+ * Returns true if ICE connection state indicates connectivity.
67
+ *
68
+ * @returns {boolean}
69
+ */
70
+ private isIceConnected(): boolean {
71
+ const state = this.webrtcMediaConnection.getIceConnectionState();
72
+
73
+ return state === 'connected' || state === 'completed';
74
+ }
75
+
61
76
  /**
62
77
  * Returns true if the ICE Gathering is completed, false otherwise.
63
78
  *
@@ -105,7 +120,7 @@ export default class MediaConnectionAwaiter {
105
120
 
106
121
  this.defer.reject({
107
122
  iceConnected: this.iceConnected,
108
- });
123
+ } satisfies FailureResult);
109
124
  }
110
125
 
111
126
  if (!this.isConnected()) {
@@ -148,7 +163,7 @@ export default class MediaConnectionAwaiter {
148
163
  `Media:MediaConnectionAwaiter#iceConnectionStateHandler --> ICE connection state change -> ${iceConnectionState}`
149
164
  );
150
165
 
151
- if (iceConnectionState === 'connected' && !this.iceConnected) {
166
+ if (this.isIceConnected() && !this.iceConnected) {
152
167
  this.iceConnected = true;
153
168
  }
154
169
 
@@ -249,13 +264,13 @@ export default class MediaConnectionAwaiter {
249
264
 
250
265
  this.defer.reject({
251
266
  iceConnected: this.iceConnected,
252
- });
267
+ } satisfies FailureResult);
253
268
  }
254
269
 
255
270
  /**
256
271
  * Waits for the webrtc media connection to be connected.
257
272
  *
258
- * @returns {Promise}
273
+ * @returns {Promise<void>} In case of failure, the promise is rejected with FailureResult
259
274
  */
260
275
  waitForMediaConnectionConnected(): Promise<void> {
261
276
  if (this.isConnected()) {
@@ -269,6 +284,8 @@ export default class MediaConnectionAwaiter {
269
284
  'Media:MediaConnectionAwaiter#waitForMediaConnectionConnected --> Waiting for media connection to be connected'
270
285
  );
271
286
 
287
+ this.iceConnected = this.isIceConnected();
288
+
272
289
  this.webrtcMediaConnection.on(
273
290
  MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED,
274
291
  this.peerConnectionStateCallback
@@ -142,6 +142,7 @@ Media.createMediaConnection = (
142
142
  enableExtmap?: boolean;
143
143
  turnServerInfo?: TurnServerInfo;
144
144
  bundlePolicy?: BundlePolicy;
145
+ iceTransportPolicy?: RTCIceTransportPolicy;
145
146
  iceCandidatesTimeout?: number;
146
147
  disableAudioMainDtx?: boolean;
147
148
  enableAudioTwcc?: boolean;
@@ -157,6 +158,7 @@ Media.createMediaConnection = (
157
158
  enableExtmap,
158
159
  turnServerInfo,
159
160
  bundlePolicy,
161
+ iceTransportPolicy,
160
162
  iceCandidatesTimeout,
161
163
  disableAudioMainDtx,
162
164
  enableAudioTwcc,
@@ -167,7 +169,7 @@ Media.createMediaConnection = (
167
169
  const iceServers = [];
168
170
 
169
171
  // we might not have any TURN server if TURN discovery failed or wasn't done or we land on a video mesh node
170
- if (turnServerInfo?.urls.length > 0) {
172
+ if (turnServerInfo && turnServerInfo.urls.length > 0) {
171
173
  // TURN-TLS server
172
174
  iceServers.push({
173
175
  urls: turnServerInfo.urls,
@@ -187,6 +189,10 @@ Media.createMediaConnection = (
187
189
  config.bundlePolicy = bundlePolicy;
188
190
  }
189
191
 
192
+ if (iceTransportPolicy) {
193
+ config.iceTransportPolicy = iceTransportPolicy;
194
+ }
195
+
190
196
  if (disableAudioMainDtx !== undefined) {
191
197
  config.disableAudioMainDtx = disableAudioMainDtx;
192
198
  }
@@ -66,6 +66,7 @@ import {
66
66
  NoMediaEstablishedYetError,
67
67
  UserNotJoinedError,
68
68
  AddMediaFailed,
69
+ MediaConnectionTimedOutError,
69
70
  SdpResponseTimeoutError,
70
71
  } from '../common/errors/webex-errors';
71
72
 
@@ -76,6 +77,7 @@ import Roap, {type TurnDiscoveryResult, type TurnDiscoverySkipReason} from '../r
76
77
  import {type TurnServerInfo} from '../roap/types';
77
78
  import Media, {type BundlePolicy} from '../media';
78
79
  import MediaProperties from '../media/properties';
80
+ import {type FailureResult as MediaConnectionAwaiterFailureResult} from '../media/MediaConnectionAwaiter';
79
81
  import MeetingStateMachine from './state';
80
82
  import {createMuteState} from './muteState';
81
83
  import LocusInfo, {LocusLLMEvent} from '../locus-info';
@@ -790,6 +792,7 @@ export default class Meeting extends StatelessWebexPlugin {
790
792
  private addMediaData: {
791
793
  retriedWithTurnServer: boolean;
792
794
  icePhaseCallback: () => string;
795
+ iceTransportPolicy?: RTCIceTransportPolicy;
793
796
  };
794
797
 
795
798
  private sendSlotManager: SendSlotManager = new SendSlotManager(LoggerProxy);
@@ -5685,6 +5688,8 @@ export default class Meeting extends StatelessWebexPlugin {
5685
5688
  joined = true;
5686
5689
  }
5687
5690
 
5691
+ const shouldRetryMediaWithOnlyTurnTLS = await this.shouldRetryMediaWithOnlyTurnTLS(prevError);
5692
+
5688
5693
  const mediaResponse = await this.addMediaInternal(
5689
5694
  () => {
5690
5695
  // callback is not called when UserNotJoinedError is thrown
@@ -5692,8 +5697,9 @@ export default class Meeting extends StatelessWebexPlugin {
5692
5697
  ? 'JOIN_MEETING_FINAL'
5693
5698
  : 'JOIN_MEETING_RETRY';
5694
5699
  },
5695
- turnServerInfo,
5696
5700
  forceTurnDiscovery,
5701
+ turnServerInfo,
5702
+ shouldRetryMediaWithOnlyTurnTLS ? 'relay' : undefined,
5697
5703
  mediaOptions
5698
5704
  );
5699
5705
 
@@ -7907,17 +7913,54 @@ export default class Meeting extends StatelessWebexPlugin {
7907
7913
  return `MC-${this.id.substring(0, 4)}`;
7908
7914
  }
7909
7915
 
7916
+ /**
7917
+ * Determines if the next media attempt should use only TURN-TLS (iceTransportPolicy='relay').
7918
+ * We do this in the hope that it will minimize the chance of Homer sending DTLS packets on a wrong
7919
+ * transport (as there will be only 1 transport) and dealing with firewalls that let STUN packets through, but block
7920
+ * other UDP traffic.
7921
+ *
7922
+ * For clients landing on video mesh nodes (VMN), reachability might still have successful TLS (to public nodes)
7923
+ * but TURN discovery will result in empty TURN urls array and iceTransportPolicy='relay' will still be overriden to undefined,
7924
+ * see the code in establishMediaConnection()
7925
+ *
7926
+ * @param {Error} prevError - The error from the previous addMedia attempt
7927
+ * @returns {Promise<boolean>}
7928
+ */
7929
+ private async shouldRetryMediaWithOnlyTurnTLS(prevError: Error | undefined): Promise<boolean> {
7930
+ if (
7931
+ this.isMultistream &&
7932
+ prevError instanceof AddMediaFailed &&
7933
+ prevError.isDtlsHandshakeFailure &&
7934
+ prevError.connectionType === 'UDP'
7935
+ ) {
7936
+ const hasTlsReachability =
7937
+ // @ts-ignore
7938
+ await this.webex.meetings.reachability.isAnyClusterReachableViaProtocol('xtls');
7939
+
7940
+ if (hasTlsReachability) {
7941
+ LoggerProxy.logger.info(
7942
+ 'Meeting:index#shouldRetryMediaWithOnlyTurnTLS --> previous attempt failed due to DTLS handshake failure over UDP and TLS reachability is available'
7943
+ );
7944
+
7945
+ return true;
7946
+ }
7947
+ }
7948
+
7949
+ return false;
7950
+ }
7951
+
7910
7952
  /**
7911
7953
  * Creates a webrtc media connection and publishes streams to it
7912
7954
  *
7913
7955
  * @param {Object} turnServerInfo TURN server information
7914
7956
  * @param {BundlePolicy} [bundlePolicy] Bundle policy settings
7915
- * @param {AddMediaOptions} [options] Options for enabling/disabling audio/video
7957
+ * @param {RTCIceTransportPolicy} [iceTransportPolicy] ICE transport policy override
7916
7958
  * @returns {RoapMediaConnection | MultistreamRoapMediaConnection}
7917
7959
  */
7918
7960
  private async createMediaConnection(
7919
7961
  turnServerInfo?: TurnServerInfo,
7920
- bundlePolicy?: BundlePolicy
7962
+ bundlePolicy?: BundlePolicy,
7963
+ iceTransportPolicy?: RTCIceTransportPolicy
7921
7964
  ) {
7922
7965
  this.rtcMetrics = this.isMultistream
7923
7966
  ? // @ts-ignore
@@ -7941,6 +7984,7 @@ export default class Meeting extends StatelessWebexPlugin {
7941
7984
  enableExtmap: this.config.enableExtmap,
7942
7985
  turnServerInfo,
7943
7986
  bundlePolicy,
7987
+ iceTransportPolicy,
7944
7988
  // @ts-ignore - config coming from registerPlugin
7945
7989
  iceCandidatesTimeout: this.config.iceCandidatesGatheringTimeout,
7946
7990
  // @ts-ignore - config coming from registerPlugin
@@ -8067,7 +8111,7 @@ export default class Meeting extends StatelessWebexPlugin {
8067
8111
  try {
8068
8112
  await this.mediaProperties.waitForMediaConnectionConnected(this.correlationId);
8069
8113
  } catch (error) {
8070
- const {iceConnected} = error;
8114
+ const {iceConnected} = error as MediaConnectionAwaiterFailureResult;
8071
8115
 
8072
8116
  if (!this.hasMediaConnectionConnectedAtLeastOnce) {
8073
8117
  // Only send CA event for join flow if we haven't successfully connected media yet
@@ -8107,13 +8151,10 @@ export default class Meeting extends StatelessWebexPlugin {
8107
8151
  });
8108
8152
  }
8109
8153
 
8110
- const timedOutError = new Error(
8111
- `Timed out waiting for media connection to be connected, correlationId=${this.correlationId}`
8154
+ throw new MediaConnectionTimedOutError(
8155
+ `Timed out waiting for media connection to be connected, correlationId=${this.correlationId}`,
8156
+ iceConnected
8112
8157
  );
8113
-
8114
- timedOutError.cause = error;
8115
-
8116
- throw timedOutError;
8117
8158
  }
8118
8159
  }
8119
8160
 
@@ -8326,7 +8367,18 @@ export default class Meeting extends StatelessWebexPlugin {
8326
8367
  error
8327
8368
  );
8328
8369
 
8329
- throw new AddMediaFailed(error);
8370
+ const {connectionType, selectedCandidatePairChanges, numTransports} =
8371
+ await this.mediaProperties.getCurrentConnectionInfo();
8372
+
8373
+ const iceConnected = error instanceof MediaConnectionTimedOutError && error.iceConnected;
8374
+
8375
+ throw new AddMediaFailed({
8376
+ cause: error,
8377
+ connectionType,
8378
+ selectedCandidatePairChanges,
8379
+ numTransports,
8380
+ iceConnected,
8381
+ });
8330
8382
  }
8331
8383
  }
8332
8384
 
@@ -8401,7 +8453,21 @@ export default class Meeting extends StatelessWebexPlugin {
8401
8453
  ({turnServerInfo} = await this.doTurnDiscovery(isReconnecting, isForced));
8402
8454
  }
8403
8455
 
8404
- const mc = await this.createMediaConnection(turnServerInfo, bundlePolicy);
8456
+ if (
8457
+ (!turnServerInfo || !turnServerInfo.urls?.length) &&
8458
+ this.addMediaData.iceTransportPolicy === 'relay'
8459
+ ) {
8460
+ LoggerProxy.logger.info(
8461
+ `${LOG_HEADER} cannot do iceTransportPolicy=relay, because there is no turn server info available`
8462
+ );
8463
+ // drop the forced relay policy and use the default, because we don't have a TURN server
8464
+ this.addMediaData.iceTransportPolicy = undefined;
8465
+ }
8466
+ const mediaConnection = await this.createMediaConnection(
8467
+ turnServerInfo,
8468
+ bundlePolicy,
8469
+ this.addMediaData.iceTransportPolicy
8470
+ );
8405
8471
 
8406
8472
  LoggerProxy.logger.info(
8407
8473
  `${LOG_HEADER} media connection created this.isMultistream=${this.isMultistream}`
@@ -8438,7 +8504,7 @@ export default class Meeting extends StatelessWebexPlugin {
8438
8504
  await this.remoteMediaManager.start();
8439
8505
  }
8440
8506
 
8441
- await mc.initiateOffer();
8507
+ await mediaConnection.initiateOffer();
8442
8508
 
8443
8509
  await this.waitForRemoteSDPAnswer();
8444
8510
 
@@ -8506,6 +8572,9 @@ export default class Meeting extends StatelessWebexPlugin {
8506
8572
 
8507
8573
  this.isMultistream = false;
8508
8574
 
8575
+ // reset iceTransportPolicy, because it's only applied for multistream media connections
8576
+ this.addMediaData.iceTransportPolicy = undefined;
8577
+
8509
8578
  if (this.mediaProperties.webrtcMediaConnection) {
8510
8579
  // close peer connection, but don't reset mute state information, because we will want to use it on the retry
8511
8580
  this.closePeerConnections(false);
@@ -8630,8 +8699,9 @@ export default class Meeting extends StatelessWebexPlugin {
8630
8699
  addMedia(options: AddMediaOptions = {}): Promise<void> {
8631
8700
  return this.addMediaInternal(
8632
8701
  () => (this.turnServerUsed ? 'JOIN_MEETING_FINAL' : 'JOIN_MEETING_RETRY'),
8633
- undefined,
8634
8702
  false,
8703
+ undefined /* turnServerInfo - it will be fetched via doTurnDiscovery() */,
8704
+ undefined /* iceTransportPolicy - we're relying on the default value */,
8635
8705
  options
8636
8706
  );
8637
8707
  }
@@ -8640,8 +8710,9 @@ export default class Meeting extends StatelessWebexPlugin {
8640
8710
  * Internal version of addMedia() with some more arguments for finer control of its behavior
8641
8711
  *
8642
8712
  * @param {Function} icePhaseCallback - callback to determine the icePhase for CA "client.ice.end" failure events
8643
- * @param {TurnServerInfo} turnServerInfo - TURN server information
8644
8713
  * @param {boolean} forceTurnDiscovery - if true, TURN discovery will be done
8714
+ * @param {TurnServerInfo} turnServerInfo - TURN server information
8715
+ * @param {RTCIceTransportPolicy} [iceTransportPolicy] - ICE transport policy override
8645
8716
  * @param {AddMediaOptions} options - same as options of the public addMedia() method
8646
8717
  * @returns {Promise<void>}
8647
8718
  * @protected
@@ -8649,12 +8720,14 @@ export default class Meeting extends StatelessWebexPlugin {
8649
8720
  */
8650
8721
  protected async addMediaInternal(
8651
8722
  icePhaseCallback: () => string,
8652
- turnServerInfo: TurnServerInfo,
8653
- forceTurnDiscovery,
8723
+ forceTurnDiscovery: boolean,
8724
+ turnServerInfo?: TurnServerInfo,
8725
+ iceTransportPolicy?: RTCIceTransportPolicy,
8654
8726
  options: AddMediaOptions = {}
8655
8727
  ): Promise<void> {
8656
8728
  this.addMediaData.retriedWithTurnServer = false;
8657
8729
  this.addMediaData.icePhaseCallback = icePhaseCallback;
8730
+ this.addMediaData.iceTransportPolicy = iceTransportPolicy;
8658
8731
 
8659
8732
  this.hasMediaConnectionConnectedAtLeastOnce = false;
8660
8733
  const LOG_HEADER = 'Meeting:index#addMedia -->';
@@ -8663,7 +8736,7 @@ export default class Meeting extends StatelessWebexPlugin {
8663
8736
  options
8664
8737
  )}, turnServerInfo=${JSON.stringify(
8665
8738
  turnServerInfo
8666
- )}, forceTurnDiscovery=${forceTurnDiscovery}`
8739
+ )}, forceTurnDiscovery=${forceTurnDiscovery}, iceTransportPolicy=${iceTransportPolicy}`
8667
8740
  );
8668
8741
 
8669
8742
  if (options.allowMediaInLobby !== true && this.meetingState !== FULL_STATE.ACTIVE) {
@@ -8777,12 +8850,7 @@ export default class Meeting extends StatelessWebexPlugin {
8777
8850
 
8778
8851
  // Establish new media connection with forced TURN discovery
8779
8852
  // We need to do TURN discovery again, because backend will be creating a new confluence, so it might land on a different node or cluster
8780
- await this.establishMediaConnection(
8781
- remoteMediaManagerConfig,
8782
- bundlePolicy,
8783
- true,
8784
- undefined
8785
- );
8853
+ await this.establishMediaConnection(remoteMediaManagerConfig, bundlePolicy, true);
8786
8854
  } else {
8787
8855
  throw error;
8788
8856
  }
@@ -8812,6 +8880,7 @@ export default class Meeting extends StatelessWebexPlugin {
8812
8880
  isMultistream: this.isMultistream,
8813
8881
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8814
8882
  isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8883
+ iceTransportPolicy: this.addMediaData.iceTransportPolicy || 'all',
8815
8884
  ...reachabilityMetrics,
8816
8885
  ...iceCandidateErrors,
8817
8886
  iceCandidatesCount: this.iceCandidatesCount,
@@ -8833,14 +8902,16 @@ export default class Meeting extends StatelessWebexPlugin {
8833
8902
  // We can log ReceiveSlot SSRCs only after the SDP exchange, so doing it here:
8834
8903
  this.remoteMediaManager?.logAllReceiveSlots();
8835
8904
  this.startPeriodicLogUpload();
8836
- } catch (error) {
8905
+ } catch (error: any) {
8837
8906
  LoggerProxy.logger.error(`${LOG_HEADER} failed to establish media connection: `, error);
8838
8907
 
8839
8908
  // @ts-ignore
8840
8909
  const reachabilityMetrics = await this.getMediaReachabilityMetricFields();
8841
8910
 
8842
- const {selectedCandidatePairChanges, numTransports} =
8843
- await this.mediaProperties.getCurrentConnectionInfo();
8911
+ const {connectionType, selectedCandidatePairChanges, numTransports} =
8912
+ error instanceof AddMediaFailed
8913
+ ? error
8914
+ : await this.mediaProperties.getCurrentConnectionInfo();
8844
8915
 
8845
8916
  const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors);
8846
8917
 
@@ -8857,6 +8928,7 @@ export default class Meeting extends StatelessWebexPlugin {
8857
8928
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8858
8929
  isMultistream: this.isMultistream,
8859
8930
  isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8931
+ iceTransportPolicy: this.addMediaData.iceTransportPolicy || 'all',
8860
8932
  signalingState:
8861
8933
  this.mediaProperties.webrtcMediaConnection?.multistreamConnection?.pc?.pc
8862
8934
  ?.signalingState ||
@@ -8872,6 +8944,7 @@ export default class Meeting extends StatelessWebexPlugin {
8872
8944
  ?.iceConnectionState ||
8873
8945
  this.mediaProperties.webrtcMediaConnection?.mediaConnection?.pc?.iceConnectionState ||
8874
8946
  'unknown',
8947
+ connectionType,
8875
8948
  ...reachabilityMetrics,
8876
8949
  ...iceCandidateErrors,
8877
8950
  iceCandidatesCount: this.iceCandidatesCount,
@@ -6,13 +6,14 @@ import {Enum} from '../constants';
6
6
  import {
7
7
  ClusterReachabilityResult,
8
8
  NatType,
9
+ Protocol,
9
10
  ReachabilityPeerConnectionEvents,
10
11
  } from './reachability.types';
11
12
  import {ReachabilityPeerConnection} from './reachabilityPeerConnection';
12
13
 
13
14
  // data for the Events.resultReady event
14
15
  export type ResultEventData = {
15
- protocol: 'udp' | 'tcp' | 'xtls';
16
+ protocol: Protocol;
16
17
  result: 'reachable' | 'unreachable' | 'untested';
17
18
  latencyInMilliseconds: number; // amount of time it took to get the ICE candidate
18
19
  clientMediaIPs?: string[];
@@ -20,7 +21,7 @@ export type ResultEventData = {
20
21
 
21
22
  // data for the Events.clientMediaIpsUpdated event
22
23
  export type ClientMediaIpsUpdatedEventData = {
23
- protocol: 'udp' | 'tcp' | 'xtls';
24
+ protocol: Protocol;
24
25
  clientMediaIPs: string[];
25
26
  };
26
27
 
@@ -25,6 +25,7 @@ import {
25
25
  TransportResultForBackend,
26
26
  GetClustersTrigger,
27
27
  NatType,
28
+ Protocol,
28
29
  } from './reachability.types';
29
30
  import {
30
31
  ClientMediaIpsUpdatedEventData,
@@ -569,6 +570,32 @@ export default class Reachability extends EventsScope {
569
570
  return unreachable;
570
571
  }
571
572
 
573
+ /**
574
+ * Checks if any cluster is reachable via the given protocol based on stored reachability results.
575
+ *
576
+ * @param {string} protocol - the protocol to check ('udp', 'tcp', or 'xtls')
577
+ * @returns {Promise<boolean>} true if at least one cluster has a 'reachable' result for the given protocol
578
+ */
579
+ async isAnyClusterReachableViaProtocol(protocol: Protocol): Promise<boolean> {
580
+ try {
581
+ // @ts-ignore
582
+ const resultsJson = await this.webex.boundedStorage.get(
583
+ this.namespace,
584
+ REACHABILITY.localStorageResult
585
+ );
586
+
587
+ const results: ReachabilityResults = JSON.parse(resultsJson);
588
+
589
+ return Object.values(results).some((result) => result[protocol]?.result === 'reachable');
590
+ } catch (e) {
591
+ LoggerProxy.logger.warn(
592
+ `Reachability:index#isAnyClusterReachableViaProtocol --> Error reading reachability data: ${e}`
593
+ );
594
+
595
+ return false;
596
+ }
597
+ }
598
+
572
599
  /**
573
600
  * Get list of all unreachable clusters
574
601
  * @returns {array} Unreachable clusters
@@ -701,7 +728,7 @@ export default class Reachability extends EventsScope {
701
728
  */
702
729
  protected getStatistics(
703
730
  results: Array<ClusterReachabilityResult & {isVideoMesh: boolean}>,
704
- protocol: 'udp' | 'tcp' | 'xtls',
731
+ protocol: Protocol,
705
732
  isVideoMesh: boolean
706
733
  ) {
707
734
  const values = results
@@ -0,0 +1,62 @@
1
+ import {assert} from '@webex/test-helper-chai';
2
+ import {AddMediaFailed, MediaConnectionTimedOutError} from '@webex/plugin-meetings/src/common/errors/webex-errors';
3
+
4
+ describe('MediaConnectionTimedOutError', () => {
5
+ [
6
+ {iceConnected: true, message: 'timeout with ice connected'},
7
+ {iceConnected: false, message: 'timeout without ice connected'},
8
+ ].forEach(({iceConnected, message}) => {
9
+ it(`stores message and iceConnected=${iceConnected}`, () => {
10
+ const error = new MediaConnectionTimedOutError(message, iceConnected);
11
+
12
+ assert.equal(error.message, message);
13
+ assert.equal(error.iceConnected, iceConnected);
14
+ assert.equal(error.name, 'MediaConnectionTimedOutError');
15
+ assert.instanceOf(error, Error);
16
+ });
17
+ });
18
+ });
19
+
20
+ describe('AddMediaFailed', () => {
21
+ it('stores all options properties', () => {
22
+ const cause = new Error('underlying');
23
+ const error = new AddMediaFailed({
24
+ cause,
25
+ connectionType: 'UDP',
26
+ selectedCandidatePairChanges: 3,
27
+ numTransports: 2,
28
+ iceConnected: true,
29
+ });
30
+
31
+ assert.equal(error.cause, cause);
32
+ assert.equal(error.connectionType, 'UDP');
33
+ assert.equal(error.selectedCandidatePairChanges, 3);
34
+ assert.equal(error.numTransports, 2);
35
+ assert.equal(error.iceConnected, true);
36
+ assert.equal(error.message, 'Failed to add media');
37
+ });
38
+
39
+ it('works with default empty options', () => {
40
+ const error = new AddMediaFailed();
41
+
42
+ assert.isUndefined(error.cause);
43
+ assert.isUndefined(error.connectionType);
44
+ assert.isUndefined(error.selectedCandidatePairChanges);
45
+ assert.isUndefined(error.numTransports);
46
+ assert.isUndefined(error.iceConnected);
47
+ });
48
+
49
+ describe('isDtlsHandshakeFailure', () => {
50
+ [
51
+ {iceConnected: true, expected: true},
52
+ {iceConnected: false, expected: false},
53
+ {iceConnected: undefined, expected: false},
54
+ ].forEach(({iceConnected, expected}) => {
55
+ it(`returns ${expected} when iceConnected=${iceConnected}`, () => {
56
+ const error = new AddMediaFailed({iceConnected});
57
+
58
+ assert.equal(error.isDtlsHandshakeFailure, expected);
59
+ });
60
+ });
61
+ });
62
+ });