@webex/internal-plugin-mercury 3.9.0-next.5 → 3.9.0-next.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mercury.js CHANGED
@@ -96,6 +96,80 @@ const Mercury = WebexPlugin.extend({
96
96
  });
97
97
  },
98
98
 
99
+ /**
100
+ * Attach event listeners to a socket.
101
+ * @param {Socket} socket - The socket to attach listeners to
102
+ * @returns {void}
103
+ */
104
+ _attachSocketEventListeners(socket) {
105
+ socket.on('close', (event) => this._onclose(event, socket));
106
+ socket.on('message', (...args) => this._onmessage(...args));
107
+ socket.on('pong', (...args) => this._setTimeOffset(...args));
108
+ socket.on('sequence-mismatch', (...args) => this._emit('sequence-mismatch', ...args));
109
+ socket.on('ping-pong-latency', (...args) => this._emit('ping-pong-latency', ...args));
110
+ },
111
+
112
+ /**
113
+ * Handle imminent shutdown by establishing a new connection while keeping
114
+ * the current one alive (make-before-break).
115
+ * Idempotent: will no-op if already in progress.
116
+ * @returns {void}
117
+ */
118
+ _handleImminentShutdown() {
119
+ try {
120
+ if (this._shutdownSwitchoverInProgress) {
121
+ this.logger.info(`${this.namespace}: [shutdown] switchover already in progress`);
122
+
123
+ return;
124
+ }
125
+ this._shutdownSwitchoverInProgress = true;
126
+ this._shutdownSwitchoverId = `${Date.now()}`;
127
+ this.logger.info(
128
+ `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId}`
129
+ );
130
+
131
+ this._connectWithBackoff(undefined, {
132
+ isShutdownSwitchover: true,
133
+ attemptOptions: {
134
+ isShutdownSwitchover: true,
135
+ onSuccess: (newSocket, webSocketUrl) => {
136
+ this.logger.info(
137
+ `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl}`
138
+ );
139
+
140
+ const oldSocket = this.socket;
141
+ // Atomically switch active socket reference
142
+ this.socket = newSocket;
143
+ this.connected = true; // remain connected throughout
144
+
145
+ this._emit('event:mercury_shutdown_switchover_complete', {url: webSocketUrl});
146
+
147
+ if (oldSocket) {
148
+ this.logger.info(
149
+ `${this.namespace}: [shutdown] old socket retained; server will close with 4001`
150
+ );
151
+ }
152
+ },
153
+ },
154
+ })
155
+ .then(() => {
156
+ this.logger.info(`${this.namespace}: [shutdown] switchover completed successfully`);
157
+ })
158
+ .catch((err) => {
159
+ this.logger.info(
160
+ `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection`,
161
+ err
162
+ );
163
+ this._emit('event:mercury_shutdown_switchover_failed', {reason: err});
164
+ // Old socket will eventually close with 4001, triggering normal reconnection
165
+ });
166
+ } catch (e) {
167
+ this.logger.error(`${this.namespace}: [shutdown] error during switchover`, e);
168
+ this._shutdownSwitchoverInProgress = false;
169
+ this._emit('event:mercury_shutdown_switchover_failed', {reason: e});
170
+ }
171
+ },
172
+
99
173
  /**
100
174
  * Get the last error.
101
175
  * @returns {any} The last error.
@@ -152,6 +226,11 @@ const Mercury = WebexPlugin.extend({
152
226
  this.backoffCall.abort();
153
227
  }
154
228
 
229
+ if (this._shutdownSwitchoverBackoffCall) {
230
+ this.logger.info(`${this.namespace}: aborting shutdown switchover`);
231
+ this._shutdownSwitchoverBackoffCall.abort();
232
+ }
233
+
155
234
  if (this.socket) {
156
235
  this.socket.removeAllListeners('message');
157
236
  this.once('offline', resolve);
@@ -233,55 +312,63 @@ const Mercury = WebexPlugin.extend({
233
312
  });
234
313
  },
235
314
 
236
- _attemptConnection(socketUrl, callback) {
315
+ _attemptConnection(socketUrl, callback, options = {}) {
316
+ const {isShutdownSwitchover = false, onSuccess = null} = options;
317
+
237
318
  const socket = new Socket();
238
- let attemptWSUrl;
319
+ let newWSUrl;
239
320
 
240
- socket.on('close', (...args) => this._onclose(...args));
241
- socket.on('message', (...args) => this._onmessage(...args));
242
- socket.on('pong', (...args) => this._setTimeOffset(...args));
243
- socket.on('sequence-mismatch', (...args) => this._emit('sequence-mismatch', ...args));
244
- socket.on('ping-pong-latency', (...args) => this._emit('ping-pong-latency', ...args));
321
+ this._attachSocketEventListeners(socket);
245
322
 
246
- Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()])
247
- .then(([webSocketUrl, token]) => {
248
- if (!this.backoffCall) {
249
- const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
323
+ // Check appropriate backoff call based on connection type
324
+ if (isShutdownSwitchover && !this._shutdownSwitchoverBackoffCall) {
325
+ const msg = `${this.namespace}: prevent socket open when switchover backoff call no longer defined`;
326
+ const err = new Error(msg);
250
327
 
251
- this.logger.info(msg);
328
+ this.logger.info(msg);
252
329
 
253
- return Promise.reject(new Error(msg));
254
- }
330
+ // Call the callback with the error before rejecting
331
+ callback(err);
255
332
 
256
- attemptWSUrl = webSocketUrl;
333
+ return Promise.reject(err);
334
+ }
257
335
 
258
- let options = {
259
- forceCloseDelay: this.config.forceCloseDelay,
260
- pingInterval: this.config.pingInterval,
261
- pongTimeout: this.config.pongTimeout,
262
- token: token.toString(),
263
- trackingId: `${this.webex.sessionId}_${Date.now()}`,
264
- logger: this.logger,
265
- };
336
+ if (!isShutdownSwitchover && !this.backoffCall) {
337
+ const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
338
+ const err = new Error(msg);
266
339
 
267
- // if the consumer has supplied request options use them
268
- if (this.webex.config.defaultMercuryOptions) {
269
- this.logger.info(`${this.namespace}: setting custom options`);
270
- options = {...options, ...this.webex.config.defaultMercuryOptions};
271
- }
340
+ this.logger.info(msg);
341
+
342
+ // Call the callback with the error before rejecting
343
+ callback(err);
272
344
 
273
- // Set the socket before opening it. This allows a disconnect() to close
274
- // the socket if it is in the process of being opened.
275
- this.socket = socket;
345
+ return Promise.reject(err);
346
+ }
276
347
 
277
- this.logger.info(`${this.namespace} connection url: ${webSocketUrl}`);
348
+ // For shutdown switchover, don't set socket yet (make-before-break)
349
+ // For normal connection, set socket before opening to allow disconnect() to close it
350
+ if (!isShutdownSwitchover) {
351
+ this.socket = socket;
352
+ }
278
353
 
279
- return socket.open(webSocketUrl, options);
280
- })
281
- .then(() => {
354
+ return this._prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover)
355
+ .then((webSocketUrl) => {
356
+ newWSUrl = webSocketUrl;
282
357
  this.logger.info(
283
- `${this.namespace}: connected to mercury, success, action: connected, url: ${attemptWSUrl}`
358
+ `${this.namespace}: ${
359
+ isShutdownSwitchover ? '[shutdown] switchover' : ''
360
+ } connected to mercury, success, action: connected, url: ${newWSUrl}`
284
361
  );
362
+
363
+ // Custom success handler for shutdown switchover
364
+ if (onSuccess) {
365
+ onSuccess(socket, webSocketUrl);
366
+ callback();
367
+
368
+ return Promise.resolve();
369
+ }
370
+
371
+ // Default behavior for normal connection
285
372
  callback();
286
373
 
287
374
  return this.webex.internal.feature
@@ -295,6 +382,14 @@ const Mercury = WebexPlugin.extend({
295
382
  });
296
383
  })
297
384
  .catch((reason) => {
385
+ // For shutdown, simpler error handling - just callback for retry
386
+ if (isShutdownSwitchover) {
387
+ this.logger.info(`${this.namespace}: [shutdown] switchover attempt failed`, reason);
388
+
389
+ return callback(reason);
390
+ }
391
+
392
+ // Normal connection error handling (existing complex logic)
298
393
  this.lastError = reason; // remember the last error
299
394
 
300
395
  // Suppress connection errors that appear to be network related. This
@@ -344,10 +439,10 @@ const Mercury = WebexPlugin.extend({
344
439
  .then((haMessagingEnabled) => {
345
440
  if (haMessagingEnabled) {
346
441
  this.logger.info(
347
- `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${attemptWSUrl} error: ${reason.message}`
442
+ `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}`
348
443
  );
349
444
 
350
- return this.webex.internal.services.markFailedUrl(attemptWSUrl);
445
+ return this.webex.internal.services.markFailedUrl(newWSUrl);
351
446
  }
352
447
 
353
448
  return null;
@@ -363,36 +458,83 @@ const Mercury = WebexPlugin.extend({
363
458
  });
364
459
  },
365
460
 
366
- _connectWithBackoff(webSocketUrl) {
461
+ _prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover = false) {
462
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
463
+
464
+ return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(
465
+ ([webSocketUrl, token]) => {
466
+ let options = {
467
+ forceCloseDelay: this.config.forceCloseDelay,
468
+ pingInterval: this.config.pingInterval,
469
+ pongTimeout: this.config.pongTimeout,
470
+ token: token.toString(),
471
+ trackingId: `${this.webex.sessionId}_${Date.now()}`,
472
+ logger: this.logger,
473
+ };
474
+
475
+ if (this.webex.config.defaultMercuryOptions) {
476
+ const customOptionsMsg = isShutdownSwitchover
477
+ ? 'setting custom options for switchover'
478
+ : 'setting custom options';
479
+
480
+ this.logger.info(`${this.namespace}: ${customOptionsMsg}`);
481
+ options = {...options, ...this.webex.config.defaultMercuryOptions};
482
+ }
483
+
484
+ this.logger.info(`${this.namespace}: ${logPrefix} url: ${webSocketUrl}`);
485
+
486
+ return socket.open(webSocketUrl, options).then(() => webSocketUrl);
487
+ }
488
+ );
489
+ },
490
+
491
+ _connectWithBackoff(webSocketUrl, context = {}) {
492
+ const {isShutdownSwitchover = false, attemptOptions = {}} = context;
493
+
367
494
  return new Promise((resolve, reject) => {
368
495
  // eslint gets confused about whether or not call is actually used
369
496
  // eslint-disable-next-line prefer-const
370
497
  let call;
371
498
  const onComplete = (err) => {
372
- this.connecting = false;
499
+ // Clear state flags based on connection type
500
+ if (isShutdownSwitchover) {
501
+ this._shutdownSwitchoverInProgress = false;
502
+ this._shutdownSwitchoverBackoffCall = undefined;
503
+ } else {
504
+ this.connecting = false;
505
+ this.backoffCall = undefined;
506
+ }
373
507
 
374
- this.backoffCall = undefined;
375
508
  if (err) {
509
+ const msg = isShutdownSwitchover
510
+ ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
511
+ : `failed to connect after ${call.getNumRetries()} retries`;
512
+
376
513
  this.logger.info(
377
- `${
378
- this.namespace
379
- }: failed to connect after ${call.getNumRetries()} retries; log statement about next retry was inaccurate; ${err}`
514
+ `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}`
380
515
  );
381
516
 
382
517
  return reject(err);
383
518
  }
384
- this.connected = true;
385
- this.hasEverConnected = true;
386
- this._emit('online');
387
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
519
+
520
+ // Default success handling for normal connections
521
+ if (!isShutdownSwitchover) {
522
+ this.connected = true;
523
+ this.hasEverConnected = true;
524
+ this._emit('online');
525
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
526
+ }
388
527
 
389
528
  return resolve();
390
529
  };
391
530
 
392
531
  // eslint-disable-next-line prefer-reflect
393
532
  call = backoff.call((callback) => {
394
- this.logger.info(`${this.namespace}: executing connection attempt ${call.getNumRetries()}`);
395
- this._attemptConnection(webSocketUrl, callback);
533
+ const attemptNum = call.getNumRetries();
534
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
535
+
536
+ this.logger.info(`${this.namespace}: executing ${logPrefix} attempt ${attemptNum}`);
537
+ this._attemptConnection(webSocketUrl, callback, attemptOptions);
396
538
  }, onComplete);
397
539
 
398
540
  call.setStrategy(
@@ -402,24 +544,33 @@ const Mercury = WebexPlugin.extend({
402
544
  })
403
545
  );
404
546
 
405
- if (this.config.initialConnectionMaxRetries && !this.hasEverConnected) {
547
+ if (
548
+ this.config.initialConnectionMaxRetries &&
549
+ !this.hasEverConnected &&
550
+ !isShutdownSwitchover
551
+ ) {
406
552
  call.failAfter(this.config.initialConnectionMaxRetries);
407
553
  } else if (this.config.maxRetries) {
408
554
  call.failAfter(this.config.maxRetries);
409
555
  }
410
556
 
411
557
  call.on('abort', () => {
412
- this.logger.info(`${this.namespace}: connection aborted`);
413
- reject(new Error('Mercury Connection Aborted'));
558
+ const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
559
+
560
+ this.logger.info(`${this.namespace}: ${msg} aborted`);
561
+ reject(new Error(`Mercury ${msg} Aborted`));
414
562
  });
415
563
 
416
564
  call.on('callback', (err) => {
417
565
  if (err) {
418
566
  const number = call.getNumRetries();
419
567
  const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax);
568
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
420
569
 
421
570
  this.logger.info(
422
- `${this.namespace}: failed to connect; attempting retry ${number + 1} in ${delay} ms`
571
+ `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${
572
+ number + 1
573
+ } in ${delay} ms`
423
574
  );
424
575
  /* istanbul ignore if */
