From 3714c8f120482e42df688dfc0e7aa32bf1f4f45a Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 20 Nov 2025 22:35:05 +0530 Subject: [PATCH 1/8] fix(plugin-meetings): moved cluster reachability functionality to new class pr1 --- .../src/reachability/clusterReachability.ts | 453 ++++++++++++------ .../spec/reachability/clusterReachability.ts | 11 +- 2 files changed, 297 insertions(+), 167 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index d91de905588..3f24ae6c95e 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -6,7 +6,56 @@ import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; import EventsScope from '../common/events/events-scope'; import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants'; -import {ClusterReachabilityResult, NatType} from './reachability.types'; +import {ClusterReachabilityResult, NatType, TransportResult} from './reachability.types'; + +/** + * Processes an ICE candidate and updates the result with candidate information. + * Handles IP deduplication and latency tracking. + * + * @param {TransportResult} result - The protocol result object to update (e.g., result.udp) + * @param {number} latency - Latency in milliseconds + * @param {string|null} [publicIp] - Public IP address from ICE candidate + * @param {string|null} [serverIp] - Server IP address (subnet) + * @param {Set} [reachedSubnets] - Optional set to track reached subnets + * @returns {boolean} true if a new IP was added, false otherwise + */ +function processIceCandidateResult( + result: TransportResult, + latency: number, + publicIp?: string | null, + serverIp?: string | null, + reachedSubnets?: Set +): boolean { + let newIpAdded = false; + + if (result.latencyInMilliseconds === undefined) { + // First result for this protocol - store latency and mark as reachable + result.latencyInMilliseconds = latency; + result.result = 'reachable'; + if (publicIp) { + result.clientMediaIPs = [publicIp]; + newIpAdded = true; + } + } else if (publicIp) { + // Already have a result - just add new IPs (deduplicated) + if (result.clientMediaIPs) { + if (!result.clientMediaIPs.includes(publicIp)) { + result.clientMediaIPs.push(publicIp); + newIpAdded = true; + } + } else { + result.clientMediaIPs = [publicIp]; + newIpAdded = true; + } + } + + // Track reached subnets + if (serverIp && reachedSubnets) { + reachedSubnets.add(serverIp); + } + + return newIpAdded; +} // data for the Events.resultReady event export type ResultEventData = { @@ -35,31 +84,29 @@ export const Events = { export type Events = Enum; /** - * A class that handles reachability checks for a single cluster. - * It emits events from Events enum + * Handles RTCPeerConnection lifecycle and ICE candidate gathering for reachability checks. + * Does ALL the work: PeerConnection lifecycle, candidate processing, result management, and event emission. */ -export class ClusterReachability extends EventsScope { - private numUdpUrls: number; - private numTcpUrls: number; - private numXTlsUrls: number; - private result: ClusterReachabilityResult; +class ReachabilityPeerConnection extends EventsScope { + public numUdpUrls: number; + public numTcpUrls: number; + public numXTlsUrls: number; private pc?: RTCPeerConnection; - private defer: Defer; // this defer is resolved once reachability checks for this cluster are completed + private defer: Defer; private startTimestamp: number; private srflxIceCandidates: RTCIceCandidate[] = []; - public readonly isVideoMesh: boolean; - public readonly name; - public readonly reachedSubnets: Set = new Set(); + private clusterName: string; + private result: ClusterReachabilityResult; + private reachedSubnets: Set = new Set(); /** - * Constructor for ClusterReachability - * @param {string} name cluster name + * Constructor for ReachabilityPeerConnection * @param {ClusterNode} clusterInfo information about the media cluster + * @param {string} clusterName name of the cluster */ - constructor(name: string, clusterInfo: ClusterNode) { + constructor(clusterInfo: ClusterNode, clusterName: string) { super(); - this.name = name; - this.isVideoMesh = clusterInfo.isVideoMesh; + this.clusterName = clusterName; this.numUdpUrls = clusterInfo.udp.length; this.numTcpUrls = clusterInfo.tcp.length; this.numXTlsUrls = clusterInfo.xtls.length; @@ -82,7 +129,7 @@ export class ClusterReachability extends EventsScope { /** * Gets total elapsed time, can be called only after start() is called - * @returns {Number} Milliseconds + * @returns {number} Milliseconds */ private getElapsedTime() { return Math.round(performance.now() - this.startTimestamp); @@ -93,7 +140,7 @@ export class ClusterReachability extends EventsScope { * @param {ClusterNode} cluster * @returns {RTCConfiguration} peerConnectionConfig */ - private buildPeerConnectionConfig(cluster: ClusterNode): RTCConfiguration { + private static buildPeerConnectionConfig(cluster: ClusterNode): RTCConfiguration { const udpIceServers = cluster.udp.map((url) => ({ username: '', credential: '', @@ -129,18 +176,18 @@ export class ClusterReachability extends EventsScope { /** * Creates an RTCPeerConnection * @param {ClusterNode} clusterInfo information about the media cluster - * @returns {RTCPeerConnection} peerConnection + * @returns {RTCPeerConnection|undefined} peerConnection */ private createPeerConnection(clusterInfo: ClusterNode) { try { - const config = this.buildPeerConnectionConfig(clusterInfo); + const config = ReachabilityPeerConnection.buildPeerConnectionConfig(clusterInfo); const peerConnection = new RTCPeerConnection(config); return peerConnection; } catch (peerConnectionError) { LoggerProxy.logger.warn( - `Reachability:index#createPeerConnection --> Error creating peerConnection:`, + `Reachability:ReachabilityPeerConnection#createPeerConnection --> Error creating peerConnection:`, peerConnectionError ); @@ -148,16 +195,8 @@ export class ClusterReachability extends EventsScope { } } - /** - * @returns {ClusterReachabilityResult} reachability result for this cluster - */ - getResult() { - return this.result; - } - /** * Closes the peerConnection - * * @returns {void} */ private closePeerConnection() { @@ -168,134 +207,21 @@ export class ClusterReachability extends EventsScope { } } - /** - * Resolves the defer, indicating that reachability checks for this cluster are completed - * - * @returns {void} - */ - private finishReachabilityCheck() { - this.defer.resolve(); - } - - /** - * Aborts the cluster reachability checks by closing the peer connection - * - * @returns {void} - */ - public abort() { - const {CLOSED} = CONNECTION_STATE; - - if (this.pc.connectionState !== CLOSED) { - this.closePeerConnection(); - this.finishReachabilityCheck(); - } - } - - /** - * Adds public IP (client media IPs) - * @param {string} protocol - * @param {string} publicIP - * @returns {void} - */ - private addPublicIP(protocol: 'udp' | 'tcp' | 'xtls', publicIP?: string | null) { - const result = this.result[protocol]; - - if (publicIP) { - let ipAdded = false; - - if (result.clientMediaIPs) { - if (!result.clientMediaIPs.includes(publicIP)) { - result.clientMediaIPs.push(publicIP); - ipAdded = true; - } - } else { - result.clientMediaIPs = [publicIP]; - ipAdded = true; - } - - if (ipAdded) - this.emit( - { - file: 'clusterReachability', - function: 'addPublicIP', - }, - Events.clientMediaIpsUpdated, - { - protocol, - clientMediaIPs: result.clientMediaIPs, - } - ); - } - } - /** * Registers a listener for the iceGatheringStateChange event - * * @returns {void} */ private registerIceGatheringStateChangeListener() { this.pc.onicegatheringstatechange = () => { if (this.pc.iceGatheringState === ICE_GATHERING_STATE.COMPLETE) { this.closePeerConnection(); - this.finishReachabilityCheck(); + this.defer.resolve(); } }; } - /** - * Saves the latency in the result for the given protocol and marks it as reachable, - * emits the "resultReady" event if this is the first result for that protocol, - * emits the "clientMediaIpsUpdated" event if we already had a result and only found - * a new client IP - * - * @param {string} protocol - * @param {number} latency - * @param {string|null} [publicIp] - * @param {string|null} [serverIp] - * @returns {void} - */ - private saveResult( - protocol: 'udp' | 'tcp' | 'xtls', - latency: number, - publicIp?: string | null, - serverIp?: string | null - ) { - const result = this.result[protocol]; - - if (result.latencyInMilliseconds === undefined) { - LoggerProxy.logger.log( - // @ts-ignore - `Reachability:index#saveResult --> Successfully reached ${this.name} over ${protocol}: ${latency}ms` - ); - result.latencyInMilliseconds = latency; - result.result = 'reachable'; - if (publicIp) { - result.clientMediaIPs = [publicIp]; - } - - this.emit( - { - file: 'clusterReachability', - function: 'saveResult', - }, - Events.resultReady, - { - protocol, - ...result, - } - ); - } else { - this.addPublicIP(protocol, publicIp); - } - - if (serverIp) { - this.reachedSubnets.add(serverIp); - } - } - /** * Determines NAT Type. - * * @param {RTCIceCandidate} candidate * @returns {void} */ @@ -318,7 +244,7 @@ export class ClusterReachability extends EventsScope { // Found candidates with the same address and relatedPort, but different ports this.emit( { - file: 'clusterReachability', + file: 'reachabilityPeerConnection', function: 'determineNatType', }, Events.natTypeUpdated, @@ -373,15 +299,98 @@ export class ClusterReachability extends EventsScope { } /** - * Starts the process of doing UDP and TCP reachability checks on the media cluster. - * XTLS reachability checking is not supported. - * - * @returns {Promise} + * Saves the latency in the result for the given protocol and marks it as reachable, + * emits the "resultReady" event if this is the first result for that protocol, + * emits the "clientMediaIpsUpdated" event if we already had a result and only found + * a new client IP + * @param {string} protocol + * @param {number} latency + * @param {string|null} [publicIp] + * @param {string|null} [serverIp] + * @returns {void} + */ + private saveResult( + protocol: 'udp' | 'tcp' | 'xtls', + latency: number, + publicIp?: string | null, + serverIp?: string | null + ) { + const result = this.result[protocol]; + const isFirstResult = result.latencyInMilliseconds === undefined; + + const newIpAdded = processIceCandidateResult( + result, + latency, + publicIp, + serverIp, + this.reachedSubnets + ); + + if (serverIp) { + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'saveResult', + }, + 'reachedSubnets', + { + subnets: [serverIp], + } + ); + } + + if (isFirstResult) { + LoggerProxy.logger.log( + // @ts-ignore + `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${this.clusterName} over ${protocol}: ${latency}ms` + ); + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'saveResult', + }, + Events.resultReady, + { + protocol, + result: result.result, + latencyInMilliseconds: result.latencyInMilliseconds, + ...(result.clientMediaIPs && {clientMediaIPs: result.clientMediaIPs}), + } + ); + } else if (newIpAdded) { + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'saveResult', + }, + Events.clientMediaIpsUpdated, + { + protocol, + clientMediaIPs: result.clientMediaIPs, + } + ); + } + } + + /** + * Starts the process of gathering ICE candidates + * @returns {Promise} promise that's resolved once reachability checks are completed or timeout is reached + */ + private gatherIceCandidates() { + this.registerIceGatheringStateChangeListener(); + this.registerIceCandidateListener(); + + return this.defer.promise; + } + + /** + * Starts the process of doing UDP, TCP, and XTLS reachability checks. + * @returns {Promise} */ async start(): Promise { if (!this.pc) { LoggerProxy.logger.warn( - `Reachability:ClusterReachability#start --> Error: peerConnection is undefined` + `Reachability:ReachabilityPeerConnection#start --> Error: peerConnection is undefined` ); return this.result; @@ -413,21 +422,149 @@ export class ClusterReachability extends EventsScope { await gatherIceCandidatePromise; } catch (error) { - LoggerProxy.logger.warn(`Reachability:ClusterReachability#start --> Error: `, error); + LoggerProxy.logger.warn(`Reachability:ReachabilityPeerConnection#start --> Error: `, error); } return this.result; } /** - * Starts the process of gathering ICE candidates - * - * @returns {Promise} promise that's resolved once reachability checks for this cluster are completed or timeout is reached + * Aborts the cluster reachability checks by closing the peer connection + * @returns {void} */ - private gatherIceCandidates() { - this.registerIceGatheringStateChangeListener(); - this.registerIceCandidateListener(); + public abort() { + const {CLOSED} = CONNECTION_STATE; - return this.defer.promise; + if (this.pc && this.pc.connectionState !== CLOSED) { + this.closePeerConnection(); + this.defer.resolve(); + } + } +} + +/** + * A class that handles reachability checks for a single cluster. + * Creates and orchestrates a ReachabilityPeerConnection instance. + * Listens to events and emits them to consumers. + */ +export class ClusterReachability extends EventsScope { + private reachabilityPeerConnection?: ReachabilityPeerConnection; + public readonly isVideoMesh: boolean; + public readonly name; + public readonly reachedSubnets: Set = new Set(); + private result: ClusterReachabilityResult; + + /** + * Constructor for ClusterReachability + * @param {string} name cluster name + * @param {ClusterNode} clusterInfo information about the media cluster + */ + constructor(name: string, clusterInfo: ClusterNode) { + super(); + this.name = name; + this.isVideoMesh = clusterInfo.isVideoMesh; + this.result = { + udp: { + result: 'untested', + }, + tcp: { + result: 'untested', + }, + xtls: { + result: 'untested', + }, + }; + + this.reachabilityPeerConnection = new ReachabilityPeerConnection(clusterInfo, name); + + this.reachabilityPeerConnection.on('resultReady', (data) => { + const {protocol, ...resultData} = data; + this.result[protocol] = resultData; + this.emit( + { + file: 'clusterReachability', + function: 'onResultReady', + }, + Events.resultReady, + data + ); + }); + + this.reachabilityPeerConnection.on('clientMediaIpsUpdated', (data) => { + const {protocol, clientMediaIPs} = data; + this.result[protocol].clientMediaIPs = clientMediaIPs; + this.emit( + { + file: 'clusterReachability', + function: 'onClientMediaIpsUpdated', + }, + Events.clientMediaIpsUpdated, + data + ); + }); + + this.reachabilityPeerConnection.on('natTypeUpdated', (data) => { + this.emit( + { + file: 'clusterReachability', + function: 'onNatTypeUpdated', + }, + Events.natTypeUpdated, + data + ); + }); + + this.reachabilityPeerConnection.on('reachedSubnets', (data) => { + data.subnets.forEach((subnet) => { + this.reachedSubnets.add(subnet); + }); + }); + } + + /** + * @returns {ClusterReachabilityResult} reachability result for this cluster + */ + getResult() { + return this.result; + } + + /** + * Starts the process of doing UDP, TCP, and XTLS reachability checks on the media cluster. + * @returns {Promise} + */ + async start(): Promise { + const pc = this.reachabilityPeerConnection; + if (!pc) { + LoggerProxy.logger.warn( + `Reachability:ClusterReachability#start --> Error: reachabilityPeerConnection is undefined` + ); + + return this.result; + } + + // Initialize result based on URL availability + this.result.udp = { + result: pc.numUdpUrls > 0 ? 'unreachable' : 'untested', + }; + this.result.tcp = { + result: pc.numTcpUrls > 0 ? 'unreachable' : 'untested', + }; + this.result.xtls = { + result: pc.numXTlsUrls > 0 ? 'unreachable' : 'untested', + }; + + await pc.start(); + + return this.result; + } + + /** + * Aborts the cluster reachability checks + * @returns {void} + */ + public abort() { + if (this.reachabilityPeerConnection) { + this.reachabilityPeerConnection.abort(); + } } } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index 6fdb0066f60..c8565607911 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -17,7 +17,6 @@ describe('ClusterReachability', () => { let previousRTCPeerConnection; let clusterReachability; let fakePeerConnection; - let gatherIceCandidatesSpy; const emittedEvents: Record = { [Events.resultReady]: [], @@ -49,8 +48,6 @@ describe('ClusterReachability', () => { xtls: ['stun:xtls1.webex.com', 'stun:xtls2.webex.com:443'], }); - gatherIceCandidatesSpy = sinon.spy(clusterReachability, 'gatherIceCandidates'); - resetEmittedEvents(); clusterReachability.on(Events.resultReady, (data: ResultEventData) => { @@ -74,8 +71,8 @@ describe('ClusterReachability', () => { assert.instanceOf(clusterReachability, ClusterReachability); assert.equal(clusterReachability.name, 'testName'); assert.equal(clusterReachability.isVideoMesh, false); - assert.equal(clusterReachability.numUdpUrls, 2); - assert.equal(clusterReachability.numTcpUrls, 2); + assert.equal(clusterReachability.reachabilityPeerConnection.numUdpUrls, 2); + assert.equal(clusterReachability.reachabilityPeerConnection.numTcpUrls, 2); }); it('should create a peer connection with the right config', () => { @@ -162,10 +159,6 @@ describe('ClusterReachability', () => { assert.calledOnceWithExactly(fakePeerConnection.createOffer, {offerToReceiveAudio: true}); assert.calledOnce(fakePeerConnection.setLocalDescription); - // Make sure that gatherIceCandidates is called before setLocalDescription - // as setLocalDescription triggers the ICE gathering process - assert.isTrue(gatherIceCandidatesSpy.calledBefore(fakePeerConnection.setLocalDescription)); - clusterReachability.abort(); await promise; From c9e7970c4bf854a245ed957d4383b46a9d830491 Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 27 Nov 2025 16:13:44 +0530 Subject: [PATCH 2/8] fix(plugin-meetings): addressed review comments for new class reachabilityPeerConnection --- .../src/reachability/clusterReachability.ts | 498 +----------------- .../reachabilityPeerConnection.ts | 425 +++++++++++++++ .../spec/reachability/clusterReachability.ts | 424 ++++++++++----- 3 files changed, 740 insertions(+), 607 deletions(-) create mode 100644 packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 3f24ae6c95e..933ed665d3f 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -1,61 +1,13 @@ -import {Defer} from '@webex/common'; - import LoggerProxy from '../common/logs/logger-proxy'; import {ClusterNode} from './request'; -import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; import EventsScope from '../common/events/events-scope'; -import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants'; -import {ClusterReachabilityResult, NatType, TransportResult} from './reachability.types'; - -/** - * Processes an ICE candidate and updates the result with candidate information. - * Handles IP deduplication and latency tracking. - * - * @param {TransportResult} result - The protocol result object to update (e.g., result.udp) - * @param {number} latency - Latency in milliseconds - * @param {string|null} [publicIp] - Public IP address from ICE candidate - * @param {string|null} [serverIp] - Server IP address (subnet) - * @param {Set} [reachedSubnets] - Optional set to track reached subnets - * @returns {boolean} true if a new IP was added, false otherwise - */ -function processIceCandidateResult( - result: TransportResult, - latency: number, - publicIp?: string | null, - serverIp?: string | null, - reachedSubnets?: Set -): boolean { - let newIpAdded = false; - - if (result.latencyInMilliseconds === undefined) { - // First result for this protocol - store latency and mark as reachable - result.latencyInMilliseconds = latency; - result.result = 'reachable'; - if (publicIp) { - result.clientMediaIPs = [publicIp]; - newIpAdded = true; - } - } else if (publicIp) { - // Already have a result - just add new IPs (deduplicated) - if (result.clientMediaIPs) { - if (!result.clientMediaIPs.includes(publicIp)) { - result.clientMediaIPs.push(publicIp); - newIpAdded = true; - } - } else { - result.clientMediaIPs = [publicIp]; - newIpAdded = true; - } - } - - // Track reached subnets - if (serverIp && reachedSubnets) { - reachedSubnets.add(serverIp); - } - - return newIpAdded; -} +import {Enum} from '../constants'; +import {ClusterReachabilityResult, NatType} from './reachability.types'; +import { + ReachabilityPeerConnection, + ReachabilityPeerConnectionEvents, +} from './reachabilityPeerConnection'; // data for the Events.resultReady event export type ResultEventData = { @@ -83,376 +35,16 @@ export const Events = { export type Events = Enum; -/** - * Handles RTCPeerConnection lifecycle and ICE candidate gathering for reachability checks. - * Does ALL the work: PeerConnection lifecycle, candidate processing, result management, and event emission. - */ -class ReachabilityPeerConnection extends EventsScope { - public numUdpUrls: number; - public numTcpUrls: number; - public numXTlsUrls: number; - private pc?: RTCPeerConnection; - private defer: Defer; - private startTimestamp: number; - private srflxIceCandidates: RTCIceCandidate[] = []; - private clusterName: string; - private result: ClusterReachabilityResult; - private reachedSubnets: Set = new Set(); - - /** - * Constructor for ReachabilityPeerConnection - * @param {ClusterNode} clusterInfo information about the media cluster - * @param {string} clusterName name of the cluster - */ - constructor(clusterInfo: ClusterNode, clusterName: string) { - super(); - this.clusterName = clusterName; - this.numUdpUrls = clusterInfo.udp.length; - this.numTcpUrls = clusterInfo.tcp.length; - this.numXTlsUrls = clusterInfo.xtls.length; - - this.pc = this.createPeerConnection(clusterInfo); - - this.defer = new Defer(); - this.result = { - udp: { - result: 'untested', - }, - tcp: { - result: 'untested', - }, - xtls: { - result: 'untested', - }, - }; - } - - /** - * Gets total elapsed time, can be called only after start() is called - * @returns {number} Milliseconds - */ - private getElapsedTime() { - return Math.round(performance.now() - this.startTimestamp); - } - - /** - * Generate peerConnection config settings - * @param {ClusterNode} cluster - * @returns {RTCConfiguration} peerConnectionConfig - */ - private static buildPeerConnectionConfig(cluster: ClusterNode): RTCConfiguration { - const udpIceServers = cluster.udp.map((url) => ({ - username: '', - credential: '', - urls: [url], - })); - - // STUN servers are contacted only using UDP, so in order to test TCP reachability - // we pretend that Linus is a TURN server, because we can explicitly say "transport=tcp" in TURN urls. - // We then check for relay candidates to know if TURN-TCP worked (see registerIceCandidateListener()). - const tcpIceServers = cluster.tcp.map((urlString: string) => { - return { - username: 'webexturnreachuser', - credential: 'webexturnreachpwd', - urls: [convertStunUrlToTurn(urlString, 'tcp')], - }; - }); - - const turnTlsIceServers = cluster.xtls.map((urlString: string) => { - return { - username: 'webexturnreachuser', - credential: 'webexturnreachpwd', - urls: [convertStunUrlToTurnTls(urlString)], - }; - }); - - return { - iceServers: [...udpIceServers, ...tcpIceServers, ...turnTlsIceServers], - iceCandidatePoolSize: 0, - iceTransportPolicy: 'all', - }; - } - - /** - * Creates an RTCPeerConnection - * @param {ClusterNode} clusterInfo information about the media cluster - * @returns {RTCPeerConnection|undefined} peerConnection - */ - private createPeerConnection(clusterInfo: ClusterNode) { - try { - const config = ReachabilityPeerConnection.buildPeerConnectionConfig(clusterInfo); - - const peerConnection = new RTCPeerConnection(config); - - return peerConnection; - } catch (peerConnectionError) { - LoggerProxy.logger.warn( - `Reachability:ReachabilityPeerConnection#createPeerConnection --> Error creating peerConnection:`, - peerConnectionError - ); - - return undefined; - } - } - - /** - * Closes the peerConnection - * @returns {void} - */ - private closePeerConnection() { - if (this.pc) { - this.pc.onicecandidate = null; - this.pc.onicegatheringstatechange = null; - this.pc.close(); - } - } - - /** - * Registers a listener for the iceGatheringStateChange event - * @returns {void} - */ - private registerIceGatheringStateChangeListener() { - this.pc.onicegatheringstatechange = () => { - if (this.pc.iceGatheringState === ICE_GATHERING_STATE.COMPLETE) { - this.closePeerConnection(); - this.defer.resolve(); - } - }; - } - - /** - * Determines NAT Type. - * @param {RTCIceCandidate} candidate - * @returns {void} - */ - private determineNatType(candidate: RTCIceCandidate) { - this.srflxIceCandidates.push(candidate); - - if (this.srflxIceCandidates.length > 1) { - const portsFound: Record> = {}; - - this.srflxIceCandidates.forEach((c) => { - const key = `${c.address}:${c.relatedPort}`; - if (!portsFound[key]) { - portsFound[key] = new Set(); - } - portsFound[key].add(c.port); - }); - - Object.entries(portsFound).forEach(([, ports]) => { - if (ports.size > 1) { - // Found candidates with the same address and relatedPort, but different ports - this.emit( - { - file: 'reachabilityPeerConnection', - function: 'determineNatType', - }, - Events.natTypeUpdated, - { - natType: NatType.SymmetricNat, - } - ); - } - }); - } - } - - /** - * Registers a listener for the icecandidate event - * - * @returns {void} - */ - private registerIceCandidateListener() { - this.pc.onicecandidate = (e) => { - const TURN_TLS_PORT = 443; - const CANDIDATE_TYPES = { - SERVER_REFLEXIVE: 'srflx', - RELAY: 'relay', - }; - - const latencyInMilliseconds = this.getElapsedTime(); - - if (e.candidate) { - if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) { - let serverIp = null; - if ('url' in e.candidate) { - const stunServerUrlRegex = /stun:([\d.]+):\d+/; - - const match = (e.candidate as any).url.match(stunServerUrlRegex); - if (match) { - // eslint-disable-next-line prefer-destructuring - serverIp = match[1]; - } - } - - this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp); - - this.determineNatType(e.candidate); - } - - if (e.candidate.type === CANDIDATE_TYPES.RELAY) { - const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp'; - this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address); - } - } - }; - } - - /** - * Saves the latency in the result for the given protocol and marks it as reachable, - * emits the "resultReady" event if this is the first result for that protocol, - * emits the "clientMediaIpsUpdated" event if we already had a result and only found - * a new client IP - * @param {string} protocol - * @param {number} latency - * @param {string|null} [publicIp] - * @param {string|null} [serverIp] - * @returns {void} - */ - private saveResult( - protocol: 'udp' | 'tcp' | 'xtls', - latency: number, - publicIp?: string | null, - serverIp?: string | null - ) { - const result = this.result[protocol]; - const isFirstResult = result.latencyInMilliseconds === undefined; - - const newIpAdded = processIceCandidateResult( - result, - latency, - publicIp, - serverIp, - this.reachedSubnets - ); - - if (serverIp) { - this.emit( - { - file: 'reachabilityPeerConnection', - function: 'saveResult', - }, - 'reachedSubnets', - { - subnets: [serverIp], - } - ); - } - - if (isFirstResult) { - LoggerProxy.logger.log( - // @ts-ignore - `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${this.clusterName} over ${protocol}: ${latency}ms` - ); - this.emit( - { - file: 'reachabilityPeerConnection', - function: 'saveResult', - }, - Events.resultReady, - { - protocol, - result: result.result, - latencyInMilliseconds: result.latencyInMilliseconds, - ...(result.clientMediaIPs && {clientMediaIPs: result.clientMediaIPs}), - } - ); - } else if (newIpAdded) { - this.emit( - { - file: 'reachabilityPeerConnection', - function: 'saveResult', - }, - Events.clientMediaIpsUpdated, - { - protocol, - clientMediaIPs: result.clientMediaIPs, - } - ); - } - } - - /** - * Starts the process of gathering ICE candidates - * @returns {Promise} promise that's resolved once reachability checks are completed or timeout is reached - */ - private gatherIceCandidates() { - this.registerIceGatheringStateChangeListener(); - this.registerIceCandidateListener(); - - return this.defer.promise; - } - - /** - * Starts the process of doing UDP, TCP, and XTLS reachability checks. - * @returns {Promise} - */ - async start(): Promise { - if (!this.pc) { - LoggerProxy.logger.warn( - `Reachability:ReachabilityPeerConnection#start --> Error: peerConnection is undefined` - ); - - return this.result; - } - - // Initialize this.result as saying that nothing is reachable. - // It will get updated as we go along and successfully gather ICE candidates. - this.result.udp = { - result: this.numUdpUrls > 0 ? 'unreachable' : 'untested', - }; - this.result.tcp = { - result: this.numTcpUrls > 0 ? 'unreachable' : 'untested', - }; - this.result.xtls = { - result: this.numXTlsUrls > 0 ? 'unreachable' : 'untested', - }; - - try { - const offer = await this.pc.createOffer({offerToReceiveAudio: true}); - - this.startTimestamp = performance.now(); - - // Set up the state change listeners before triggering the ICE gathering - const gatherIceCandidatePromise = this.gatherIceCandidates(); - - // not awaiting the next call on purpose, because we're not sending the offer anywhere and there won't be any answer - // we just need to make this call to trigger the ICE gathering process - this.pc.setLocalDescription(offer); - - await gatherIceCandidatePromise; - } catch (error) { - LoggerProxy.logger.warn(`Reachability:ReachabilityPeerConnection#start --> Error: `, error); - } - - return this.result; - } - - /** - * Aborts the cluster reachability checks by closing the peer connection - * @returns {void} - */ - public abort() { - const {CLOSED} = CONNECTION_STATE; - - if (this.pc && this.pc.connectionState !== CLOSED) { - this.closePeerConnection(); - this.defer.resolve(); - } - } -} - /** * A class that handles reachability checks for a single cluster. * Creates and orchestrates a ReachabilityPeerConnection instance. * Listens to events and emits them to consumers. */ export class ClusterReachability extends EventsScope { - private reachabilityPeerConnection?: ReachabilityPeerConnection; + private reachabilityPeerConnection: ReachabilityPeerConnection; public readonly isVideoMesh: boolean; public readonly name; public readonly reachedSubnets: Set = new Set(); - private result: ClusterReachabilityResult; /** * Constructor for ClusterReachability @@ -463,23 +55,10 @@ export class ClusterReachability extends EventsScope { super(); this.name = name; this.isVideoMesh = clusterInfo.isVideoMesh; - this.result = { - udp: { - result: 'untested', - }, - tcp: { - result: 'untested', - }, - xtls: { - result: 'untested', - }, - }; this.reachabilityPeerConnection = new ReachabilityPeerConnection(clusterInfo, name); - this.reachabilityPeerConnection.on('resultReady', (data) => { - const {protocol, ...resultData} = data; - this.result[protocol] = resultData; + this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.resultReady, (data) => { this.emit( { file: 'clusterReachability', @@ -490,20 +69,21 @@ export class ClusterReachability extends EventsScope { ); }); - this.reachabilityPeerConnection.on('clientMediaIpsUpdated', (data) => { - const {protocol, clientMediaIPs} = data; - this.result[protocol].clientMediaIPs = clientMediaIPs; - this.emit( - { - file: 'clusterReachability', - function: 'onClientMediaIpsUpdated', - }, - Events.clientMediaIpsUpdated, - data - ); - }); + this.reachabilityPeerConnection.on( + ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, + (data) => { + this.emit( + { + file: 'clusterReachability', + function: 'onClientMediaIpsUpdated', + }, + Events.clientMediaIpsUpdated, + data + ); + } + ); - this.reachabilityPeerConnection.on('natTypeUpdated', (data) => { + this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.natTypeUpdated, (data) => { this.emit( { file: 'clusterReachability', @@ -514,7 +94,7 @@ export class ClusterReachability extends EventsScope { ); }); - this.reachabilityPeerConnection.on('reachedSubnets', (data) => { + this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.reachedSubnets, (data) => { data.subnets.forEach((subnet) => { this.reachedSubnets.add(subnet); }); @@ -524,8 +104,8 @@ export class ClusterReachability extends EventsScope { /** * @returns {ClusterReachabilityResult} reachability result for this cluster */ - getResult() { - return this.result; + getResult(): ClusterReachabilityResult { + return this.reachabilityPeerConnection.getResult(); } /** @@ -533,29 +113,9 @@ export class ClusterReachability extends EventsScope { * @returns {Promise} */ async start(): Promise { - const pc = this.reachabilityPeerConnection; - if (!pc) { - LoggerProxy.logger.warn( - `Reachability:ClusterReachability#start --> Error: reachabilityPeerConnection is undefined` - ); - - return this.result; - } - - // Initialize result based on URL availability - this.result.udp = { - result: pc.numUdpUrls > 0 ? 'unreachable' : 'untested', - }; - this.result.tcp = { - result: pc.numTcpUrls > 0 ? 'unreachable' : 'untested', - }; - this.result.xtls = { - result: pc.numXTlsUrls > 0 ? 'unreachable' : 'untested', - }; - - await pc.start(); + await this.reachabilityPeerConnection.start(); - return this.result; + return this.getResult(); } /** @@ -563,8 +123,6 @@ export class ClusterReachability extends EventsScope { * @returns {void} */ public abort() { - if (this.reachabilityPeerConnection) { - this.reachabilityPeerConnection.abort(); - } + this.reachabilityPeerConnection.abort(); } } diff --git a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts new file mode 100644 index 00000000000..1a546fe462b --- /dev/null +++ b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts @@ -0,0 +1,425 @@ +import {Defer} from '@webex/common'; + +import LoggerProxy from '../common/logs/logger-proxy'; +import {ClusterNode} from './request'; +import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; +import EventsScope from '../common/events/events-scope'; + +import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants'; +import {ClusterReachabilityResult, NatType} from './reachability.types'; + +/** + * Events emitted by ReachabilityPeerConnection + */ +export const ReachabilityPeerConnectionEvents = { + resultReady: 'resultReady', // emitted when successfully reached over a protocol + clientMediaIpsUpdated: 'clientMediaIpsUpdated', // emitted when new public IPs are found + natTypeUpdated: 'natTypeUpdated', // emitted when NAT type is determined + reachedSubnets: 'reachedSubnets', // emitted when server IP (subnet) is discovered +} as const; + +export type ReachabilityPeerConnectionEvents = Enum; + +/** + * A class to handle RTCPeerConnection lifecycle and ICE candidate gathering for reachability checks. + * It will do all the work like PeerConnection lifecycle, candidate processing, result management, and event emission. + */ +export class ReachabilityPeerConnection extends EventsScope { + public numUdpUrls: number; + public numTcpUrls: number; + public numXTlsUrls: number; + private pc?: RTCPeerConnection; + private defer: Defer; + private startTimestamp: number; + private srflxIceCandidates: RTCIceCandidate[] = []; + private clusterName: string; + private result: ClusterReachabilityResult; + private emittedSubnets: Set = new Set(); + + /** + * Constructor for ReachabilityPeerConnection + * @param {ClusterNode} clusterInfo information about the media cluster + * @param {string} clusterName name of the cluster + */ + constructor(clusterInfo: ClusterNode, clusterName: string) { + super(); + this.clusterName = clusterName; + this.numUdpUrls = clusterInfo.udp.length; + this.numTcpUrls = clusterInfo.tcp.length; + this.numXTlsUrls = clusterInfo.xtls.length; + + this.pc = this.createPeerConnection(clusterInfo); + + this.defer = new Defer(); + this.result = { + udp: { + result: 'untested', + }, + tcp: { + result: 'untested', + }, + xtls: { + result: 'untested', + }, + }; + } + + /** + * Gets total elapsed time, can be called only after start() is called + * @returns {number} Milliseconds + */ + private getElapsedTime() { + return Math.round(performance.now() - this.startTimestamp); + } + + /** + * Generate peerConnection config settings + * @param {ClusterNode} cluster + * @returns {RTCConfiguration} peerConnectionConfig + */ + private static buildPeerConnectionConfig(cluster: ClusterNode): RTCConfiguration { + const udpIceServers = cluster.udp.map((url) => ({ + username: '', + credential: '', + urls: [url], + })); + + // STUN servers are contacted only using UDP, so in order to test TCP reachability + // we pretend that Linus is a TURN server, because we can explicitly say "transport=tcp" in TURN urls. + // We then check for relay candidates to know if TURN-TCP worked (see registerIceCandidateListener()). + const tcpIceServers = cluster.tcp.map((urlString: string) => { + return { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: [convertStunUrlToTurn(urlString, 'tcp')], + }; + }); + + const turnTlsIceServers = cluster.xtls.map((urlString: string) => { + return { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: [convertStunUrlToTurnTls(urlString)], + }; + }); + + return { + iceServers: [...udpIceServers, ...tcpIceServers, ...turnTlsIceServers], + iceCandidatePoolSize: 0, + iceTransportPolicy: 'all', + }; + } + + /** + * Creates an RTCPeerConnection + * @param {ClusterNode} clusterInfo information about the media cluster + * @returns {RTCPeerConnection|undefined} peerConnection + */ + private createPeerConnection(clusterInfo: ClusterNode) { + try { + const config = ReachabilityPeerConnection.buildPeerConnectionConfig(clusterInfo); + + const peerConnection = new RTCPeerConnection(config); + + return peerConnection; + } catch (peerConnectionError) { + LoggerProxy.logger.warn( + `Reachability:ReachabilityPeerConnection#createPeerConnection --> Error creating peerConnection:`, + peerConnectionError + ); + + return undefined; + } + } + + /** + * @returns {ClusterReachabilityResult} reachability result for this instance + */ + getResult() { + return this.result; + } + + /** + * Closes the peerConnection + * @returns {void} + */ + private closePeerConnection() { + if (this.pc) { + this.pc.onicecandidate = null; + this.pc.onicegatheringstatechange = null; + this.pc.close(); + } + } + + /** + * Resolves the defer, indicating that reachability checks for this cluster are completed + * + * @returns {void} + */ + private finishReachabilityCheck() { + this.defer.resolve(); + } + + /** + * Aborts the cluster reachability checks by closing the peer connection + * + * @returns {void} + */ + public abort() { + const {CLOSED} = CONNECTION_STATE; + + if (this.pc && this.pc.connectionState !== CLOSED) { + this.closePeerConnection(); + this.finishReachabilityCheck(); + } + } + + /** + * Adds public IP (client media IPs) + * @param {string} protocol + * @param {string} publicIp + * @returns {void} + */ + private addPublicIp(protocol: 'udp' | 'tcp' | 'xtls', publicIp?: string | null) { + const result = this.result[protocol]; + + if (publicIp) { + let ipAdded = false; + + if (result.clientMediaIPs) { + if (!result.clientMediaIPs.includes(publicIp)) { + result.clientMediaIPs.push(publicIp); + ipAdded = true; + } + } else { + result.clientMediaIPs = [publicIp]; + ipAdded = true; + } + + if (ipAdded) { + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'addPublicIp', + }, + ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, + { + protocol, + clientMediaIPs: result.clientMediaIPs, + } + ); + } + } + } + + /** + * Registers a listener for the iceGatheringStateChange event + * + * @returns {void} + */ + private registerIceGatheringStateChangeListener() { + this.pc.onicegatheringstatechange = () => { + if (this.pc.iceGatheringState === ICE_GATHERING_STATE.COMPLETE) { + this.closePeerConnection(); + this.defer.resolve(); + } + }; + } + + /** + * Saves the latency in the result for the given protocol and marks it as reachable, + * emits the "resultReady" event if this is the first result for that protocol, + * emits the "clientMediaIpsUpdated" event if we already had a result and only found + * a new client IP + * + * @param {string} protocol + * @param {number} latency + * @param {string|null} [publicIp] + * @param {string|null} [serverIp] + * @returns {void} + */ + private saveResult( + protocol: 'udp' | 'tcp' | 'xtls', + latency: number, + publicIp?: string | null, + serverIp?: string | null + ) { + const result = this.result[protocol]; + + if (result.latencyInMilliseconds === undefined) { + LoggerProxy.logger.log( + // @ts-ignore + `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${this.clusterName} over ${protocol}: ${latency}ms` + ); + result.latencyInMilliseconds = latency; + result.result = 'reachable'; + if (publicIp) { + result.clientMediaIPs = [publicIp]; + } + + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'saveResult', + }, + ReachabilityPeerConnectionEvents.resultReady, + { + protocol, + ...result, + } + ); + } else { + this.addPublicIp(protocol, publicIp); + } + + if (serverIp) { + if (!this.emittedSubnets.has(serverIp)) { + this.emittedSubnets.add(serverIp); + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'saveResult', + }, + ReachabilityPeerConnectionEvents.reachedSubnets, + { + subnets: [serverIp], + } + ); + } + } + } + + /** + * Determines NAT Type. + * @param {RTCIceCandidate} candidate + * @returns {void} + */ + private determineNatType(candidate: RTCIceCandidate) { + this.srflxIceCandidates.push(candidate); + + if (this.srflxIceCandidates.length > 1) { + const portsFound: Record> = {}; + + this.srflxIceCandidates.forEach((c) => { + const key = `${c.address}:${c.relatedPort}`; + if (!portsFound[key]) { + portsFound[key] = new Set(); + } + portsFound[key].add(c.port); + }); + + Object.entries(portsFound).forEach(([, ports]) => { + if (ports.size > 1) { + // Found candidates with the same address and relatedPort, but different ports + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'determineNatType', + }, + ReachabilityPeerConnectionEvents.natTypeUpdated, + { + natType: NatType.SymmetricNat, + } + ); + } + }); + } + } + + /** + * Registers a listener for the icecandidate event + * + * @returns {void} + */ + private registerIceCandidateListener() { + this.pc.onicecandidate = (e) => { + const TURN_TLS_PORT = 443; + const CANDIDATE_TYPES = { + SERVER_REFLEXIVE: 'srflx', + RELAY: 'relay', + }; + + const latencyInMilliseconds = this.getElapsedTime(); + + if (e.candidate) { + if (e.candidate.type === CANDIDATE_TYPES.SERVER_REFLEXIVE) { + let serverIp = null; + if ('url' in e.candidate) { + const stunServerUrlRegex = /stun:([\d.]+):\d+/; + + const match = (e.candidate as any).url.match(stunServerUrlRegex); + if (match) { + // eslint-disable-next-line prefer-destructuring + serverIp = match[1]; + } + } + + this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp); + + this.determineNatType(e.candidate); + } + + if (e.candidate.type === CANDIDATE_TYPES.RELAY) { + const protocol = e.candidate.port === TURN_TLS_PORT ? 'xtls' : 'tcp'; + this.saveResult(protocol, latencyInMilliseconds, null, e.candidate.address); + } + } + }; + } + + /** + * Starts the process of doing UDP, TCP, and XTLS reachability checks. + * @returns {Promise} + */ + async start(): Promise { + if (!this.pc) { + LoggerProxy.logger.warn( + `Reachability:ReachabilityPeerConnection#start --> Error: peerConnection is undefined` + ); + + return this.result; + } + + // Initialize this.result as saying that nothing is reachable. + // It will get updated as we go along and successfully gather ICE candidates. + this.result.udp = { + result: this.numUdpUrls > 0 ? 'unreachable' : 'untested', + }; + this.result.tcp = { + result: this.numTcpUrls > 0 ? 'unreachable' : 'untested', + }; + this.result.xtls = { + result: this.numXTlsUrls > 0 ? 'unreachable' : 'untested', + }; + + try { + const offer = await this.pc.createOffer({offerToReceiveAudio: true}); + + this.startTimestamp = performance.now(); + + // Set up the state change listeners before triggering the ICE gathering + const gatherIceCandidatePromise = this.gatherIceCandidates(); + + // not awaiting the next call on purpose, because we're not sending the offer anywhere and there won't be any answer + // we just need to make this call to trigger the ICE gathering process + this.pc.setLocalDescription(offer); + + await gatherIceCandidatePromise; + } catch (error) { + LoggerProxy.logger.warn(`Reachability:ReachabilityPeerConnection#start --> Error: `, error); + } + + return this.result; + } + + /** + * Starts the process of gathering ICE candidates + * @returns {Promise} promise that's resolved once reachability checks are completed or timeout is reached + */ + private gatherIceCandidates() { + this.registerIceGatheringStateChangeListener(); + this.registerIceCandidateListener(); + + return this.defer.promise; + } +} diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index c8565607911..d653e633562 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -1,22 +1,21 @@ import {assert} from '@webex/test-helper-chai'; -import MockWebex from '@webex/test-helper-mock-webex'; import sinon from 'sinon'; import testUtils from '../../../utils/testUtils'; -// packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts import { ClusterReachability, ResultEventData, Events, ClientMediaIpsUpdatedEventData, NatTypeUpdatedEventData, -} from '@webex/plugin-meetings/src/reachability/clusterReachability'; // replace with actual path -import { NatType } from 'packages/@webex/plugin-meetings/dist/reachability/reachability.types'; +} from '@webex/plugin-meetings/src/reachability/clusterReachability'; +import {ReachabilityPeerConnection} from '@webex/plugin-meetings/src/reachability/reachabilityPeerConnection'; describe('ClusterReachability', () => { let previousRTCPeerConnection; let clusterReachability; let fakePeerConnection; + let gatherIceCandidatesSpy; const emittedEvents: Record = { [Events.resultReady]: [], @@ -48,6 +47,8 @@ describe('ClusterReachability', () => { xtls: ['stun:xtls1.webex.com', 'stun:xtls2.webex.com:443'], }); + gatherIceCandidatesSpy = sinon.spy(clusterReachability.reachabilityPeerConnection as any, 'gatherIceCandidates'); + resetEmittedEvents(); clusterReachability.on(Events.resultReady, (data: ResultEventData) => { @@ -67,60 +68,16 @@ describe('ClusterReachability', () => { global.RTCPeerConnection = previousRTCPeerConnection; }); - it('should create an instance correctly', () => { + it('should create an instance correctly with provided cluster info', () => { assert.instanceOf(clusterReachability, ClusterReachability); assert.equal(clusterReachability.name, 'testName'); assert.equal(clusterReachability.isVideoMesh, false); - assert.equal(clusterReachability.reachabilityPeerConnection.numUdpUrls, 2); - assert.equal(clusterReachability.reachabilityPeerConnection.numTcpUrls, 2); - }); - - it('should create a peer connection with the right config', () => { - assert.calledOnceWithExactly(global.RTCPeerConnection, { - iceServers: [ - {username: '', credential: '', urls: ['stun:udp1']}, - {username: '', credential: '', urls: ['stun:udp2']}, - { - username: 'webexturnreachuser', - credential: 'webexturnreachpwd', - urls: ['turn:tcp1.webex.com?transport=tcp'], - }, - { - username: 'webexturnreachuser', - credential: 'webexturnreachpwd', - urls: ['turn:tcp2.webex.com:5004?transport=tcp'], - }, - { - username: 'webexturnreachuser', - credential: 'webexturnreachpwd', - urls: ['turns:xtls1.webex.com?transport=tcp'], - }, - { - username: 'webexturnreachuser', - credential: 'webexturnreachpwd', - urls: ['turns:xtls2.webex.com:443?transport=tcp'], - }, - ], - iceCandidatePoolSize: 0, - iceTransportPolicy: 'all', - }); + assert.instanceOf(clusterReachability.reachabilityPeerConnection, ReachabilityPeerConnection); }); - it('should create a peer connection with the right config even if lists of urls are empty', () => { - (global.RTCPeerConnection as any).resetHistory(); - - clusterReachability = new ClusterReachability('testName', { - isVideoMesh: false, - udp: [], - tcp: [], - xtls: [], - }); - - assert.calledOnceWithExactly(global.RTCPeerConnection, { - iceServers: [], - iceCandidatePoolSize: 0, - iceTransportPolicy: 'all', - }); + it('should initialize reachedSubnets as empty set', () => { + assert.instanceOf(clusterReachability.reachedSubnets, Set); + assert.equal(clusterReachability.reachedSubnets.size, 0); }); it('returns correct results before start() is called', () => { @@ -135,7 +92,7 @@ describe('ClusterReachability', () => { assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated], []); }); - describe('#start', () => { + describe('#event relaying', () => { let clock; beforeEach(() => { @@ -146,6 +103,224 @@ describe('ClusterReachability', () => { clock.restore(); }); + it('relays resultReady event from ReachabilityPeerConnection', async () => { + const promise = clusterReachability.start(); + + await testUtils.flushPromises(); + + // Simulate RPC emitting resultReady + await clock.tickAsync(50); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}}); + + // ClusterReachability should relay the event + assert.equal(emittedEvents[Events.resultReady].length, 1); + assert.deepEqual(emittedEvents[Events.resultReady][0], { + protocol: 'udp', + result: 'reachable', + latencyInMilliseconds: 50, + clientMediaIPs: ['somePublicIp1'], + }); + + clusterReachability.abort(); + await promise; + }); + + it('relays clientMediaIpsUpdated event from ReachabilityPeerConnection', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}}); + + // First IP found - only resultReady emitted + assert.equal(emittedEvents[Events.resultReady].length, 1); + assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 0); + resetEmittedEvents(); + + // New IP found - should emit clientMediaIpsUpdated + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp2'}}); + + assert.equal(emittedEvents[Events.resultReady].length, 0); + assert.equal(emittedEvents[Events.clientMediaIpsUpdated].length, 1); + assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated][0], { + protocol: 'udp', + clientMediaIPs: ['somePublicIp1', 'somePublicIp2'], + }); + + clusterReachability.abort(); + await promise; + }); + + it('relays natTypeUpdated event from ReachabilityPeerConnection', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', port: 1000, relatedPort: 3478}}); + + // No NAT detection yet (only 1 candidate) + assert.equal(emittedEvents[Events.natTypeUpdated].length, 0); + + // Second candidate with same address but different port - indicates symmetric NAT + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1', port: 2000, relatedPort: 3478}}); + + assert.equal(emittedEvents[Events.natTypeUpdated].length, 1); + assert.deepEqual(emittedEvents[Events.natTypeUpdated][0], { + natType: 'symmetric-nat', + }); + + clusterReachability.abort(); + await promise; + }); + }); + + describe('#subnet collection', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('collects reached subnets from ReachabilityPeerConnection events', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:192.168.1.1:5004'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:10.0.0.1:5004'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'relay.server.ip'}}); + + clusterReachability.abort(); + await promise; + + assert.equal(clusterReachability.reachedSubnets.size, 3); + assert.isTrue(clusterReachability.reachedSubnets.has('192.168.1.1')); + assert.isTrue(clusterReachability.reachedSubnets.has('10.0.0.1')); + assert.isTrue(clusterReachability.reachedSubnets.has('relay.server.ip')); + }); + + it('stores only unique subnet addresses', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:192.168.1.1:5004'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:192.168.1.1:9000'}}); + fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '192.168.1.1'}}); + + clusterReachability.abort(); + await promise; + + // Should have only 1 unique subnet + assert.equal(clusterReachability.reachedSubnets.size, 1); + assert.isTrue(clusterReachability.reachedSubnets.has('192.168.1.1')); + }); + + it('accumulates subnets from multiple candidates', async () => { + const promise = clusterReachability.start(); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:192.168.1.1:5004'}}); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:10.0.0.1:5004'}}); + + await clock.tickAsync(10); + fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '172.16.0.1'}}); + + clusterReachability.abort(); + await promise; + + assert.equal(clusterReachability.reachedSubnets.size, 3); + assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['192.168.1.1', '10.0.0.1', '172.16.0.1']); + }); + }); + + describe('#delegation', () => { + it('delegates getResult() to ReachabilityPeerConnection', () => { + const rpcGetResultStub = sinon.stub(clusterReachability.reachabilityPeerConnection, 'getResult').returns({ + udp: {result: 'reachable', latencyInMilliseconds: 42}, + tcp: {result: 'unreachable'}, + xtls: {result: 'untested'}, + }); + + const result = clusterReachability.getResult(); + + assert.calledOnce(rpcGetResultStub); + assert.equal(result.udp.result, 'reachable'); + assert.equal(result.udp.latencyInMilliseconds, 42); + }); + + it('delegates abort() to ReachabilityPeerConnection', () => { + const rpcAbortStub = sinon.stub(clusterReachability.reachabilityPeerConnection, 'abort'); + + clusterReachability.abort(); + + assert.calledOnce(rpcAbortStub); + }); + + it('delegates start() to ReachabilityPeerConnection and returns result', async () => { + const expectedResult = { + udp: {result: 'reachable'}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + }; + + const rpcStartStub = sinon.stub(clusterReachability.reachabilityPeerConnection, 'start').resolves(); + const rpcGetResultStub = sinon.stub(clusterReachability.reachabilityPeerConnection, 'getResult').returns(expectedResult); + + const result = await clusterReachability.start(); + + assert.calledOnce(rpcStartStub); + assert.calledOnce(rpcGetResultStub); + assert.deepEqual(result, expectedResult); + }); + }); + + describe('#WebRTC peer connection setup', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('should create a peer connection with the right config', () => { + assert.calledOnceWithExactly(global.RTCPeerConnection, { + iceServers: [ + {username: '', credential: '', urls: ['stun:udp1']}, + {username: '', credential: '', urls: ['stun:udp2']}, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turn:tcp1.webex.com?transport=tcp'], + }, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turn:tcp2.webex.com:5004?transport=tcp'], + }, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turns:xtls1.webex.com?transport=tcp'], + }, + { + username: 'webexturnreachuser', + credential: 'webexturnreachpwd', + urls: ['turns:xtls2.webex.com:443?transport=tcp'], + }, + ], + iceCandidatePoolSize: 0, + iceTransportPolicy: 'all', + }); + }); + it('should initiate the ICE gathering process', async () => { const promise = clusterReachability.start(); @@ -159,6 +334,10 @@ describe('ClusterReachability', () => { assert.calledOnceWithExactly(fakePeerConnection.createOffer, {offerToReceiveAudio: true}); assert.calledOnce(fakePeerConnection.setLocalDescription); + // Make sure that gatherIceCandidates is called before setLocalDescription + // as setLocalDescription triggers the ICE gathering process + assert.isTrue(gatherIceCandidatesSpy.calledBefore(fakePeerConnection.setLocalDescription)); + clusterReachability.abort(); await promise; @@ -167,6 +346,40 @@ describe('ClusterReachability', () => { assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated], []); }); + it('resolves when ICE gathering is completed', async () => { + const promise = clusterReachability.start(); + + await testUtils.flushPromises(); + + fakePeerConnection.iceGatheringState = 'complete'; + fakePeerConnection.onicegatheringstatechange(); + await promise; + + assert.deepEqual(clusterReachability.getResult(), { + udp: {result: 'unreachable'}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + }); + }); + + it('resolves with the right result when ICE gathering is completed', async () => { + const promise = clusterReachability.start(); + + // send 1 candidate + await clock.tickAsync(30); + fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}}); + + fakePeerConnection.iceGatheringState = 'complete'; + fakePeerConnection.onicegatheringstatechange(); + await promise; + + assert.deepEqual(clusterReachability.getResult(), { + udp: {result: 'reachable', latencyInMilliseconds: 30, clientMediaIPs: ['somePublicIp1']}, + tcp: {result: 'unreachable'}, + xtls: {result: 'unreachable'}, + }); + }); + it('resolves and returns correct results when aborted before it gets any candidates', async () => { const promise = clusterReachability.start(); @@ -209,39 +422,17 @@ describe('ClusterReachability', () => { xtls: {result: 'unreachable'}, }); }); + }); - it('resolves when ICE gathering is completed', async () => { - const promise = clusterReachability.start(); - - await testUtils.flushPromises(); - - fakePeerConnection.iceGatheringState = 'complete'; - fakePeerConnection.onicegatheringstatechange(); - await promise; + describe('#latency and candidate handling', () => { + let clock; - assert.deepEqual(clusterReachability.getResult(), { - udp: {result: 'unreachable'}, - tcp: {result: 'unreachable'}, - xtls: {result: 'unreachable'}, - }); + beforeEach(() => { + clock = sinon.useFakeTimers(); }); - it('resolves with the right result when ICE gathering is completed', async () => { - const promise = clusterReachability.start(); - - // send 1 candidate - await clock.tickAsync(30); - fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}}); - - fakePeerConnection.iceGatheringState = 'complete'; - fakePeerConnection.onicegatheringstatechange(); - await promise; - - assert.deepEqual(clusterReachability.getResult(), { - udp: {result: 'reachable', latencyInMilliseconds: 30, clientMediaIPs: ['somePublicIp1']}, - tcp: {result: 'unreachable'}, - xtls: {result: 'unreachable'}, - }); + afterEach(() => { + clock.restore(); }); it('should store latency only for the first srflx candidate, but IPs from all of them', async () => { @@ -250,17 +441,16 @@ describe('ClusterReachability', () => { await clock.tickAsync(10); fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp1'}}); - // generate more candidates - await clock.tickAsync(10); + await clock.tickAsync(50); // total elapsed time: 60 fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp2'}}); - await clock.tickAsync(10); + await clock.tickAsync(10); // total elapsed time: 70 fakePeerConnection.onicecandidate({candidate: {type: 'srflx', address: 'somePublicIp3'}}); clusterReachability.abort(); await promise; - // latency should be from only the first candidates, but the clientMediaIps should be from all UDP candidates (not TCP) + // latency should be from only the first candidates, but the clientMediaIps should be from all UDP candidates assert.deepEqual(clusterReachability.getResult(), { udp: { result: 'reachable', @@ -276,19 +466,18 @@ describe('ClusterReachability', () => { const promise = clusterReachability.start(); await clock.tickAsync(10); - fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp1'}}); - - // generate more candidates - await clock.tickAsync(10); - fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp2'}}); + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'relayIp1', port: 3478}, + }); - await clock.tickAsync(10); - fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp3'}}); + await clock.tickAsync(50); // total elapsed time: 60 + fakePeerConnection.onicecandidate({ + candidate: {type: 'relay', address: 'relayIp2', port: 3478}, + }); clusterReachability.abort(); await promise; - // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'unreachable'}, tcp: {result: 'reachable', latencyInMilliseconds: 10}, @@ -301,24 +490,17 @@ describe('ClusterReachability', () => { await clock.tickAsync(10); fakePeerConnection.onicecandidate({ - candidate: {type: 'relay', address: 'someTurnRelayIp1', port: 443}, - }); - - // generate more candidates - await clock.tickAsync(10); - fakePeerConnection.onicecandidate({ - candidate: {type: 'relay', address: 'someTurnRelayIp2', port: 443}, + candidate: {type: 'relay', address: 'relayIp1', port: 443}, }); - await clock.tickAsync(10); + await clock.tickAsync(50); // total elapsed time: 60 fakePeerConnection.onicecandidate({ - candidate: {type: 'relay', address: 'someTurnRelayIp3', port: 443}, + candidate: {type: 'relay', address: 'relayIp2', port: 443}, }); clusterReachability.abort(); await promise; - // latency should be from only the first candidates, but the clientMediaIps should be from only from UDP candidates assert.deepEqual(clusterReachability.getResult(), { udp: {result: 'unreachable'}, tcp: {result: 'unreachable'}, @@ -433,37 +615,5 @@ describe('ClusterReachability', () => { xtls: {result: 'reachable', latencyInMilliseconds: 20}, }); }); - - it('should gather correctly reached subnets', async () => { - const promise = clusterReachability.start(); - - await clock.tickAsync(10); - fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}}); - fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:4.3.2.1:5004'}}); - fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: 'someTurnRelayIp'}}); - - clusterReachability.abort(); - await promise; - - assert.deepEqual(Array.from(clusterReachability.reachedSubnets), [ - '1.2.3.4', - '4.3.2.1', - 'someTurnRelayIp' - ]); - }); - - it('should store only unique subnet address', async () => { - const promise = clusterReachability.start(); - - await clock.tickAsync(10); - fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:5004'}}); - fakePeerConnection.onicecandidate({candidate: {type: 'srflx', url: 'stun:1.2.3.4:9000'}}); - fakePeerConnection.onicecandidate({candidate: {type: 'relay', address: '1.2.3.4'}}); - - clusterReachability.abort(); - await promise; - - assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['1.2.3.4']); - }); }); }); From 661445a83d82c855621f72b1986d50b4802d0657 Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 4 Dec 2025 14:24:26 +0530 Subject: [PATCH 3/8] fix(plugin-meetings): addressed review comments for moving cluster reachability functionality --- .../src/reachability/clusterReachability.ts | 24 +++-- .../src/reachability/reachability.types.ts | 16 ++- .../reachabilityPeerConnection.ts | 99 +++++++++---------- 3 files changed, 76 insertions(+), 63 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 933ed665d3f..9bbc5c39518 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -1,13 +1,13 @@ -import LoggerProxy from '../common/logs/logger-proxy'; import {ClusterNode} from './request'; import EventsScope from '../common/events/events-scope'; import {Enum} from '../constants'; -import {ClusterReachabilityResult, NatType} from './reachability.types'; import { - ReachabilityPeerConnection, + ClusterReachabilityResult, + NatType, ReachabilityPeerConnectionEvents, -} from './reachabilityPeerConnection'; +} from './reachability.types'; +import {ReachabilityPeerConnection} from './reachabilityPeerConnection'; // data for the Events.resultReady event export type ResultEventData = { @@ -56,13 +56,21 @@ export class ClusterReachability extends EventsScope { this.name = name; this.isVideoMesh = clusterInfo.isVideoMesh; - this.reachabilityPeerConnection = new ReachabilityPeerConnection(clusterInfo, name); + this.reachabilityPeerConnection = new ReachabilityPeerConnection(name, clusterInfo); + this.setupReachabilityPeerConnectionEventListeners(); + } + + /** + * Sets up event listeners for the ReachabilityPeerConnection instance + * @returns {void} + */ + private setupReachabilityPeerConnectionEventListeners() { this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.resultReady, (data) => { this.emit( { file: 'clusterReachability', - function: 'onResultReady', + function: 'setupReachabilityPeerConnectionEventListeners', }, Events.resultReady, data @@ -75,7 +83,7 @@ export class ClusterReachability extends EventsScope { this.emit( { file: 'clusterReachability', - function: 'onClientMediaIpsUpdated', + function: 'setupReachabilityPeerConnectionEventListeners', }, Events.clientMediaIpsUpdated, data @@ -87,7 +95,7 @@ export class ClusterReachability extends EventsScope { this.emit( { file: 'clusterReachability', - function: 'onNatTypeUpdated', + function: 'setupReachabilityPeerConnectionEventListeners', }, Events.natTypeUpdated, data diff --git a/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts b/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts index 5922b7ec46f..cf5c8c07272 100644 --- a/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts +++ b/packages/@webex/plugin-meetings/src/reachability/reachability.types.ts @@ -1,4 +1,18 @@ -import {IP_VERSION} from '../constants'; +import {IP_VERSION, Enum} from '../constants'; + +export type Protocol = 'udp' | 'tcp' | 'xtls'; + +/** + * Events emitted by ReachabilityPeerConnection + */ +export const ReachabilityPeerConnectionEvents = { + resultReady: 'resultReady', // emitted when successfully reached over a protocol + clientMediaIpsUpdated: 'clientMediaIpsUpdated', // emitted when new public IPs are found + natTypeUpdated: 'natTypeUpdated', // emitted when NAT type is determined + reachedSubnets: 'reachedSubnets', // emitted when server IP (subnet) is discovered +} as const; + +export type ReachabilityPeerConnectionEvents = Enum; // result for a specific transport protocol (like udp or tcp) export type TransportResult = { diff --git a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts index 1a546fe462b..09e2e74f9d6 100644 --- a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts +++ b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts @@ -5,20 +5,13 @@ import {ClusterNode} from './request'; import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util'; import EventsScope from '../common/events/events-scope'; -import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants'; -import {ClusterReachabilityResult, NatType} from './reachability.types'; - -/** - * Events emitted by ReachabilityPeerConnection - */ -export const ReachabilityPeerConnectionEvents = { - resultReady: 'resultReady', // emitted when successfully reached over a protocol - clientMediaIpsUpdated: 'clientMediaIpsUpdated', // emitted when new public IPs are found - natTypeUpdated: 'natTypeUpdated', // emitted when NAT type is determined - reachedSubnets: 'reachedSubnets', // emitted when server IP (subnet) is discovered -} as const; - -export type ReachabilityPeerConnectionEvents = Enum; +import {CONNECTION_STATE, ICE_GATHERING_STATE} from '../constants'; +import { + ClusterReachabilityResult, + NatType, + Protocol, + ReachabilityPeerConnectionEvents, +} from './reachability.types'; /** * A class to handle RTCPeerConnection lifecycle and ICE candidate gathering for reachability checks. @@ -28,7 +21,7 @@ export class ReachabilityPeerConnection extends EventsScope { public numUdpUrls: number; public numTcpUrls: number; public numXTlsUrls: number; - private pc?: RTCPeerConnection; + private pc: RTCPeerConnection | null; private defer: Defer; private startTimestamp: number; private srflxIceCandidates: RTCIceCandidate[] = []; @@ -38,10 +31,10 @@ export class ReachabilityPeerConnection extends EventsScope { /** * Constructor for ReachabilityPeerConnection - * @param {ClusterNode} clusterInfo information about the media cluster * @param {string} clusterName name of the cluster + * @param {ClusterNode} clusterInfo information about the media cluster */ - constructor(clusterInfo: ClusterNode, clusterName: string) { + constructor(clusterName: string, clusterInfo: ClusterNode) { super(); this.clusterName = clusterName; this.numUdpUrls = clusterInfo.udp.length; @@ -113,9 +106,9 @@ export class ReachabilityPeerConnection extends EventsScope { /** * Creates an RTCPeerConnection * @param {ClusterNode} clusterInfo information about the media cluster - * @returns {RTCPeerConnection|undefined} peerConnection + * @returns {RTCPeerConnection|null} peerConnection */ - private createPeerConnection(clusterInfo: ClusterNode) { + private createPeerConnection(clusterInfo: ClusterNode): RTCPeerConnection | null { try { const config = ReachabilityPeerConnection.buildPeerConnectionConfig(clusterInfo); @@ -128,7 +121,7 @@ export class ReachabilityPeerConnection extends EventsScope { peerConnectionError ); - return undefined; + return null; } } @@ -180,35 +173,36 @@ export class ReachabilityPeerConnection extends EventsScope { * @param {string} publicIp * @returns {void} */ - private addPublicIp(protocol: 'udp' | 'tcp' | 'xtls', publicIp?: string | null) { - const result = this.result[protocol]; + private addPublicIp(protocol: Protocol, publicIp?: string | null) { + if (!publicIp) { + return; + } - if (publicIp) { - let ipAdded = false; + const result = this.result[protocol]; + let ipAdded = false; - if (result.clientMediaIPs) { - if (!result.clientMediaIPs.includes(publicIp)) { - result.clientMediaIPs.push(publicIp); - ipAdded = true; - } - } else { - result.clientMediaIPs = [publicIp]; + if (result.clientMediaIPs) { + if (!result.clientMediaIPs.includes(publicIp)) { + result.clientMediaIPs.push(publicIp); ipAdded = true; } + } else { + result.clientMediaIPs = [publicIp]; + ipAdded = true; + } - if (ipAdded) { - this.emit( - { - file: 'reachabilityPeerConnection', - function: 'addPublicIp', - }, - ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, - { - protocol, - clientMediaIPs: result.clientMediaIPs, - } - ); - } + if (ipAdded) { + this.emit( + { + file: 'reachabilityPeerConnection', + function: 'addPublicIp', + }, + ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, + { + protocol, + clientMediaIPs: result.clientMediaIPs, + } + ); } } @@ -239,7 +233,7 @@ export class ReachabilityPeerConnection extends EventsScope { * @returns {void} */ private saveResult( - protocol: 'udp' | 'tcp' | 'xtls', + protocol: Protocol, latency: number, publicIp?: string | null, serverIp?: string | null @@ -290,11 +284,11 @@ export class ReachabilityPeerConnection extends EventsScope { } /** - * Determines NAT Type. - * @param {RTCIceCandidate} candidate + * Determines NAT type by analyzing server reflexive candidate patterns + * @param {RTCIceCandidate} candidate server reflexive candidate * @returns {void} */ - private determineNatType(candidate: RTCIceCandidate) { + private determineNatTypeForSrflxCandidate(candidate: RTCIceCandidate) { this.srflxIceCandidates.push(candidate); if (this.srflxIceCandidates.length > 1) { @@ -314,7 +308,7 @@ export class ReachabilityPeerConnection extends EventsScope { this.emit( { file: 'reachabilityPeerConnection', - function: 'determineNatType', + function: 'determineNatTypeForSrflxCandidate', }, ReachabilityPeerConnectionEvents.natTypeUpdated, { @@ -348,15 +342,12 @@ export class ReachabilityPeerConnection extends EventsScope { const stunServerUrlRegex = /stun:([\d.]+):\d+/; const match = (e.candidate as any).url.match(stunServerUrlRegex); - if (match) { - // eslint-disable-next-line prefer-destructuring - serverIp = match[1]; - } + serverIp = match && match[1]; } this.saveResult('udp', latencyInMilliseconds, e.candidate.address, serverIp); - this.determineNatType(e.candidate); + this.determineNatTypeForSrflxCandidate(e.candidate); } if (e.candidate.type === CANDIDATE_TYPES.RELAY) { From 78d8676d0e37955cb1c126678d45fd5bb7dcdb1d Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 11 Dec 2025 16:39:02 +0530 Subject: [PATCH 4/8] fix(plugin-meetings): added perUDPURL check based on flag from config pr2 --- packages/@webex/plugin-meetings/src/config.ts | 1 + .../src/reachability/clusterReachability.ts | 180 +++++++++++++++--- .../plugin-meetings/src/reachability/index.ts | 7 +- .../reachabilityPeerConnection.ts | 4 +- .../spec/reachability/clusterReachability.ts | 124 ++++++++++++ .../test/unit/spec/reachability/index.ts | 6 +- 6 files changed, 290 insertions(+), 32 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index 8544031c190..7c4a271fe4d 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -100,5 +100,6 @@ export default { logUploadIntervalMultiplicationFactor: 0, // if set to 0 or undefined, logs won't be uploaded periodically, if you want periodic logs, recommended value is 1 stopIceGatheringAfterFirstRelayCandidate: false, enableAudioTwccForMultistream: false, + enablePerUdpUrlReachability: false, // true: separate peer connection per each UDP URL; false: single peer connection for all URLs }, }; diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 9bbc5c39518..8a7d4af1f1d 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -1,5 +1,6 @@ import {ClusterNode} from './request'; import EventsScope from '../common/events/events-scope'; +import LoggerProxy from '../common/logs/logger-proxy'; import {Enum} from '../constants'; import { @@ -37,36 +38,117 @@ export type Events = Enum; /** * A class that handles reachability checks for a single cluster. - * Creates and orchestrates a ReachabilityPeerConnection instance. + * Creates and orchestrates ReachabilityPeerConnection instance(s). * Listens to events and emits them to consumers. + * + * When enablePerUdpUrlReachability is true: + * - Creates one ReachabilityPeerConnection for each UDP URL + * - Creates one ReachabilityPeerConnection for all TCP and TLS URLs together + * Otherwise: + * - Creates a single ReachabilityPeerConnection for all URLs */ export class ClusterReachability extends EventsScope { - private reachabilityPeerConnection: ReachabilityPeerConnection; + private reachabilityPeerConnection: ReachabilityPeerConnection | null = null; + private reachabilityPeerConnectionsForUdp: ReachabilityPeerConnection[] = []; + public readonly isVideoMesh: boolean; public readonly name; public readonly reachedSubnets: Set = new Set(); + private enablePerUdpUrlReachability: boolean; + private udpResultEmitted = false; + /** * Constructor for ClusterReachability * @param {string} name cluster name * @param {ClusterNode} clusterInfo information about the media cluster + * @param {boolean} enablePerUdpUrlReachability whether to create separate peer connections per UDP URL */ - constructor(name: string, clusterInfo: ClusterNode) { + constructor(name: string, clusterInfo: ClusterNode, enablePerUdpUrlReachability = false) { super(); this.name = name; this.isVideoMesh = clusterInfo.isVideoMesh; + this.enablePerUdpUrlReachability = enablePerUdpUrlReachability; - this.reachabilityPeerConnection = new ReachabilityPeerConnection(name, clusterInfo); + if (this.enablePerUdpUrlReachability) { + this.initializePerUdpUrlReachabilityCheck(clusterInfo); + } else { + this.initializeSingleReachabilityPeerConnection(clusterInfo); + } + } - this.setupReachabilityPeerConnectionEventListeners(); + /** + * Initializes a single ReachabilityPeerConnection for all protocols + * @param {ClusterNode} clusterInfo information about the media cluster + * @returns {void} + */ + private initializeSingleReachabilityPeerConnection(clusterInfo: ClusterNode) { + this.reachabilityPeerConnection = new ReachabilityPeerConnection(this.name, clusterInfo); + this.setupReachabilityPeerConnectionEventListeners(this.reachabilityPeerConnection); } /** - * Sets up event listeners for the ReachabilityPeerConnection instance + * Initializes per-URL UDP reachability checks: + * - One ReachabilityPeerConnection per UDP URL + * - One ReachabilityPeerConnection for all TCP and TLS URLs together + * @param {ClusterNode} clusterInfo information about the media cluster * @returns {void} */ - private setupReachabilityPeerConnectionEventListeners() { - this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.resultReady, (data) => { + private initializePerUdpUrlReachabilityCheck(clusterInfo: ClusterNode) { + LoggerProxy.logger.log( + `ClusterReachability#initializePerUdpUrlReachabilityCheck --> cluster: ${this.name}, performing per-URL UDP reachability for ${clusterInfo.udp.length} URLs` + ); + + // Create one ReachabilityPeerConnection for each UDP URL + clusterInfo.udp.forEach((udpUrl) => { + const singleUdpClusterInfo: ClusterNode = { + isVideoMesh: clusterInfo.isVideoMesh, + udp: [udpUrl], + tcp: [], + xtls: [], + }; + const rpc = new ReachabilityPeerConnection(this.name, singleUdpClusterInfo); + this.setupReachabilityPeerConnectionEventListeners(rpc, true); + this.reachabilityPeerConnectionsForUdp.push(rpc); + }); + + // Create one ReachabilityPeerConnection for all TCP and TLS URLs together + if (clusterInfo.tcp.length > 0 || clusterInfo.xtls.length > 0) { + const tcpTlsClusterInfo: ClusterNode = { + isVideoMesh: clusterInfo.isVideoMesh, + udp: [], + tcp: clusterInfo.tcp, + xtls: clusterInfo.xtls, + }; + this.reachabilityPeerConnection = new ReachabilityPeerConnection( + this.name, + tcpTlsClusterInfo + ); + this.setupReachabilityPeerConnectionEventListeners(this.reachabilityPeerConnection); + } + } + + /** + * Sets up event listeners for a ReachabilityPeerConnection instance + * @param {ReachabilityPeerConnection} rpc the ReachabilityPeerConnection instance + * @param {boolean} isUdpPerUrl whether this is a per-URL UDP instance + * @returns {void} + */ + private setupReachabilityPeerConnectionEventListeners( + rpc: ReachabilityPeerConnection, + isUdpPerUrl = false + ) { + rpc.on(ReachabilityPeerConnectionEvents.resultReady, (data) => { + // For per-URL UDP checks, only emit the first successful UDP result + if (isUdpPerUrl && data.protocol === 'udp') { + if (this.udpResultEmitted) { + return; + } + if (data.result === 'reachable') { + this.udpResultEmitted = true; + } + } + this.emit( { file: 'clusterReachability', @@ -77,21 +159,18 @@ export class ClusterReachability extends EventsScope { ); }); - this.reachabilityPeerConnection.on( - ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, - (data) => { - this.emit( - { - file: 'clusterReachability', - function: 'setupReachabilityPeerConnectionEventListeners', - }, - Events.clientMediaIpsUpdated, - data - ); - } - ); + rpc.on(ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, (data) => { + this.emit( + { + file: 'clusterReachability', + function: 'setupReachabilityPeerConnectionEventListeners', + }, + Events.clientMediaIpsUpdated, + data + ); + }); - this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.natTypeUpdated, (data) => { + rpc.on(ReachabilityPeerConnectionEvents.natTypeUpdated, (data) => { this.emit( { file: 'clusterReachability', @@ -102,18 +181,54 @@ export class ClusterReachability extends EventsScope { ); }); - this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.reachedSubnets, (data) => { - data.subnets.forEach((subnet) => { + rpc.on(ReachabilityPeerConnectionEvents.reachedSubnets, (data) => { + data.subnets.forEach((subnet: string) => { this.reachedSubnets.add(subnet); }); }); } /** + * Gets the aggregated reachability result for this cluster. * @returns {ClusterReachabilityResult} reachability result for this cluster */ getResult(): ClusterReachabilityResult { - return this.reachabilityPeerConnection.getResult(); + if (!this.enablePerUdpUrlReachability) { + return ( + this.reachabilityPeerConnection?.getResult() ?? { + udp: {result: 'untested'}, + tcp: {result: 'untested'}, + xtls: {result: 'untested'}, + } + ); + } + + const result: ClusterReachabilityResult = { + udp: {result: 'untested'}, + tcp: {result: 'untested'}, + xtls: {result: 'untested'}, + }; + + // Get the first reachable UDP result from per-URL instances + for (const rpc of this.reachabilityPeerConnectionsForUdp) { + const rpcResult = rpc.getResult(); + if (rpcResult.udp.result === 'reachable') { + result.udp = rpcResult.udp; + break; + } + if (rpcResult.udp.result === 'unreachable' && result.udp.result === 'untested') { + result.udp = rpcResult.udp; + } + } + + // Get TCP and TLS results from the main peer connection + if (this.reachabilityPeerConnection) { + const mainResult = this.reachabilityPeerConnection.getResult(); + result.tcp = mainResult.tcp; + result.xtls = mainResult.xtls; + } + + return result; } /** @@ -121,7 +236,17 @@ export class ClusterReachability extends EventsScope { * @returns {Promise} */ async start(): Promise { - await this.reachabilityPeerConnection.start(); + const startPromises: Promise[] = []; + + this.reachabilityPeerConnectionsForUdp.forEach((rpc) => { + startPromises.push(rpc.start()); + }); + + if (this.reachabilityPeerConnection) { + startPromises.push(this.reachabilityPeerConnection.start()); + } + + await Promise.all(startPromises); return this.getResult(); } @@ -131,6 +256,7 @@ export class ClusterReachability extends EventsScope { * @returns {void} */ public abort() { - this.reachabilityPeerConnection.abort(); + this.reachabilityPeerConnectionsForUdp.forEach((rpc) => rpc.abort()); + this.reachabilityPeerConnection?.abort(); } } diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index 1e1a3514dd6..cffe13c01d0 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -961,7 +961,12 @@ export default class Reachability extends EventsScope { Object.keys(clusterList).forEach((key) => { const cluster = clusterList[key]; - this.clusterReachability[key] = new ClusterReachability(key, cluster); + this.clusterReachability[key] = new ClusterReachability( + key, + cluster, + // @ts-ignore + this.webex.config.meetings.enablePerUdpUrlReachability + ); this.clusterReachability[key].on(Events.resultReady, async (data: ResultEventData) => { const {protocol, result, clientMediaIPs, latencyInMilliseconds} = data; diff --git a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts index 09e2e74f9d6..3de6d506f6a 100644 --- a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts +++ b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts @@ -243,7 +243,9 @@ export class ReachabilityPeerConnection extends EventsScope { if (result.latencyInMilliseconds === undefined) { LoggerProxy.logger.log( // @ts-ignore - `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${this.clusterName} over ${protocol}: ${latency}ms` + `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${ + this.clusterName + } over ${protocol}: ${latency}ms, serverIp=${serverIp || 'unknown'}` ); result.latencyInMilliseconds = latency; result.result = 'reachable'; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index d653e633562..83647d9b3e7 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -10,6 +10,7 @@ import { NatTypeUpdatedEventData, } from '@webex/plugin-meetings/src/reachability/clusterReachability'; import {ReachabilityPeerConnection} from '@webex/plugin-meetings/src/reachability/reachabilityPeerConnection'; +import {ReachabilityPeerConnectionEvents} from '@webex/plugin-meetings/src/reachability/reachability.types'; describe('ClusterReachability', () => { let previousRTCPeerConnection; @@ -92,6 +93,22 @@ describe('ClusterReachability', () => { assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated], []); }); + it('should create separate peer connections when enablePerUdpUrlReachability is true', () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: ['stun:tcp1.webex.com'], + xtls: ['stun:xtls1.webex.com'], + }, + true + ); + + assert.equal((perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp.length, 2); + assert.instanceOf((perUdpClusterReachability as any).reachabilityPeerConnection, ReachabilityPeerConnection); + }); + describe('#event relaying', () => { let clock; @@ -172,6 +189,44 @@ describe('ClusterReachability', () => { clusterReachability.abort(); await promise; }); + + it('emits only the first successful UDP result when enablePerUdpUrlReachability is true', async () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: [], + xtls: [], + }, + true + ); + + const udpEvents: ResultEventData[] = []; + perUdpClusterReachability.on(Events.resultReady, (data: ResultEventData) => { + udpEvents.push(data); + }); + + const udpRpc1 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[0]; + const udpRpc2 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[1]; + + udpRpc1.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.resultReady, { + protocol: 'udp', + result: 'reachable', + latencyInMilliseconds: 50, + clientMediaIPs: ['1.1.1.1'], + }); + + udpRpc2.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.resultReady, { + protocol: 'udp', + result: 'reachable', + latencyInMilliseconds: 30, + clientMediaIPs: ['2.2.2.2'], + }); + + assert.equal(udpEvents.length, 1); + assert.equal(udpEvents[0].latencyInMilliseconds, 50); + }); }); describe('#subnet collection', () => { @@ -236,6 +291,38 @@ describe('ClusterReachability', () => { assert.equal(clusterReachability.reachedSubnets.size, 3); assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['192.168.1.1', '10.0.0.1', '172.16.0.1']); }); + + it('collects reached subnets from all peer connections when enablePerUdpUrlReachability is true', async () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: ['stun:tcp1.webex.com'], + xtls: [], + }, + true + ); + + const udpRpc1 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[0]; + const udpRpc2 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[1]; + const tcpTlsRpc = (perUdpClusterReachability as any).reachabilityPeerConnection; + + udpRpc1.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.reachedSubnets, { + subnets: ['192.168.1.1'], + }); + udpRpc2.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.reachedSubnets, { + subnets: ['10.0.0.1'], + }); + tcpTlsRpc.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.reachedSubnets, { + subnets: ['172.16.0.1'], + }); + + assert.equal(perUdpClusterReachability.reachedSubnets.size, 3); + assert.isTrue(perUdpClusterReachability.reachedSubnets.has('192.168.1.1')); + assert.isTrue(perUdpClusterReachability.reachedSubnets.has('10.0.0.1')); + assert.isTrue(perUdpClusterReachability.reachedSubnets.has('172.16.0.1')); + }); }); describe('#delegation', () => { @@ -277,6 +364,43 @@ describe('ClusterReachability', () => { assert.calledOnce(rpcGetResultStub); assert.deepEqual(result, expectedResult); }); + + it('delegates start() and abort() to all peer connections when enablePerUdpUrlReachability is true', async () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: ['stun:tcp1.webex.com'], + xtls: [], + }, + true + ); + + const udpRpc1 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[0]; + const udpRpc2 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[1]; + const tcpTlsRpc = (perUdpClusterReachability as any).reachabilityPeerConnection; + + const startStub1 = sinon.stub(udpRpc1, 'start').resolves({udp: {result: 'reachable'}}); + const startStub2 = sinon.stub(udpRpc2, 'start').resolves({udp: {result: 'unreachable'}}); + const startStubTcp = sinon.stub(tcpTlsRpc, 'start').resolves({tcp: {result: 'reachable'}}); + + const abortStub1 = sinon.stub(udpRpc1, 'abort'); + const abortStub2 = sinon.stub(udpRpc2, 'abort'); + const abortStubTcp = sinon.stub(tcpTlsRpc, 'abort'); + + await perUdpClusterReachability.start(); + + assert.calledOnce(startStub1); + assert.calledOnce(startStub2); + assert.calledOnce(startStubTcp); + + perUdpClusterReachability.abort(); + + assert.calledOnce(abortStub1); + assert.calledOnce(abortStub2); + assert.calledOnce(abortStubTcp); + }); }); describe('#WebRTC peer connection setup', () => { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index c512e8708b4..42783e97d93 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1693,7 +1693,7 @@ describe('gatherReachability', () => { udp: ['testUDP1', 'testUDP2'], tcp: [], // empty list because TCP is disabled in config xtls: ['testXTLS1', 'testXTLS2'], - }); + }, undefined); }); it('does not do TLS reachability if it is disabled in config', async () => { @@ -1728,7 +1728,7 @@ describe('gatherReachability', () => { udp: ['testUDP1', 'testUDP2'], tcp: ['testTCP1', 'testTCP2'], xtls: [], // empty list because TLS is disabled in config - }); + }, undefined); }); it('does not do TCP or TLS reachability if it is disabled in config', async () => { @@ -1763,7 +1763,7 @@ describe('gatherReachability', () => { udp: ['testUDP1', 'testUDP2'], tcp: [], // empty list because TCP is disabled in config xtls: [], // empty list because TLS is disabled in config - }); + }, undefined); }); it('retry of getClusters is succesfull', async () => { From 1bde4fc669fbe58e8d733ddd4f561505d47eb036 Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 20 Nov 2025 22:35:05 +0530 Subject: [PATCH 5/8] fix(plugin-meetings): moved cluster reachability functionality to new class pr1 --- .../src/reachability/clusterReachability.ts | 182 +++++++++++++++--- .../spec/reachability/clusterReachability.ts | 126 +++++++++++- 2 files changed, 279 insertions(+), 29 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 9bbc5c39518..157e9759b73 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -1,5 +1,6 @@ import {ClusterNode} from './request'; import EventsScope from '../common/events/events-scope'; +import LoggerProxy from '../common/logs/logger-proxy'; import {Enum} from '../constants'; import { @@ -37,36 +38,117 @@ export type Events = Enum; /** * A class that handles reachability checks for a single cluster. - * Creates and orchestrates a ReachabilityPeerConnection instance. + * Creates and orchestrates ReachabilityPeerConnection instance(s). * Listens to events and emits them to consumers. + * + * When enablePerUdpUrlReachability is true: + * - Creates one ReachabilityPeerConnection for each UDP URL + * - Creates one ReachabilityPeerConnection for all TCP and TLS URLs together + * Otherwise: + * - Creates a single ReachabilityPeerConnection for all URLs */ export class ClusterReachability extends EventsScope { - private reachabilityPeerConnection: ReachabilityPeerConnection; + private reachabilityPeerConnection: ReachabilityPeerConnection | null = null; + private reachabilityPeerConnectionsForUdp: ReachabilityPeerConnection[] = []; + public readonly isVideoMesh: boolean; public readonly name; public readonly reachedSubnets: Set = new Set(); + private enablePerUdpUrlReachability: boolean; + private udpResultEmitted = false; + /** * Constructor for ClusterReachability * @param {string} name cluster name * @param {ClusterNode} clusterInfo information about the media cluster + * @param {boolean} enablePerUdpUrlReachability whether to create separate peer connections per UDP URL */ - constructor(name: string, clusterInfo: ClusterNode) { + constructor(name: string, clusterInfo: ClusterNode, enablePerUdpUrlReachability = false) { super(); this.name = name; this.isVideoMesh = clusterInfo.isVideoMesh; + this.enablePerUdpUrlReachability = enablePerUdpUrlReachability; - this.reachabilityPeerConnection = new ReachabilityPeerConnection(name, clusterInfo); + if (this.enablePerUdpUrlReachability) { + this.initializePerUdpUrlReachabilityCheck(clusterInfo); + } else { + this.initializeSingleReachabilityPeerConnection(clusterInfo); + } + } - this.setupReachabilityPeerConnectionEventListeners(); + /** + * Initializes a single ReachabilityPeerConnection for all protocols + * @param {ClusterNode} clusterInfo information about the media cluster + * @returns {void} + */ + private initializeSingleReachabilityPeerConnection(clusterInfo: ClusterNode) { + this.reachabilityPeerConnection = new ReachabilityPeerConnection(this.name, clusterInfo); + this.setupReachabilityPeerConnectionEventListeners(this.reachabilityPeerConnection); } /** - * Sets up event listeners for the ReachabilityPeerConnection instance + * Initializes per-URL UDP reachability checks: + * - One ReachabilityPeerConnection per UDP URL + * - One ReachabilityPeerConnection for all TCP and TLS URLs together + * @param {ClusterNode} clusterInfo information about the media cluster * @returns {void} */ - private setupReachabilityPeerConnectionEventListeners() { - this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.resultReady, (data) => { + private initializePerUdpUrlReachabilityCheck(clusterInfo: ClusterNode) { + LoggerProxy.logger.log( + `ClusterReachability#initializePerUdpUrlReachabilityCheck --> cluster: ${this.name}, performing per-URL UDP reachability for ${clusterInfo.udp.length} URLs` + ); + + // Create one ReachabilityPeerConnection for each UDP URL + clusterInfo.udp.forEach((udpUrl) => { + const singleUdpClusterInfo: ClusterNode = { + isVideoMesh: clusterInfo.isVideoMesh, + udp: [udpUrl], + tcp: [], + xtls: [], + }; + const rpc = new ReachabilityPeerConnection(this.name, singleUdpClusterInfo); + this.setupReachabilityPeerConnectionEventListeners(rpc, true); + this.reachabilityPeerConnectionsForUdp.push(rpc); + }); + + // Create one ReachabilityPeerConnection for all TCP and TLS URLs together + if (clusterInfo.tcp.length > 0 || clusterInfo.xtls.length > 0) { + const tcpTlsClusterInfo: ClusterNode = { + isVideoMesh: clusterInfo.isVideoMesh, + udp: [], + tcp: clusterInfo.tcp, + xtls: clusterInfo.xtls, + }; + this.reachabilityPeerConnection = new ReachabilityPeerConnection( + this.name, + tcpTlsClusterInfo + ); + this.setupReachabilityPeerConnectionEventListeners(this.reachabilityPeerConnection); + } + } + + /** + * Sets up event listeners for a ReachabilityPeerConnection instance + * @param {ReachabilityPeerConnection} rpc the ReachabilityPeerConnection instance + * @param {boolean} isUdpPerUrl whether this is a per-URL UDP instance + * @returns {void} + */ + private setupReachabilityPeerConnectionEventListeners( + rpc: ReachabilityPeerConnection, + isUdpPerUrl = false + ) { + rpc.on(ReachabilityPeerConnectionEvents.resultReady, (data) => { + // For per-URL UDP checks, only emit the first successful UDP result + if (isUdpPerUrl && data.protocol === 'udp') { + if (this.udpResultEmitted) { + return; + } + if (data.result === 'reachable') { + this.udpResultEmitted = true; + } + } + this.emit( { file: 'clusterReachability', @@ -77,21 +159,18 @@ export class ClusterReachability extends EventsScope { ); }); - this.reachabilityPeerConnection.on( - ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, - (data) => { - this.emit( - { - file: 'clusterReachability', - function: 'setupReachabilityPeerConnectionEventListeners', - }, - Events.clientMediaIpsUpdated, - data - ); - } - ); + rpc.on(ReachabilityPeerConnectionEvents.clientMediaIpsUpdated, (data) => { + this.emit( + { + file: 'clusterReachability', + function: 'setupReachabilityPeerConnectionEventListeners', + }, + Events.clientMediaIpsUpdated, + data + ); + }); - this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.natTypeUpdated, (data) => { + rpc.on(ReachabilityPeerConnectionEvents.natTypeUpdated, (data) => { this.emit( { file: 'clusterReachability', @@ -102,18 +181,54 @@ export class ClusterReachability extends EventsScope { ); }); - this.reachabilityPeerConnection.on(ReachabilityPeerConnectionEvents.reachedSubnets, (data) => { - data.subnets.forEach((subnet) => { + rpc.on(ReachabilityPeerConnectionEvents.reachedSubnets, (data) => { + data.subnets.forEach((subnet: string) => { this.reachedSubnets.add(subnet); }); }); } /** + * Gets the aggregated reachability result for this cluster. * @returns {ClusterReachabilityResult} reachability result for this cluster */ getResult(): ClusterReachabilityResult { - return this.reachabilityPeerConnection.getResult(); + if (!this.enablePerUdpUrlReachability) { + return ( + this.reachabilityPeerConnection?.getResult() ?? { + udp: {result: 'untested'}, + tcp: {result: 'untested'}, + xtls: {result: 'untested'}, + } + ); + } + + const result: ClusterReachabilityResult = { + udp: {result: 'untested'}, + tcp: {result: 'untested'}, + xtls: {result: 'untested'}, + }; + + // Get the first reachable UDP result from per-URL instances + for (const rpc of this.reachabilityPeerConnectionsForUdp) { + const rpcResult = rpc.getResult(); + if (rpcResult.udp.result === 'reachable') { + result.udp = rpcResult.udp; + break; + } + if (rpcResult.udp.result === 'unreachable' && result.udp.result === 'untested') { + result.udp = rpcResult.udp; + } + } + + // Get TCP and TLS results from the main peer connection + if (this.reachabilityPeerConnection) { + const mainResult = this.reachabilityPeerConnection.getResult(); + result.tcp = mainResult.tcp; + result.xtls = mainResult.xtls; + } + + return result; } /** @@ -121,7 +236,17 @@ export class ClusterReachability extends EventsScope { * @returns {Promise} */ async start(): Promise { - await this.reachabilityPeerConnection.start(); + const startPromises: Promise[] = []; + + this.reachabilityPeerConnectionsForUdp.forEach((rpc) => { + startPromises.push(rpc.start()); + }); + + if (this.reachabilityPeerConnection) { + startPromises.push(this.reachabilityPeerConnection.start()); + } + + await Promise.all(startPromises); return this.getResult(); } @@ -131,6 +256,7 @@ export class ClusterReachability extends EventsScope { * @returns {void} */ public abort() { - this.reachabilityPeerConnection.abort(); + this.reachabilityPeerConnectionsForUdp.forEach((rpc) => rpc.abort()); + this.reachabilityPeerConnection?.abort(); } -} +} \ No newline at end of file diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts index d653e633562..63e35701610 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/clusterReachability.ts @@ -10,6 +10,7 @@ import { NatTypeUpdatedEventData, } from '@webex/plugin-meetings/src/reachability/clusterReachability'; import {ReachabilityPeerConnection} from '@webex/plugin-meetings/src/reachability/reachabilityPeerConnection'; +import {ReachabilityPeerConnectionEvents} from '@webex/plugin-meetings/src/reachability/reachability.types'; describe('ClusterReachability', () => { let previousRTCPeerConnection; @@ -92,6 +93,22 @@ describe('ClusterReachability', () => { assert.deepEqual(emittedEvents[Events.clientMediaIpsUpdated], []); }); + it('should create separate peer connections when enablePerUdpUrlReachability is true', () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: ['stun:tcp1.webex.com'], + xtls: ['stun:xtls1.webex.com'], + }, + true + ); + + assert.equal((perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp.length, 2); + assert.instanceOf((perUdpClusterReachability as any).reachabilityPeerConnection, ReachabilityPeerConnection); + }); + describe('#event relaying', () => { let clock; @@ -172,6 +189,44 @@ describe('ClusterReachability', () => { clusterReachability.abort(); await promise; }); + + it('emits only the first successful UDP result when enablePerUdpUrlReachability is true', async () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: [], + xtls: [], + }, + true + ); + + const udpEvents: ResultEventData[] = []; + perUdpClusterReachability.on(Events.resultReady, (data: ResultEventData) => { + udpEvents.push(data); + }); + + const udpRpc1 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[0]; + const udpRpc2 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[1]; + + udpRpc1.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.resultReady, { + protocol: 'udp', + result: 'reachable', + latencyInMilliseconds: 50, + clientMediaIPs: ['1.1.1.1'], + }); + + udpRpc2.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.resultReady, { + protocol: 'udp', + result: 'reachable', + latencyInMilliseconds: 30, + clientMediaIPs: ['2.2.2.2'], + }); + + assert.equal(udpEvents.length, 1); + assert.equal(udpEvents[0].latencyInMilliseconds, 50); + }); }); describe('#subnet collection', () => { @@ -236,6 +291,38 @@ describe('ClusterReachability', () => { assert.equal(clusterReachability.reachedSubnets.size, 3); assert.deepEqual(Array.from(clusterReachability.reachedSubnets), ['192.168.1.1', '10.0.0.1', '172.16.0.1']); }); + + it('collects reached subnets from all peer connections when enablePerUdpUrlReachability is true', async () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: ['stun:tcp1.webex.com'], + xtls: [], + }, + true + ); + + const udpRpc1 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[0]; + const udpRpc2 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[1]; + const tcpTlsRpc = (perUdpClusterReachability as any).reachabilityPeerConnection; + + udpRpc1.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.reachedSubnets, { + subnets: ['192.168.1.1'], + }); + udpRpc2.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.reachedSubnets, { + subnets: ['10.0.0.1'], + }); + tcpTlsRpc.emit({file: 'test', function: 'test'}, ReachabilityPeerConnectionEvents.reachedSubnets, { + subnets: ['172.16.0.1'], + }); + + assert.equal(perUdpClusterReachability.reachedSubnets.size, 3); + assert.isTrue(perUdpClusterReachability.reachedSubnets.has('192.168.1.1')); + assert.isTrue(perUdpClusterReachability.reachedSubnets.has('10.0.0.1')); + assert.isTrue(perUdpClusterReachability.reachedSubnets.has('172.16.0.1')); + }); }); describe('#delegation', () => { @@ -277,6 +364,43 @@ describe('ClusterReachability', () => { assert.calledOnce(rpcGetResultStub); assert.deepEqual(result, expectedResult); }); + + it('delegates start() and abort() to all peer connections when enablePerUdpUrlReachability is true', async () => { + const perUdpClusterReachability = new ClusterReachability( + 'testName', + { + isVideoMesh: false, + udp: ['stun:udp1', 'stun:udp2'], + tcp: ['stun:tcp1.webex.com'], + xtls: [], + }, + true + ); + + const udpRpc1 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[0]; + const udpRpc2 = (perUdpClusterReachability as any).reachabilityPeerConnectionsForUdp[1]; + const tcpTlsRpc = (perUdpClusterReachability as any).reachabilityPeerConnection; + + const startStub1 = sinon.stub(udpRpc1, 'start').resolves({udp: {result: 'reachable'}}); + const startStub2 = sinon.stub(udpRpc2, 'start').resolves({udp: {result: 'unreachable'}}); + const startStubTcp = sinon.stub(tcpTlsRpc, 'start').resolves({tcp: {result: 'reachable'}}); + + const abortStub1 = sinon.stub(udpRpc1, 'abort'); + const abortStub2 = sinon.stub(udpRpc2, 'abort'); + const abortStubTcp = sinon.stub(tcpTlsRpc, 'abort'); + + await perUdpClusterReachability.start(); + + assert.calledOnce(startStub1); + assert.calledOnce(startStub2); + assert.calledOnce(startStubTcp); + + perUdpClusterReachability.abort(); + + assert.calledOnce(abortStub1); + assert.calledOnce(abortStub2); + assert.calledOnce(abortStubTcp); + }); }); describe('#WebRTC peer connection setup', () => { @@ -616,4 +740,4 @@ describe('ClusterReachability', () => { }); }); }); -}); +}); \ No newline at end of file From ecab64cf8f4b3ccb5320a09f64ea28cddf8aade5 Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 27 Nov 2025 16:13:44 +0530 Subject: [PATCH 6/8] fix(plugin-meetings): addressed review comments for new class reachabilityPeerConnection --- .../src/reachability/reachabilityPeerConnection.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts index 09e2e74f9d6..61b582bcd87 100644 --- a/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts +++ b/packages/@webex/plugin-meetings/src/reachability/reachabilityPeerConnection.ts @@ -243,7 +243,9 @@ export class ReachabilityPeerConnection extends EventsScope { if (result.latencyInMilliseconds === undefined) { LoggerProxy.logger.log( // @ts-ignore - `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${this.clusterName} over ${protocol}: ${latency}ms` + `Reachability:ReachabilityPeerConnection#saveResult --> Successfully reached ${ + this.clusterName + } over ${protocol}: ${latency}ms, serverIp=${serverIp || 'unknown'}` ); result.latencyInMilliseconds = latency; result.result = 'reachable'; @@ -413,4 +415,4 @@ export class ReachabilityPeerConnection extends EventsScope { return this.defer.promise; } -} +} \ No newline at end of file From 7016420927ca525ba2b05733145e2461c1a16155 Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Thu, 11 Dec 2025 16:39:02 +0530 Subject: [PATCH 7/8] fix(plugin-meetings): added perUDPURL check based on flag from config pr2 --- packages/@webex/plugin-meetings/src/config.ts | 1 + packages/@webex/plugin-meetings/src/reachability/index.ts | 7 ++++++- .../plugin-meetings/test/unit/spec/reachability/index.ts | 6 +++--- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts index 8544031c190..7c4a271fe4d 100644 --- a/packages/@webex/plugin-meetings/src/config.ts +++ b/packages/@webex/plugin-meetings/src/config.ts @@ -100,5 +100,6 @@ export default { logUploadIntervalMultiplicationFactor: 0, // if set to 0 or undefined, logs won't be uploaded periodically, if you want periodic logs, recommended value is 1 stopIceGatheringAfterFirstRelayCandidate: false, enableAudioTwccForMultistream: false, + enablePerUdpUrlReachability: false, // true: separate peer connection per each UDP URL; false: single peer connection for all URLs }, }; diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index 1e1a3514dd6..cffe13c01d0 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -961,7 +961,12 @@ export default class Reachability extends EventsScope { Object.keys(clusterList).forEach((key) => { const cluster = clusterList[key]; - this.clusterReachability[key] = new ClusterReachability(key, cluster); + this.clusterReachability[key] = new ClusterReachability( + key, + cluster, + // @ts-ignore + this.webex.config.meetings.enablePerUdpUrlReachability + ); this.clusterReachability[key].on(Events.resultReady, async (data: ResultEventData) => { const {protocol, result, clientMediaIPs, latencyInMilliseconds} = data; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index c512e8708b4..42783e97d93 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -1693,7 +1693,7 @@ describe('gatherReachability', () => { udp: ['testUDP1', 'testUDP2'], tcp: [], // empty list because TCP is disabled in config xtls: ['testXTLS1', 'testXTLS2'], - }); + }, undefined); }); it('does not do TLS reachability if it is disabled in config', async () => { @@ -1728,7 +1728,7 @@ describe('gatherReachability', () => { udp: ['testUDP1', 'testUDP2'], tcp: ['testTCP1', 'testTCP2'], xtls: [], // empty list because TLS is disabled in config - }); + }, undefined); }); it('does not do TCP or TLS reachability if it is disabled in config', async () => { @@ -1763,7 +1763,7 @@ describe('gatherReachability', () => { udp: ['testUDP1', 'testUDP2'], tcp: [], // empty list because TCP is disabled in config xtls: [], // empty list because TLS is disabled in config - }); + }, undefined); }); it('retry of getClusters is succesfull', async () => { From 6d14433a984b6c4ed1b2f611dbd15f1e62769e71 Mon Sep 17 00:00:00 2001 From: venky-mediboina Date: Fri, 12 Dec 2025 11:04:33 +0530 Subject: [PATCH 8/8] fix(plugin-meetings): added perUDPURL check based on flag from config pr2-style fix in pipeline --- .../plugin-meetings/src/reachability/clusterReachability.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts index 157e9759b73..8a7d4af1f1d 100644 --- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts +++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts @@ -259,4 +259,4 @@ export class ClusterReachability extends EventsScope { this.reachabilityPeerConnectionsForUdp.forEach((rpc) => rpc.abort()); this.reachabilityPeerConnection?.abort(); } -} \ No newline at end of file +}