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..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 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 () => {