@webex/internal-plugin-mercury 3.9.0 → 3.10.0-multi-llms.1

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
@@ -6,7 +6,7 @@
6
6
  import url from 'url';
7
7
 
8
8
  import {WebexPlugin} from '@webex/webex-core';
9
- import {deprecated, oneFlight} from '@webex/common';
9
+ import {deprecated} from '@webex/common';
10
10
  import {camelCase, get, set} from 'lodash';
11
11
  import backoff from 'backoff';
12
12
 
@@ -25,6 +25,7 @@ const normalReconnectReasons = ['idle', 'done (forced)', 'pong not received', 'p
25
25
  const Mercury = WebexPlugin.extend({
26
26
  namespace: 'Mercury',
27
27
  lastError: undefined,
28
+ defaultSessionId: 'mercury-default-session',
28
29
 
29
30
  session: {
30
31
  connected: {
@@ -39,7 +40,18 @@ const Mercury = WebexPlugin.extend({
39
40
  default: false,
40
41
  type: 'boolean',
41
42
  },
42
- socket: 'object',
43
+ sockets: {
44
+ default: () => new Map(),
45
+ type: 'object',
46
+ },
47
+ backoffCalls: {
48
+ default: () => new Map(),
49
+ type: 'object',
50
+ },
51
+ _shutdownSwitchoverBackoffCalls: {
52
+ default: () => new Map(),
53
+ type: 'object',
54
+ },
43
55
  localClusterServiceUrls: 'object',
44
56
  mercuryTimeOffset: {
45
57
  default: undefined,
@@ -68,6 +80,121 @@ const Mercury = WebexPlugin.extend({
68
80
  this.webex.internal.feature.updateFeature(envelope.data.featureToggle);
69
81
  }
70
82
  });
83
+ /*
84
+ * When Cluster Migrations, notify clients using ActiveClusterStatusEvent via mercury
85
+ * https://wwwin-github.cisco.com/pages/Webex/crr-docs/techdocs/rr-002.html#wip-notifying-clients-of-cluster-migrations
86
+ * */
87
+ this.on('event:ActiveClusterStatusEvent', (envelope) => {
88
+ if (
89
+ typeof this.webex.internal.services?.switchActiveClusterIds === 'function' &&
90
+ envelope &&
91
+ envelope.data
92
+ ) {
93
+ this.webex.internal.services.switchActiveClusterIds(envelope.data?.activeClusters);
94
+ }
95
+ });
96
+ /*
97
+ * Using cache-invalidation via mercury to instead the method of polling via the new /timestamp endpoint from u2c
98
+ * https://wwwin-github.cisco.com/pages/Webex/crr-docs/techdocs/rr-005.html#websocket-notifications
99
+ * */
100
+ this.on('event:u2c.cache-invalidation', (envelope) => {
101
+ if (
102
+ typeof this.webex.internal.services?.invalidateCache === 'function' &&
103
+ envelope &&
104
+ envelope.data
105
+ ) {
106
+ this.webex.internal.services.invalidateCache(envelope.data?.timestamp);
107
+ }
108
+ });
109
+ },
110
+
111
+ /**
112
+ * Attach event listeners to a socket.
113
+ * @param {Socket} socket - The socket to attach listeners to
114
+ * @param {sessionId} sessionId - The socket related session ID
115
+ * @returns {void}
116
+ */
117
+ _attachSocketEventListeners(socket, sessionId) {
118
+ socket.on('close', (event) => this._onclose(sessionId, event, socket));
119
+ socket.on('message', (...args) => this._onmessage(sessionId, ...args));
120
+ socket.on('pong', (...args) => this._setTimeOffset(sessionId, ...args));
121
+ socket.on('sequence-mismatch', (...args) =>
122
+ this._emit(sessionId, 'sequence-mismatch', ...args)
123
+ );
124
+ socket.on('ping-pong-latency', (...args) =>
125
+ this._emit(sessionId, 'ping-pong-latency', ...args)
126
+ );
127
+ },
128
+
129
+ /**
130
+ * Handle imminent shutdown by establishing a new connection while keeping
131
+ * the current one alive (make-before-break).
132
+ * Idempotent: will no-op if already in progress.
133
+ * @param {string} sessionId - The session ID for which the shutdown is imminent
134
+ * @returns {void}
135
+ */
136
+ _handleImminentShutdown(sessionId) {
137
+ const oldSocket = this.sockets.get(sessionId);
138
+
139
+ try {
140
+ if (this._shutdownSwitchoverBackoffCalls.get(sessionId)) {
141
+ this.logger.info(
142
+ `${this.namespace}: [shutdown] switchover already in progress for ${sessionId}`
143
+ );
144
+
145
+ return;
146
+ }
147
+ this._shutdownSwitchoverId = `${Date.now()}`;
148
+ this.logger.info(
149
+ `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId} for ${sessionId}`
150
+ );
151
+
152
+ this._connectWithBackoff(undefined, sessionId, {
153
+ isShutdownSwitchover: true,
154
+ attemptOptions: {
155
+ isShutdownSwitchover: true,
156
+ onSuccess: (newSocket, webSocketUrl) => {
157
+ this.logger.info(
158
+ `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl} for ${sessionId}`
159
+ );
160
+
161
+ // Atomically switch active socket reference
162
+ this.socket = this.sockets.get(this.defaultSessionId) || newSocket;
163
+ this.connected = this.hasConnectedSockets(); // remain connected throughout
164
+
165
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_complete', {
166
+ url: webSocketUrl,
167
+ });
168
+
169
+ if (oldSocket) {
170
+ this.logger.info(
171
+ `${this.namespace}: [shutdown] old socket retained; server will close with 4001`
172
+ );
173
+ }
174
+ },
175
+ },
176
+ })
177
+ .then(() => {
178
+ this.logger.info(
179
+ `${this.namespace}: [shutdown] switchover completed successfully for ${sessionId}`
180
+ );
181
+ })
182
+ .catch((err) => {
183
+ this.logger.info(
184
+ `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection for ${sessionId}: `,
185
+ err
186
+ );
187
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: err});
188
+ // Old socket will eventually close with 4001, triggering normal reconnection
189
+ });
190
+ } catch (e) {
191
+ this.logger.error(
192
+ `${this.namespace}: [shutdown] error during switchover for ${sessionId}`,
193
+ e
194
+ );
195
+ this._shutdownSwitchoverBackoffCalls.delete(sessionId);
196
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: e});
197
+ }
71
198
  },
