@webex/plugin-meetings 3.12.0-next.55 → 3.12.0-next.56

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -381,7 +381,7 @@ var SimultaneousInterpretation = _webexCore.WebexPlugin.extend({
381
381
  throw error;
382
382
  });
383
383
  },
384
- version: "3.12.0-next.55"
384
+ version: "3.12.0-next.56"
385
385
  });
386
386
  var _default = exports.default = SimultaneousInterpretation;
387
387
  //# sourceMappingURL=index.js.map
@@ -18,7 +18,7 @@ var SILanguage = _webexCore.WebexPlugin.extend({
18
18
  languageCode: 'number',
19
19
  languageName: 'string'
20
20
  },
21
- version: "3.12.0-next.55"
21
+ version: "3.12.0-next.56"
22
22
  });
23
23
  var _default = exports.default = SILanguage;
24
24
  //# sourceMappingURL=siLanguage.js.map
@@ -38,6 +38,7 @@ interface InternalDataSet extends DataSet {
38
38
  hashTree?: HashTree;
39
39
  timer?: ReturnType<typeof setTimeout>;
40
40
  heartbeatWatchdogTimer?: ReturnType<typeof setTimeout>;
41
+ syncAbortController?: AbortController;
41
42
  }
42
43
  type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
43
44
  export declare const LocusInfoUpdateType: {
@@ -332,6 +333,14 @@ declare class HashTreeParser {
332
333
  * @returns {Promise<void>}
333
334
  */
334
335
  private performSync;
336
+ /**
337
+ * Cancels any pending or in-flight syncs for the specified data sets.
338
+ * This removes matching entries from the sync queue and aborts any in-flight sync HTTP requests.
339
+ *
340
+ * @param {string[]} dataSetNames - The names of the data sets to cancel syncs for
341
+ * @returns {void}
342
+ */
343
+ private cancelPendingSyncsForDataSets;
335
344
  /**
336
345
  * Enqueues a sync for the given data set. If the data set is already in the queue, the request is ignored.
337
346
  * This ensures that all syncs are executed sequentially and no more than 1 sync runs at a time.
@@ -774,7 +774,7 @@ var Webinar = _webexCore.WebexPlugin.extend({
774
774
  }, _callee1);
775
775
  }))();
776
776
  },
777
- version: "3.12.0-next.55"
777
+ version: "3.12.0-next.56"
778
778
  });
779
779
  var _default = exports.default = Webinar;
780
780
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -94,5 +94,5 @@
94
94
  "//": [
95
95
  "TODO: upgrade jwt-decode when moving to node 18"
96
96
  ],
97
- "version": "3.12.0-next.55"
97
+ "version": "3.12.0-next.56"
98
98
  }
@@ -49,6 +49,7 @@ interface InternalDataSet extends DataSet {
49
49
  hashTree?: HashTree; // set only for visible data sets
50
50
  timer?: ReturnType<typeof setTimeout>;
51
51
  heartbeatWatchdogTimer?: ReturnType<typeof setTimeout>;
52
+ syncAbortController?: AbortController;
52
53
  }
53
54
 
54
55
  type WebexRequestMethod = (options: Record<string, any>) => Promise<any>;
@@ -546,6 +547,21 @@ class HashTreeParser {
546
547
  private handleRootHashHeartBeatMessage(message: RootHashMessage): void {
547
548
  const {dataSets} = message;
548
549
 
550
+ LoggerProxy.logger.info(
551
+ `HashTreeParser#handleRootHashMessage --> ${
552
+ this.debugId
553
+ } Received heartbeat root hash message with data sets: ${JSON.stringify(
554
+ dataSets.map(({name, root, leafCount, version}) => ({
555
+ name,
556
+ root,
557
+ leafCount,
558
+ version,
559
+ }))
560
+ )}`
561
+ );
562
+
563
+ this.cancelPendingSyncsForDataSets(dataSets.map((ds) => ds.name));
564
+
549
565
  dataSets.forEach((dataSet) => {
550
566
  this.updateDataSetInfo(dataSet);
551
567
  this.runSyncAlgorithm(dataSet);
@@ -845,6 +861,8 @@ class HashTreeParser {
845
861
  */
846
862
  private deleteHashTree(dataSetName: string) {
847
863
  this.dataSets[dataSetName].hashTree = undefined;
864
+ this.dataSets[dataSetName].syncAbortController?.abort();
865
+ this.dataSets[dataSetName].syncAbortController = undefined;
848
866
 
849
867
  // we also need to stop the timers as there is no hash tree anymore to sync
850
868
  if (this.dataSets[dataSetName].timer) {
@@ -1020,6 +1038,7 @@ class HashTreeParser {
1020
1038
  // first, update our metadata about the datasets with info from the message
1021
1039
  this.visibleDataSetsUrl = visibleDataSetsUrl;
1022
1040
  dataSets.forEach((dataSet) => this.updateDataSetInfo(dataSet));
1041
+ this.cancelPendingSyncsForDataSets(dataSets.map((ds) => ds.name));
1023
1042
 
1024
1043
  const updatedObjects: HashTreeObject[] = [];
1025
1044
 
@@ -1243,6 +1262,9 @@ class HashTreeParser {
1243
1262
  return;
1244
1263
  }
1245
1264
 
1265
+ const abortController = dataSet.syncAbortController ?? new AbortController();
1266
+ dataSet.syncAbortController = abortController;
1267
+
1246
1268
  const {hashTree} = dataSet;
1247
1269
  const rootHash = hashTree.getRootHash();
1248
1270
 
@@ -1291,6 +1313,14 @@ class HashTreeParser {
1291
1313
  leavesData = {0: hashTree.getLeafData(0)};
1292
1314
  }
1293
1315
  }
1316
+
1317
+ if (abortController.signal.aborted) {
1318
+ LoggerProxy.logger.info(
1319
+ `HashTreeParser#performSync --> ${this.debugId} abandoning sync for "${dataSet.name}" before /sync - message received during sync`
1320
+ );
1321
+
1322
+ return;
1323
+ }
1294
1324
  // request sync for mismatched leaves
1295
1325
  let syncResponse: HashTreeMessage | null = null;
1296
1326
 
@@ -1308,6 +1338,10 @@ class HashTreeParser {
1308
1338
  this.runSyncAlgorithm(dataSet);
1309
1339
 
1310
1340
  if (syncResponse) {
1341
+ // clear the abort controller before processing the response so that
1342
+ // parseMessage() -> cancelPendingSyncsForDataSets() doesn't log a
1343
+ // misleading "aborting sync" message for this already-completed sync
1344
+ dataSet.syncAbortController = undefined;
1311
1345
  // the format of sync response is the same as messages, so we can reuse the same handler
1312
1346
  this.handleMessage(syncResponse, 'via sync API');
1313
1347
  }
@@ -1318,6 +1352,38 @@ class HashTreeParser {
1318
1352
  error
1319
1353
  );
1320
1354
  }
1355
+ } finally {
1356
+ dataSet.syncAbortController = undefined;
1357
+ }
1358
+ }
1359
+
1360
+ /**
1361
+ * Cancels any pending or in-flight syncs for the specified data sets.
1362
+ * This removes matching entries from the sync queue and aborts any in-flight sync HTTP requests.
1363
+ *
1364
+ * @param {string[]} dataSetNames - The names of the data sets to cancel syncs for
1365
+ * @returns {void}
1366
+ */
1367
+ private cancelPendingSyncsForDataSets(dataSetNames: string[]): void {
1368
+ const previousLength = this.syncQueue.length;
1369
+
1370
+ this.syncQueue = this.syncQueue.filter((entry) => !dataSetNames.includes(entry.dataSetName));
1371
+
1372
+ if (previousLength !== this.syncQueue.length) {
1373
+ LoggerProxy.logger.info(
1374
+ `HashTreeParser#cancelPendingSyncsForDataSets --> ${this.debugId} removed ${
1375
+ previousLength - this.syncQueue.length
1376
+ } entries from sync queue for data sets: ${dataSetNames.join(', ')}`
1377
+ );
1378
+ }
1379
+
1380
+ for (const name of dataSetNames) {
1381
+ if (this.dataSets[name]?.syncAbortController) {
1382
+ LoggerProxy.logger.info(
1383
+ `HashTreeParser#cancelPendingSyncsForDataSets --> ${this.debugId} aborting in-flight sync for data set "${name}"`
1384
+ );
1385
+ this.dataSets[name].syncAbortController.abort();
1386
+ }
1321
1387
  }
1322
1388
  }
1323
1389
 
@@ -1558,6 +1624,8 @@ class HashTreeParser {
1558
1624
  this.stopAllTimers();
1559
1625
  this.syncQueue = [];
1560
1626
  Object.values(this.dataSets).forEach((dataSet) => {
1627
+ dataSet.syncAbortController?.abort();
1628
+ dataSet.syncAbortController = undefined;
1561
1629
  dataSet.hashTree = undefined;
1562
1630
  });
1563
1631
  this.visibleDataSets = [];
@@ -8,6 +8,7 @@ import {expect} from '@webex/test-helper-chai';
8
8
  import sinon from 'sinon';
9
9
  import {assert} from '@webex/test-helper-chai';
10
10
  import {EMPTY_HASH} from '@webex/plugin-meetings/src/hashTree/constants';
11
+ import testUtils from '@webex/plugin-meetings/test/utils/testUtils';
11
12
  import { some } from 'lodash';
12
13
  import Metrics from '@webex/plugin-meetings/src/metrics';
13
14
  import BEHAVIORAL_METRICS from '@webex/plugin-meetings/src/metrics/constants';
@@ -4734,6 +4735,258 @@ describe('HashTreeParser', () => {
4734
4735
  });
4735
4736
  });
4736
4737
 
4738
+ describe('#performSync abort controller', () => {
4739
+ it('should reuse an existing syncAbortController if one is already set on the dataset', async () => {
4740
+ const parser = createHashTreeParser();
4741
+ const mainUrl = parser.dataSets.main.url;
4742
+
4743
+ // Pre-set an AbortController on the dataset before sync starts
4744
+ const existingController = new AbortController();
4745
+ parser.dataSets.main.syncAbortController = existingController;
4746
+
4747
+ // Use a deferred promise for GET hashtree so we can inspect the controller mid-sync
4748
+ let resolveGetHashtree;
4749
+ webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
4750
+ () =>
4751
+ new Promise((resolve) => {
4752
+ resolveGetHashtree = resolve;
4753
+ })
4754
+ );
4755
+
4756
+ // Trigger sync for main
4757
+ parser.handleMessage(
4758
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4759
+ 'trigger main sync'
4760
+ );
4761
+
4762
+ await clock.tickAsync(1000);
4763
+
4764
+ // While sync is in-flight, verify the controller is the same one we pre-set
4765
+ expect(parser.dataSets.main.syncAbortController).to.equal(existingController);
4766
+
4767
+ // Resolve GET hashtree with matching hashes (no sync needed)
4768
+ resolveGetHashtree({body: {}});
4769
+ await testUtils.flushPromises();
4770
+
4771
+ // After sync completes, syncAbortController is cleared in finally
4772
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
4773
+ });
4774
+
4775
+ it('should abort the sync before /sync request when the controller is aborted during getHashesFromLocus', async () => {
4776
+ const parser = createHashTreeParser();
4777
+ const mainUrl = parser.dataSets.main.url;
4778
+
4779
+ // Use a deferred promise for GET hashtree so we can abort while it's pending
4780
+ let resolveGetHashtree;
4781
+ webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
4782
+ () =>
4783
+ new Promise((resolve) => {
4784
+ resolveGetHashtree = resolve;
4785
+ })
4786
+ );
4787
+
4788
+ // Mock POST sync - should NOT be called if abort works
4789
+ mockSendSyncRequestResponse(mainUrl, null);
4790
+
4791
+ // Trigger sync for main via heartbeat with mismatched root hash
4792
+ parser.handleMessage(
4793
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4794
+ 'trigger main sync'
4795
+ );
4796
+
4797
+ // Fire the timer to start the sync
4798
+ await clock.tickAsync(1000);
4799
+
4800
+ // Now abort the controller while getHashesFromLocus is pending
4801
+ expect(parser.dataSets.main.syncAbortController).to.not.be.undefined;
4802
+ parser.dataSets.main.syncAbortController.abort();
4803
+
4804
+ // Resolve GET hashtree with mismatched hashes so the code would normally proceed to /sync
4805
+ resolveGetHashtree({
4806
+ body: {
4807
+ hashes: new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4808
+ dataSet: createDataSet('main', 16, 1100),
4809
+ },
4810
+ });
4811
+
4812
+ await testUtils.flushPromises();
4813
+
4814
+ // POST sync should NOT have been called because the controller was aborted
4815
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4816
+ });
4817
+
4818
+ it('should abort the sync before /sync request when the controller is aborted for leafCount === 1 datasets', async () => {
4819
+ const parser = createHashTreeParser();
4820
+ const selfUrl = parser.dataSets.self.url;
4821
+
4822
+ // Pre-set an already-aborted controller so performSync picks it up via ??
4823
+ const abortedController = new AbortController();
4824
+ abortedController.abort();
4825
+ parser.dataSets.self.syncAbortController = abortedController;
4826
+
4827
+ // Mock POST sync - should NOT be called
4828
+ mockSendSyncRequestResponse(selfUrl, null);
4829
+
4830
+ // Trigger sync for self via heartbeat with mismatched root hash
4831
+ parser.handleMessage(
4832
+ {
4833
+ dataSets: [
4834
+ {
4835
+ ...createDataSet('self', 1, 2100),
4836
+ url: parser.dataSets.self.url,
4837
+ root: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1',
4838
+ },
4839
+ ],
4840
+ visibleDataSetsUrl,
4841
+ locusUrl,
4842
+ },
4843
+ 'trigger self sync'
4844
+ );
4845
+
4846
+ // Fire the timer to start the sync
4847
+ await clock.tickAsync(1000);
4848
+
4849
+ // GET hashtree should NOT have been called (leafCount === 1 skips it)
4850
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'GET', uri: `${selfUrl}/hashtree`}));
4851
+
4852
+ // POST sync should NOT have been called because the controller was already aborted
4853
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${selfUrl}/sync`}));
4854
+ });
4855
+
4856
+ it('should unconditionally clear syncAbortController in the finally block', async () => {
4857
+ const parser = createHashTreeParser();
4858
+ const mainUrl = parser.dataSets.main.url;
4859
+
4860
+ // Mock GET hashtree to return matching hashes (early return, no sync needed)
4861
+ webexRequest
4862
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4863
+ .resolves({body: {}});
4864
+
4865
+ // Trigger sync for main
4866
+ parser.handleMessage(
4867
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4868
+ 'trigger main sync'
4869
+ );
4870
+
4871
+ await clock.tickAsync(1000);
4872
+
4873
+ // After sync completes (even via early return), syncAbortController should be cleared
4874
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
4875
+ });
4876
+
4877
+ it('should unconditionally clear syncAbortController even when sync throws an error', async () => {
4878
+ const parser = createHashTreeParser();
4879
+ const mainUrl = parser.dataSets.main.url;
4880
+
4881
+ // Mock GET hashtree to reject with a non-409 error
4882
+ webexRequest
4883
+ .withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`}))
4884
+ .rejects({statusCode: 500, message: 'Internal Server Error'});
4885
+
4886
+ // Trigger sync for main
4887
+ parser.handleMessage(
4888
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4889
+ 'trigger main sync'
4890
+ );
4891
+
4892
+ await clock.tickAsync(1000);
4893
+
4894
+ // After sync completes with error, syncAbortController should still be cleared
4895
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
4896
+ });
4897
+
4898
+ it('should reuse a pre-existing abort controller and respect its aborted state', async () => {
4899
+ const parser = createHashTreeParser();
4900
+ const mainUrl = parser.dataSets.main.url;
4901
+
4902
+ // Pre-set an AbortController and abort it before sync starts
4903
+ const preAbortedController = new AbortController();
4904
+ preAbortedController.abort();
4905
+ parser.dataSets.main.syncAbortController = preAbortedController;
4906
+
4907
+ // Mock GET hashtree to return mismatched hashes
4908
+ mockGetHashesFromLocusResponse(
4909
+ mainUrl,
4910
+ new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4911
+ createDataSet('main', 16, 1100)
4912
+ );
4913
+
4914
+ // Mock POST sync - should NOT be called
4915
+ mockSendSyncRequestResponse(mainUrl, null);
4916
+
4917
+ // Trigger sync for main
4918
+ parser.handleMessage(
4919
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4920
+ 'trigger main sync'
4921
+ );
4922
+
4923
+ await clock.tickAsync(1000);
4924
+
4925
+ // POST sync should NOT have been called because the reused controller was already aborted
4926
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4927
+
4928
+ // syncAbortController should be cleaned up
4929
+ expect(parser.dataSets.main.syncAbortController).to.be.undefined;
4930
+ });
4931
+
4932
+ it('should allow cancelPendingSyncsForDataSets to abort an in-flight sync via the shared controller', async () => {
4933
+ const parser = createHashTreeParser();
4934
+ const mainUrl = parser.dataSets.main.url;
4935
+
4936
+ // Use a deferred promise for GET hashtree
4937
+ let resolveGetHashtree;
4938
+ webexRequest.withArgs(sinon.match({method: 'GET', uri: `${mainUrl}/hashtree`})).callsFake(
4939
+ () =>
4940
+ new Promise((resolve) => {
4941
+ resolveGetHashtree = resolve;
4942
+ })
4943
+ );
4944
+
4945
+ mockSendSyncRequestResponse(mainUrl, null);
4946
+
4947
+ // Trigger sync for main
4948
+ parser.handleMessage(
4949
+ createHeartbeatMessage('main', 16, 1100, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4950
+ 'trigger main sync'
4951
+ );
4952
+
4953
+ // Fire the timer to start sync
4954
+ await clock.tickAsync(1000);
4955
+
4956
+ // Verify controller is set
4957
+ expect(parser.dataSets.main.syncAbortController).to.not.be.undefined;
4958
+
4959
+ // Simulate a new heartbeat arriving that cancels the in-flight sync
4960
+ // (this is what happens in production via parseMessage -> cancelPendingSyncsForDataSets)
4961
+ parser.handleMessage(
4962
+ {
4963
+ dataSets: [
4964
+ {
4965
+ ...createDataSet('main', 16, 1101),
4966
+ root: parser.dataSets.main.hashTree.getRootHash(), // matching hash so no new sync
4967
+ },
4968
+ ],
4969
+ visibleDataSetsUrl,
4970
+ locusUrl,
4971
+ },
4972
+ 'new heartbeat cancels sync'
4973
+ );
4974
+
4975
+ // Resolve the pending GET hashtree with mismatched hashes
4976
+ resolveGetHashtree({
4977
+ body: {
4978
+ hashes: new Array(16).fill('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1'),
4979
+ dataSet: createDataSet('main', 16, 1101),
4980
+ },
4981
+ });
4982
+
4983
+ await testUtils.flushPromises();
4984
+
4985
+ // POST sync should NOT have been called because cancelPendingSyncsForDataSets aborted the controller
4986
+ assert.neverCalledWith(webexRequest, sinon.match({method: 'POST', uri: `${mainUrl}/sync`}));
4987
+ });
4988
+ });
4989
+
4737
4990
  describe('#cleanUp', () => {
4738
4991
  it('should stop the parser, clear all timers and clear all dataSets', () => {
4739
4992
  const parser = createHashTreeParser();