425
576
  if (process.env.NODE_ENV === 'development') {
@@ -431,9 +582,14 @@ const Mercury = WebexPlugin.extend({
431
582
  this.logger.info(`${this.namespace}: connected`);
432
583
  });
433
584
 
434
- call.start();
585
+ // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
586
+ if (isShutdownSwitchover) {
587
+ this._shutdownSwitchoverBackoffCall = call;
588
+ } else {
589
+ this.backoffCall = call;
590
+ }
435
591
 
436
- this.backoffCall = call;
592
+ call.start();
437
593
  });
438
594
  },
439
595
 
@@ -470,19 +626,42 @@ const Mercury = WebexPlugin.extend({
470
626
  return handlers;
471
627
  },
472
628
 
473
- _onclose(event) {
629
+ _onclose(event, sourceSocket) {
474
630
  // I don't see any way to avoid the complexity or statement count in here.
475
631
  /* eslint complexity: [0] */
476
632
 
477
633
  try {
634
+ const isActiveSocket = sourceSocket === this.socket;
478
635
  const reason = event.reason && event.reason.toLowerCase();
479
- const socketUrl = this.socket.url;
480
636
 
481
- this.socket.removeAllListeners();
482
- this.unset('socket');
483
- this.connected = false;
484
- this._emit('offline', event);
485
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
637
+ let socketUrl;
638
+ if (isActiveSocket && this.socket) {
639
+ // Active socket closed - get URL from current socket reference
640
+ socketUrl = this.socket.url;
641
+ } else if (sourceSocket) {
642
+ // Old socket closed - get URL from the closed socket
643
+ socketUrl = sourceSocket.url;
644
+ }
645
+
646
+ if (isActiveSocket) {
647
+ // Only tear down state if the currently active socket closed
648
+ if (this.socket) {
649
+ this.socket.removeAllListeners();
650
+ }
651
+ this.unset('socket');
652
+ this.connected = false;
653
+ this._emit('offline', event);
654
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
655
+ } else {
656
+ // Old socket closed; do not flip connection state
657
+ this.logger.info(
658
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code}`
659
+ );
660
+ // Clean up listeners from old socket now that it's closed
661
+ if (sourceSocket) {
662
+ sourceSocket.removeAllListeners();
663
+ }
664
+ }
486
665
 
487
666
  switch (event.code) {
488
667
  case 1003:
@@ -490,20 +669,42 @@ const Mercury = WebexPlugin.extend({
490
669
  this.logger.info(
491
670
  `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}`
492
671
  );
493
- this._emit('offline.permanent', event);
672
+ if (isActiveSocket) this._emit('offline.permanent', event);
494
673
  break;
495
674
  case 4000:
496
675
  // metric: disconnect
497
676
  this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
498
- this._emit('offline.replaced', event);
677
+ if (isActiveSocket) this._emit('offline.replaced', event);
678
+ // If not active, nothing to do
679
+ break;
680
+ case 4001:
681
+ // replaced during shutdown
682
+ if (isActiveSocket) {
683
+ // Server closed active socket with 4001, meaning it expected this connection
684
+ // to be replaced, but the switchover in _handleImminentShutdown failed.
685
+ // This is a permanent failure - do not reconnect.
686
+ this.logger.warn(
687
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed`
688
+ );
689
+ this._emit('offline.permanent', event);
690
+ } else {
691
+ // Expected: old socket closed after successful switchover
692
+ this.logger.info(
693
+ `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed`
694
+ );
695
+ this._emit('offline.replaced', event);
696
+ }
499
697
  break;
500
698
  case 1001:
501
699
  case 1005:
502
700
  case 1006:
503
701
  case 1011:
504
702
  this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
505
- this._emit('offline.transient', event);
506
- this._reconnect(socketUrl);
703
+ if (isActiveSocket) {
704
+ this._emit('offline.transient', event);
705
+ this.logger.info(`${this.namespace}: [shutdown] reconnecting active socket to recover`);
706
+ this._reconnect(socketUrl);
707
+ }
507
708
  // metric: disconnect
508
709
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
509
710
  break;
@@ -511,15 +712,18 @@ const Mercury = WebexPlugin.extend({
511
712
  case 3050: // 3050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block
512
713
  if (normalReconnectReasons.includes(reason)) {
513
714
  this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
514
- this._emit('offline.transient', event);
515
- this._reconnect(socketUrl);
715
+ if (isActiveSocket) {
716
+ this._emit('offline.transient', event);
717
+ this.logger.info(`${this.namespace}: [shutdown] reconnecting due to normal close`);
718
+ this._reconnect(socketUrl);
719
+ }
516
720
  // metric: disconnect
517
721
  // if (reason === done forced) metric: force closure
518
722
  } else {
519
723
  this.logger.info(
520
724
  `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
521
725
  );
522
- this._emit('offline.permanent', event);
726
+ if (isActiveSocket) this._emit('offline.permanent', event);
523
727
  }
524
728
  break;
525
729
  default:
@@ -527,7 +731,7 @@ const Mercury = WebexPlugin.extend({
527
731
  `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
528
732
  );
529
733
  // unexpected disconnect
530
- this._emit('offline.permanent', event);
734
+ if (isActiveSocket) this._emit('offline.permanent', event);
531
735
  }
532
736
  } catch (error) {
533
737
  this.logger.error(`${this.namespace}: error occurred in close handler`, error);
@@ -542,6 +746,16 @@ const Mercury = WebexPlugin.extend({
542
746
  this.logger.debug(`${this.namespace}: message envelope: `, envelope);
543
747
  }
544
748
 
749
+ // Handle shutdown message shape: { type: 'shutdown' }
750
+ if (envelope && envelope.type === 'shutdown') {
751
+ this.logger.info(`${this.namespace}: [shutdown] imminent shutdown message received`);
752
+ this._emit('event:mercury_shutdown_imminent', envelope);
753
+
754
+ this._handleImminentShutdown();
755
+
756
+ return Promise.resolve();
757
+ }
758
+
545
759
  const {data} = envelope;
546
760
 
547
761
  this._applyOverrides(data);