72
199
 
73
200
  /**
@@ -78,29 +205,96 @@ const Mercury = WebexPlugin.extend({
78
205
  return this.lastError;
79
206
  },
80
207
 
81
- @oneFlight
82
- connect(webSocketUrl) {
83
- if (this.connected) {
84
- this.logger.info(`${this.namespace}: already connected, will not connect again`);
208
+ /**
209
+ * Get all active socket connections
210
+ * @returns {Map} Map of sessionId to socket instances
211
+ */
212
+ getSockets() {
213
+ return this.sockets;
214
+ },
215
+
216
+ /**
217
+ * Get a specific socket by connection ID
218
+ * @param {string} sessionId - The connection identifier
219
+ * @returns {Socket|undefined} The socket instance or undefined if not found
220
+ */
221
+ getSocket(sessionId = this.defaultSessionId) {
222
+ return this.sockets.get(sessionId);
223
+ },
224
+
225
+ /**
226
+ * Check if any sockets are connected
227
+ * @returns {boolean} True if at least one socket is connected
228
+ */
229
+ hasConnectedSockets() {
230
+ for (const socket of this.sockets.values()) {
231
+ if (socket && socket.connected) {
232
+ return true;
233
+ }
234
+ }
235
+
236
+ return false;
237
+ },
238
+
239
+ /**
240
+ * Check if any sockets are connecting
241
+ * @returns {boolean} True if at least one socket is connected
242
+ */
243
+ hasConnectingSockets() {
244
+ for (const socket of this.sockets.values()) {
245
+ if (socket && socket.connecting) {
246
+ return true;
247
+ }
248
+ }
249
+
250
+ return false;
251
+ },
252
+
253
+ // @oneFlight
254
+ connect(webSocketUrl, sessionId = this.defaultSessionId) {
255
+ if (!this._connectPromises) this._connectPromises = new Map();
256
+
257
+ // First check if there's already a connection promise for this session
258
+ if (this._connectPromises.has(sessionId)) {
259
+ this.logger.info(
260
+ `${this.namespace}: connection ${sessionId} already in progress, returning existing promise`
261
+ );
262
+
263
+ return this._connectPromises.get(sessionId);
264
+ }
265
+
266
+ const sessionSocket = this.sockets.get(sessionId);
267
+ if (sessionSocket?.connected || sessionSocket?.connecting) {
268
+ this.logger.info(
269
+ `${this.namespace}: connection ${sessionId} already connected, will not connect again`
270
+ );
85
271
 
86
272
  return Promise.resolve();
87
273
  }
88
274
 
89
275
  this.connecting = true;
90
276
 
91
- this.logger.info(`${this.namespace}: starting connection attempt`);
277
+ this.logger.info(`${this.namespace}: starting connection attempt for ${sessionId}`);
92
278
  this.logger.info(
93
279
  `${this.namespace}: debug_mercury_logging stack: `,
94
280
  new Error('debug_mercury_logging').stack
95
281
  );
96
282
 
97
- return Promise.resolve(
283
+ const connectPromise = Promise.resolve(
98
284
  this.webex.internal.device.registered || this.webex.internal.device.register()
99
- ).then(() => {
100
- this.logger.info(`${this.namespace}: connecting`);
285
+ )
286
+ .then(() => {
287
+ this.logger.info(`${this.namespace}: connecting ${sessionId}`);
101
288
 
102
- return this._connectWithBackoff(webSocketUrl);
103
- });
289
+ return this._connectWithBackoff(webSocketUrl, sessionId);
290
+ })
291
+ .finally(() => {
292
+ this._connectPromises.delete(sessionId);
293
+ });
294
+
295
+ this._connectPromises.set(sessionId, connectPromise);
296
+
297
+ return connectPromise;
104
298
  },
105
299
 
106
300
  logout() {
@@ -110,7 +304,7 @@ const Mercury = WebexPlugin.extend({
110
304
  new Error('debug_mercury_logging').stack
111
305
  );
112
306
 
113
- return this.disconnect(
307
+ return this.disconnectAll(
114
308
  this.config.beforeLogoutOptionsCloseReason &&
115
309
  !normalReconnectReasons.includes(this.config.beforeLogoutOptionsCloseReason)
116
310
  ? {code: 3050, reason: this.config.beforeLogoutOptionsCloseReason}
@@ -118,21 +312,63 @@ const Mercury = WebexPlugin.extend({
118
312
  );
119
313
  },
120
314
 
121
- @oneFlight
122
- disconnect(options) {
315
+ // @oneFlight
316
+ disconnect(options, sessionId = this.defaultSessionId) {
123
317
  return new Promise((resolve) => {
124
- if (this.backoffCall) {
125
- this.logger.info(`${this.namespace}: aborting connection`);
126
- this.backoffCall.abort();
318
+ const backoffCall = this.backoffCalls.get(sessionId);
319
+ if (backoffCall) {
320
+ this.logger.info(`${this.namespace}: aborting connection ${sessionId}`);
321
+ backoffCall.abort();
322
+ this.backoffCalls.delete(sessionId);
127
323
  }
128
-
129
- if (this.socket) {
130
- this.socket.removeAllListeners('message');
131
- this.once('offline', resolve);
132
- resolve(this.socket.close(options || undefined));
324
+ const shutdownSwitchoverBackoffCalls = this._shutdownSwitchoverBackoffCalls.get(sessionId);
325
+ if (shutdownSwitchoverBackoffCalls) {
326
+ this.logger.info(`${this.namespace}: aborting shutdown switchover connection ${sessionId}`);
327
+ shutdownSwitchoverBackoffCalls.abort();
328
+ this._shutdownSwitchoverBackoffCalls.delete(sessionId);
133
329
  }
330
+ // Clean up any pending connection promises
331
+ if (this._connectPromises) {
332
+ this._connectPromises.delete(sessionId);
333
+ }
334
+
335
+ const sessionSocket = this.sockets.get(sessionId);
336
+ const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`;
134
337
 
338
+ if (sessionSocket) {
339
+ sessionSocket.removeAllListeners('message');
340
+ sessionSocket.connecting = false;
341
+ sessionSocket.connected = false;
342
+ this.once(sessionId === this.defaultSessionId ? 'offline' : `offline${suffix}`, resolve);
343
+ resolve(sessionSocket.close(options || undefined));
344
+ }
135
345
  resolve();
346
+
347
+ // Update overall connected status
348
+ this.connected = this.hasConnectedSockets();
349
+ });
350
+ },
351
+
352
+ /**
353
+ * Disconnect all socket connections
354
+ * @param {object} options - Close options
355
+ * @returns {Promise} Promise that resolves when all connections are closed
356
+ */
357
+ disconnectAll(options) {
358
+ const disconnectPromises = [];
359
+
360
+ for (const sessionId of this.sockets.keys()) {
361
+ disconnectPromises.push(this.disconnect(options, sessionId));
362
+ }
363
+
364
+ return Promise.all(disconnectPromises).then(() => {
365
+ this.connected = false;
366
+ this.sockets.clear();
367
+ this.backoffCalls.clear();
368
+ // Clear connection promises to prevent stale promises
369
+ if (this._connectPromises) {
370
+ this._connectPromises.clear();
371
+ }
136
372
  });
137
373
  },
138
374
 
@@ -207,55 +443,69 @@ const Mercury = WebexPlugin.extend({
207
443
  });
208
444
  },
209
445
 
210
- _attemptConnection(socketUrl, callback) {
446
+ _attemptConnection(socketUrl, sessionId, callback, options = {}) {
447
+ const {isShutdownSwitchover = false, onSuccess = null} = options;
448
+
211
449
  const socket = new Socket();
212
- let attemptWSUrl;
450
+ socket.connecting = true;
451
+ let newWSUrl;
213
452
 
214
- socket.on('close', (...args) => this._onclose(...args));
215
- socket.on('message', (...args) => this._onmessage(...args));
216
- socket.on('pong', (...args) => this._setTimeOffset(...args));
217
- socket.on('sequence-mismatch', (...args) => this._emit('sequence-mismatch', ...args));
218
- socket.on('ping-pong-latency', (...args) => this._emit('ping-pong-latency', ...args));
453
+ this._attachSocketEventListeners(socket, sessionId);
219
454
 
220
- Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()])
221
- .then(([webSocketUrl, token]) => {
222
- if (!this.backoffCall) {
223
- const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
455
+ const backoffCall = isShutdownSwitchover
456
+ ? this._shutdownSwitchoverBackoffCalls.get(sessionId)
457
+ : this.backoffCalls.get(sessionId);
224
458
 
225
- this.logger.info(msg);
459
+ // Check appropriate backoff call based on connection type
460
+ if (isShutdownSwitchover && !backoffCall) {
461
+ const msg = `${this.namespace}: prevent socket open when switchover backoff call no longer defined for ${sessionId}`;
462
+ const err = new Error(msg);
226
463
 
227
- return Promise.reject(new Error(msg));
228
- }
464
+ this.logger.info(msg);
229
465
 
230
- attemptWSUrl = webSocketUrl;
466
+ // Call the callback with the error before rejecting
467
+ callback(err);
231
468
 
232
- let options = {
233
- forceCloseDelay: this.config.forceCloseDelay,
234
- pingInterval: this.config.pingInterval,
235
- pongTimeout: this.config.pongTimeout,
236
- token: token.toString(),
237
- trackingId: `${this.webex.sessionId}_${Date.now()}`,
238
- logger: this.logger,
239
- };
469
+ return Promise.reject(err);
470
+ }
240
471
 
241
- // if the consumer has supplied request options use them
242
- if (this.webex.config.defaultMercuryOptions) {
243
- this.logger.info(`${this.namespace}: setting custom options`);
244
- options = {...options, ...this.webex.config.defaultMercuryOptions};
245
- }
472
+ if (!isShutdownSwitchover && !backoffCall) {
473
+ const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined for ${sessionId}`;
474
+ const err = new Error(msg);
246
475
 
247
- // Set the socket before opening it. This allows a disconnect() to close
248
- // the socket if it is in the process of being opened.
249
- this.socket = socket;
476
+ this.logger.info(msg);
250
477
 
251
- this.logger.info(`${this.namespace} connection url: ${webSocketUrl}`);
478
+ // Call the callback with the error before rejecting
479
+ callback(err);
480
+
481
+ return Promise.reject(err);
482
+ }
483
+
484
+ // For shutdown switchover, don't set socket yet (make-before-break)
485
+ // For normal connection, set socket before opening to allow disconnect() to close it
486
+ if (!isShutdownSwitchover) {
487
+ this.sockets.set(sessionId, socket);
488
+ }
489
+
490
+ return this._prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover)
491
+ .then((webSocketUrl) => {
492
+ newWSUrl = webSocketUrl;
252
493
 
253
- return socket.open(webSocketUrl, options);
254
- })
255
- .then(() => {
256
494
  this.logger.info(
257
- `${this.namespace}: connected to mercury, success, action: connected, url: ${attemptWSUrl}`
495
+ `${this.namespace}: ${
496
+ isShutdownSwitchover ? '[shutdown] switchover' : ''
497
+ } connected to mercury, success, action: connected for ${sessionId}, url: ${newWSUrl}`
258
498
  );
499
+
500
+ // Custom success handler for shutdown switchover
501
+ if (onSuccess) {
502
+ onSuccess(socket, webSocketUrl);
503
+ callback();
504
+
505
+ return Promise.resolve();
506
+ }
507
+
508
+ // Default behavior for normal connection
259
509
  callback();
260
510
 
261
511
  return this.webex.internal.feature
@@ -269,32 +519,49 @@ const Mercury = WebexPlugin.extend({
269
519
  });
270
520
  })
271
521
  .catch((reason) => {
522
+ // For shutdown, simpler error handling - just callback for retry
523
+ if (isShutdownSwitchover) {
524
+ this.logger.info(
525
+ `${this.namespace}: [shutdown] switchover attempt failed for ${sessionId}`,
526
+ reason
527
+ );
528
+
529
+ return callback(reason);
530
+ }
531
+
532
+ // Normal connection error handling (existing complex logic)
272
533
  this.lastError = reason; // remember the last error
273
534
 
535
+ const backoffCall = this.backoffCalls.get(sessionId);
274
536
  // Suppress connection errors that appear to be network related. This
275
537
  // may end up suppressing metrics during outages, but we might not care
276
538
  // (especially since many of our outages happen in a way that client
277
539
  // metrics can't be trusted).
278
- if (reason.code !== 1006 && this.backoffCall && this.backoffCall?.getNumRetries() > 0) {
279
- this._emit('connection_failed', reason, {retries: this.backoffCall?.getNumRetries()});
540
+ if (reason.code !== 1006 && backoffCall && backoffCall?.getNumRetries() > 0) {
541
+ this._emit(sessionId, 'connection_failed', reason, {
542
+ sessionId,
543
+ retries: backoffCall?.getNumRetries(),
544
+ });
280
545
  }
281
546
  this.logger.info(
282
- `${this.namespace}: connection attempt failed`,
547
+ `${this.namespace}: connection attempt failed for ${sessionId}`,
283
548
  reason,
284
- this.backoffCall?.getNumRetries() === 0 ? reason.stack : ''
549
+ backoffCall?.getNumRetries() === 0 ? reason.stack : ''
285
550
  );
286
551
  // UnknownResponse is produced by IE for any 4XXX; treated it like a bad
287
552
  // web socket url and let WDM handle the token checking
288
553
  if (reason instanceof UnknownResponse) {
289
554
  this.logger.info(
290
- `${this.namespace}: received unknown response code, refreshing device registration`
555
+ `${this.namespace}: received unknown response code for ${sessionId}, refreshing device registration`
291
556
  );
292
557
 
293
558
  return this.webex.internal.device.refresh().then(() => callback(reason));
294
559
  }
295
560
  // NotAuthorized implies expired token
296
561
  if (reason instanceof NotAuthorized) {
297
- this.logger.info(`${this.namespace}: received authorization error, reauthorizing`);
562
+ this.logger.info(
563
+ `${this.namespace}: received authorization error for ${sessionId}, reauthorizing`
564
+ );
298
565
 
299
566
  return this.webex.credentials.refresh({force: true}).then(() => callback(reason));
300
567
  }
@@ -307,8 +574,10 @@ const Mercury = WebexPlugin.extend({
307
574
  // BadRequest implies current credentials are for a Service Account
308
575
  // Forbidden implies current user is not entitle for Webex
309
576
  if (reason instanceof BadRequest || reason instanceof Forbidden) {
310
- this.logger.warn(`${this.namespace}: received unrecoverable response from mercury`);
311
- this.backoffCall.abort();
577
+ this.logger.warn(
578
+ `${this.namespace}: received unrecoverable response from mercury for ${sessionId}`
579
+ );
580
+ backoffCall?.abort();
312
581
 
313
582
  return callback(reason);
314
583
  }
@@ -318,10 +587,10 @@ const Mercury = WebexPlugin.extend({
318
587
  .then((haMessagingEnabled) => {
319
588
  if (haMessagingEnabled) {
320
589
  this.logger.info(
321
- `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${attemptWSUrl} error: ${reason.message}`
590
+ `${this.namespace}: received a generic connection error for ${sessionId}, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}`
322
591
  );
323
592
 
324
- return this.webex.internal.services.markFailedUrl(attemptWSUrl);
593
+ return this.webex.internal.services.markFailedUrl(newWSUrl);
325
594
  }
326
595
 
327
596
  return null;
@@ -332,42 +601,103 @@ const Mercury = WebexPlugin.extend({
332
601
  return callback(reason);
333
602
  })
334
603
  .catch((reason) => {
335
- this.logger.error(`${this.namespace}: failed to handle connection failure`, reason);
604
+ this.logger.error(
605
+ `${this.namespace}: failed to handle connection failure for ${sessionId}`,
606
+ reason
607
+ );
336
608
  callback(reason);
337
609
  });
338
610
  },
339
611
 
340
- _connectWithBackoff(webSocketUrl) {
612
+ _prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover = false) {
613
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
614
+
615
+ return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(
616
+ ([webSocketUrl, token]) => {
617
+ let options = {
618
+ forceCloseDelay: this.config.forceCloseDelay,
619
+ pingInterval: this.config.pingInterval,
620
+ pongTimeout: this.config.pongTimeout,
621
+ token: token.toString(),
622
+ trackingId: `${this.webex.sessionId}_${Date.now()}`,
623
+ logger: this.logger,
624
+ };
625
+
626
+ if (this.webex.config.defaultMercuryOptions) {
627
+ const customOptionsMsg = isShutdownSwitchover
628
+ ? 'setting custom options for switchover'
629
+ : 'setting custom options';
630
+
631
+ this.logger.info(`${this.namespace}: ${customOptionsMsg}`);
632
+ options = {...options, ...this.webex.config.defaultMercuryOptions};
633
+ }
634
+
635
+ // Set the socket before opening it. This allows a disconnect() to close
636
+ // the socket if it is in the process of being opened.
637
+ this.sockets.set(sessionId, socket);
638
+ this.socket = this.sockets.get(this.defaultSessionId) || socket;
639
+
640
+ this.logger.info(`${this.namespace} ${logPrefix} url for ${sessionId}: ${webSocketUrl}`);
641
+
642
+ return socket.open(webSocketUrl, options).then(() => webSocketUrl);
643
+ }
644
+ );
645
+ },
646
+
647
+ _connectWithBackoff(webSocketUrl, sessionId, context = {}) {
648
+ const {isShutdownSwitchover = false, attemptOptions = {}} = context;
649
+
341
650
  return new Promise((resolve, reject) => {
342
- // eslint gets confused about whether or not call is actually used
651
+ // eslint gets confused about whether call is actually used
343
652
  // eslint-disable-next-line prefer-const
344
653
  let call;
345
- const onComplete = (err) => {
346
- this.connecting = false;
347
-
348
- this.backoffCall = undefined;
654
+ const onComplete = (err, sid = sessionId) => {
655
+ if (isShutdownSwitchover) {
656
+ this._shutdownSwitchoverBackoffCalls.delete(sid);
657
+ } else {
658
+ this.backoffCalls.delete(sid);
659
+ }
349
660
  if (err) {
661
+ const msg = isShutdownSwitchover
662
+ ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
663
+ : `failed to connect after ${call.getNumRetries()} retries`;
664
+
350
665
  this.logger.info(
351
- `${
352
- this.namespace
353
- }: failed to connect after ${call.getNumRetries()} retries; log statement about next retry was inaccurate; ${err}`
666
+ `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}`
354
667
  );
355
668
 
356
669
  return reject(err);
357
670
  }
358
- this.connected = true;
359
- this.hasEverConnected = true;
360
- this._emit('online');
361
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
671
+ // Update overall connected status
672
+ const sessionSocket = this.sockets.get(sid);
673
+ if (sessionSocket) {
674
+ sessionSocket.connecting = false;
675
+ sessionSocket.connected = true;
676
+ }
677
+ // Default success handling for normal connections
678
+ if (!isShutdownSwitchover) {
679
+ this.connecting = this.hasConnectingSockets();
680
+ this.connected = this.hasConnectedSockets();
681
+ this.hasEverConnected = true;
682
+ this._emit(sid, 'online', {sessionId: sid});
683
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
684
+ }
362
685
 
363
686
  return resolve();
364
687
  };
365
-
366
688
  // eslint-disable-next-line prefer-reflect
367
- call = backoff.call((callback) => {
368
- this.logger.info(`${this.namespace}: executing connection attempt ${call.getNumRetries()}`);
369
- this._attemptConnection(webSocketUrl, callback);
370
- }, onComplete);
689
+ call = backoff.call(
690
+ (callback) => {
691
+ const attemptNum = call.getNumRetries();
692
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
693
+
694
+ this.logger.info(
695
+ `${this.namespace}: executing ${logPrefix} attempt ${attemptNum} for ${sessionId}`
696
+ );
697
+ this._attemptConnection(webSocketUrl, sessionId, callback, attemptOptions);
698
+ },
699
+ (err) => onComplete(err, sessionId)
700
+ );
371
701
 
372
702
  call.setStrategy(
373
703
  new backoff.ExponentialStrategy({
@@ -376,15 +706,29 @@ const Mercury = WebexPlugin.extend({
376
706
  })
377
707
  );
378
708
 
379
- if (this.config.initialConnectionMaxRetries && !this.hasEverConnected) {
709
+ if (
710
+ this.config.initialConnectionMaxRetries &&
711
+ !this.hasEverConnected &&
712
+ !isShutdownSwitchover
713
+ ) {
380
714
  call.failAfter(this.config.initialConnectionMaxRetries);
381
715
  } else if (this.config.maxRetries) {
382
716
  call.failAfter(this.config.maxRetries);
383
717
  }
384
718
 
719
+ // Store the call BEFORE setting up event handlers to prevent race conditions
720
+ // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
721
+ if (isShutdownSwitchover) {
722
+ this._shutdownSwitchoverBackoffCalls.set(sessionId, call);
723
+ } else {
724
+ this.backoffCalls.set(sessionId, call);
725
+ }
726
+
385
727
  call.on('abort', () => {
386
- this.logger.info(`${this.namespace}: connection aborted`);
387
- reject(new Error('Mercury Connection Aborted'));
728
+ const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
729
+
730
+ this.logger.info(`${this.namespace}: ${msg} aborted for ${sessionId}`);
731
+ reject(new Error(`Mercury ${msg} Aborted for ${sessionId}`));
388
732
  });
389
733
 
390
734
  call.on('callback', (err) => {
@@ -392,8 +736,12 @@ const Mercury = WebexPlugin.extend({
392
736
  const number = call.getNumRetries();
393
737
  const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax);
394
738
 
739
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
740
+
395
741
  this.logger.info(
396
- `${this.namespace}: failed to connect; attempting retry ${number + 1} in ${delay} ms`
742
+ `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${
743
+ number + 1
744
+ } in ${delay} ms for ${sessionId}`
397
745
  );
398
746
  /* istanbul ignore if */
399
747
  if (process.env.NODE_ENV === 'development') {
@@ -402,25 +750,52 @@ const Mercury = WebexPlugin.extend({
402
750
 
403
751
  return;
404
752
  }
405
- this.logger.info(`${this.namespace}: connected`);
753
+ this.logger.info(`${this.namespace}: connected ${sessionId}`);
406
754
  });
407
755
 
408
756
  call.start();
409
-
410
- this.backoffCall = call;
411
757
  });
412
758
  },
413
759
 
414
760
  _emit(...args) {
415
761
  try {
416
- this.trigger(...args);
762
+ if (!args || args.length === 0) {
763
+ return;
764
+ }
765
+
766
+ // New signature: _emit(sessionId, eventName, ...rest)
767
+ // Backwards compatibility: if the first arg isn't a known sessionId (or defaultSessionId),
768
+ // treat the call as the old signature and forward directly to trigger(...)
769
+ const [first, second, ...rest] = args;
770
+
771
+ if (
772
+ typeof first === 'string' &&
773
+ (this.sockets.has(first) || first === this.defaultSessionId) &&
774
+ typeof second === 'string'
775
+ ) {
776
+ const sessionId = first;
777
+ const eventName = second;
778
+ const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`;
779
+
780
+ this.trigger(`${eventName}${suffix}`, ...rest);
781
+ } else {
782
+ // Old usage: _emit(eventName, ...args)
783
+ this.trigger(...args);
784
+ }
417
785
  } catch (error) {
418
- this.logger.error(
419
- `${this.namespace}: error occurred in event handler:`,
420
- error,
421
- ' with args: ',
422
- args
423
- );
786
+ // Safely handle errors without causing additional issues during cleanup
787
+ try {
788
+ this.logger.error(
789
+ `${this.namespace}: error occurred in event handler:`,
790
+ error,
791
+ ' with args: ',
792
+ args
793
+ );
794
+ } catch (logError) {
795
+ // If even logging fails, just ignore to prevent cascading errors during cleanup
796
+ // eslint-disable-next-line no-console
797
+ console.error('Mercury _emit error handling failed:', logError);
798
+ }
424
799
  }
425
800
  },
426
801
 
@@ -444,78 +819,152 @@ const Mercury = WebexPlugin.extend({
444
819
  return handlers;
445
820
  },
446
821
 
447
- _onclose(event) {
822
+ _onclose(sessionId, event, sourceSocket) {
448
823
  // I don't see any way to avoid the complexity or statement count in here.
449
824
  /* eslint complexity: [0] */
450
825
 
451
826
  try {
452
827
  const reason = event.reason && event.reason.toLowerCase();
453
- const socketUrl = this.socket.url;
828
+ let sessionSocket = this.sockets.get(sessionId);
829
+ let socketUrl;
830
+ event.sessionId = sessionId;
454
831
 
455
- this.socket.removeAllListeners();
456
- this.unset('socket');
457
- this.connected = false;
458
- this._emit('offline', event);
459
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
832
+ const isActiveSocket = sourceSocket === sessionSocket;
833
+ if (sourceSocket) {
834
+ socketUrl = sourceSocket.url;
835
+ }
836
+ this.sockets.delete(sessionId);
837
+
838
+ if (isActiveSocket) {
839
+ // Only tear down state if the currently active socket closed
840
+ if (sessionSocket) {
841
+ sessionSocket.removeAllListeners();
842
+ sessionSocket = null;
843
+ this._emit(sessionId, 'offline', event);
844
+ }
845
+ // Update overall connected status
846
+ this.connecting = this.hasConnectingSockets();
847
+ this.connected = this.hasConnectedSockets();
848
+
849
+ if (!this.connected) {
850
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
851
+ }
852
+ } else {
853
+ // Old socket closed; do not flip connection state
854
+ this.logger.info(
855
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code} for ${sessionId}`
856
+ );
857
+ // Clean up listeners from old socket now that it's closed
858
+ if (sourceSocket) {
859
+ sourceSocket.removeAllListeners();
860
+ }
861
+ }
460
862
 
461
863
  switch (event.code) {
462
864
  case 1003:
463
865
  // metric: disconnect
464
866
  this.logger.info(
465
- `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}`
867
+ `${this.namespace}: Mercury service rejected last message for ${sessionId}; will not reconnect: ${event.reason}`
466
868
  );
467
- this._emit('offline.permanent', event);
869
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
468
870
  break;
469
871
  case 4000:
470
872
  // metric: disconnect
471
- this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
472
- this._emit('offline.replaced', event);
873
+ this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`);
874
+ if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event);
875
+ // If not active, nothing to do
876
+ break;
877
+ case 4001:
878
+ // replaced during shutdown
879
+ if (isActiveSocket) {
880
+ // Server closed active socket with 4001, meaning it expected this connection
881
+ // to be replaced, but the switchover in _handleImminentShutdown failed.
882
+ // This is a permanent failure - do not reconnect.
883
+ this.logger.warn(
884
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed for ${sessionId}`
885
+ );
886
+ this._emit(sessionId, 'offline.permanent', event);
887
+ } else {
888
+ // Expected: old socket closed after successful switchover
889
+ this.logger.info(
890
+ `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed for ${sessionId}`
891
+ );
892
+ this._emit(sessionId, 'offline.replaced', event);
893
+ }
473
894
  break;
474
895
  case 1001:
475
896
  case 1005:
476
897
  case 1006:
477
898
  case 1011:
478
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
479
- this._emit('offline.transient', event);
480
- this._reconnect(socketUrl);
899
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
900
+ if (isActiveSocket) {
901
+ this._emit(sessionId, 'offline.transient', event);
902
+ this.logger.info(
903
+ `${this.namespace}: [shutdown] reconnecting active socket to recover for ${sessionId}`
904
+ );
905
+ this._reconnect(socketUrl, sessionId);
906
+ }
481
907
  // metric: disconnect
482
908
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
483
909
  break;
484
910
  case 1000:
485
911
  case 3050: // 3050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block
486
912
  if (normalReconnectReasons.includes(reason)) {
487
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
488
- this._emit('offline.transient', event);
489
- this._reconnect(socketUrl);
913
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
914
+ if (isActiveSocket) {
915
+ this._emit(sessionId, 'offline.transient', event);
916
+ this.logger.info(
917
+ `${this.namespace}: [shutdown] reconnecting due to normal close for ${sessionId}`
918
+ );
919
+ this._reconnect(socketUrl, sessionId);
920
+ }
490
921
  // metric: disconnect
491
922
  // if (reason === done forced) metric: force closure
492
923
  } else {
493
924
  this.logger.info(
494
- `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
925
+ `${this.namespace}: socket ${sessionId} disconnected; will not reconnect: ${event.reason}`
495
926
  );
496
- this._emit('offline.permanent', event);
927
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
497
928
  }
498
929
  break;
499
930
  default:
500
931
  this.logger.info(
501
- `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
932
+ `${this.namespace}: socket ${sessionId} disconnected unexpectedly; will not reconnect`
502
933
  );
503
934
  // unexpected disconnect
504
- this._emit('offline.permanent', event);
935
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
505
936
  }
506
937
  } catch (error) {
507
- this.logger.error(`${this.namespace}: error occurred in close handler`, error);
938
+ this.logger.error(
939
+ `${this.namespace}: error occurred in close handler for ${sessionId}`,
940
+ error
941
+ );
508
942
  }
509
943
  },
510
944
 
511
- _onmessage(event) {
512
- this._setTimeOffset(event);
945
+ _onmessage(sessionId, event) {
946
+ this._setTimeOffset(sessionId, event);
513
947
  const envelope = event.data;
514
948
 
515
949
  if (process.env.ENABLE_MERCURY_LOGGING) {
516
- this.logger.debug(`${this.namespace}: message envelope: `, envelope);
950
+ this.logger.debug(`${this.namespace}: message envelope from ${sessionId}: `, envelope);
951
+ }
952
+
953
+ envelope.sessionId = sessionId;
954
+
955
+ // Handle shutdown message shape: { type: 'shutdown' }
956
+ if (envelope && envelope.type === 'shutdown') {
957
+ this.logger.info(
958
+ `${this.namespace}: [shutdown] imminent shutdown message received for ${sessionId}`
959
+ );
960
+ this._emit(sessionId, 'event:mercury_shutdown_imminent', envelope);
961
+
962
+ this._handleImminentShutdown(sessionId);
963
+
964
+ return Promise.resolve();
517
965
  }
518
966
 
967
+ envelope.sessionId = sessionId;
519
968
  const {data} = envelope;
520
969
 
521
970
  this._applyOverrides(data);
@@ -530,7 +979,7 @@ const Mercury = WebexPlugin.extend({
530
979
  resolve((this.webex[namespace] || this.webex.internal[namespace])[name](data))
531
980
  ).catch((reason) =>
532
981
  this.logger.error(
533
- `${this.namespace}: error occurred in autowired event handler for ${data.eventType}`,
982
+ `${this.namespace}: error occurred in autowired event handler for ${data.eventType} from ${sessionId}`,
534
983
  reason
535
984
  )
536
985
  );
@@ -538,32 +987,35 @@ const Mercury = WebexPlugin.extend({
538
987
  Promise.resolve()
539
988
  )
540
989
  .then(() => {
541
- this._emit('event', event.data);
990
+ this._emit(sessionId, 'event', envelope);
542
991
  const [namespace] = data.eventType.split('.');
543
992
 
544
993
  if (namespace === data.eventType) {
545
- this._emit(`event:${namespace}`, envelope);
994
+ this._emit(sessionId, `event:${namespace}`, envelope);
546
995
  } else {
547
- this._emit(`event:${namespace}`, envelope);
548
- this._emit(`event:${data.eventType}`, envelope);
996
+ this._emit(sessionId, `event:${namespace}`, envelope);
997
+ this._emit(sessionId, `event:${data.eventType}`, envelope);
549
998
  }
550
999
  })
551
1000
  .catch((reason) => {
552
- this.logger.error(`${this.namespace}: error occurred processing socket message`, reason);
1001
+ this.logger.error(
1002
+ `${this.namespace}: error occurred processing socket message from ${sessionId}`,
1003
+ reason
1004
+ );
553
1005
  });
554
1006
  },
555
1007
 
556
- _setTimeOffset(event) {
1008
+ _setTimeOffset(sessionId, event) {
557
1009
  const {wsWriteTimestamp} = event.data;
558
1010
  if (typeof wsWriteTimestamp === 'number' && wsWriteTimestamp > 0) {
559
1011
  this.mercuryTimeOffset = Date.now() - wsWriteTimestamp;
560
1012
  }
561
1013
  },
562
1014
 
563
- _reconnect(webSocketUrl) {
564
- this.logger.info(`${this.namespace}: reconnecting`);
1015
+ _reconnect(webSocketUrl, sessionId = this.defaultSessionId) {
1016
+ this.logger.info(`${this.namespace}: reconnecting ${sessionId}`);
565
1017
 
566
- return this.connect(webSocketUrl);
1018
+ return this.connect(webSocketUrl, sessionId);
567
1019
  },
568
1020
  });
569
1021