@webex/internal-plugin-mercury 3.11.0 → 3.12.0-next.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,
@@ -99,50 +111,63 @@ const Mercury = WebexPlugin.extend({
99
111
  /**
100
112
  * Attach event listeners to a socket.
101
113
  * @param {Socket} socket - The socket to attach listeners to
114
+ * @param {sessionId} sessionId - The socket related session ID
102
115
  * @returns {void}
103
116
  */
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));
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
+ );
110
127
  },
111
128
 
112
129
  /**
113
130
  * Handle imminent shutdown by establishing a new connection while keeping
114
131
  * the current one alive (make-before-break).
115
132
  * Idempotent: will no-op if already in progress.
133
+ * @param {string} sessionId - The session ID for which the shutdown is imminent
116
134
  * @returns {void}
117
135
  */
118
- _handleImminentShutdown() {
136
+ _handleImminentShutdown(sessionId) {
137
+ const oldSocket = this.sockets.get(sessionId);
138
+
119
139
  try {
120
- if (this._shutdownSwitchoverInProgress) {
121
- this.logger.info(`${this.namespace}: [shutdown] switchover already in progress`);
140
+ // Idempotent: if we already have a switchover backoff call for this session,
141
+ // a switchover is in progress – do nothing.
142
+ if (this._shutdownSwitchoverBackoffCalls.get(sessionId)) {
143
+ this.logger.info(
144
+ `${this.namespace}: [shutdown] switchover already in progress for ${sessionId}`
145
+ );
122
146
 
123
147
  return;
124
148
  }
125
- this._shutdownSwitchoverInProgress = true;
149
+
126
150
  this._shutdownSwitchoverId = `${Date.now()}`;
127
151
  this.logger.info(
128
- `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId}`
152
+ `${this.namespace}: [shutdown] switchover start, id=${this._shutdownSwitchoverId} for ${sessionId}`
129
153
  );
130
154
 
131
- this._connectWithBackoff(undefined, {
155
+ this._connectWithBackoff(undefined, sessionId, {
132
156
  isShutdownSwitchover: true,
133
157
  attemptOptions: {
134
158
  isShutdownSwitchover: true,
135
159
  onSuccess: (newSocket, webSocketUrl) => {
136
160
  this.logger.info(
137
- `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl}`
161
+ `${this.namespace}: [shutdown] switchover connected, url: ${webSocketUrl} for ${sessionId}`
138
162
  );
139
163
 
140
- const oldSocket = this.socket;
141
164
  // Atomically switch active socket reference
142
- this.socket = newSocket;
143
- this.connected = true; // remain connected throughout
165
+ this.socket = this.sockets.get(this.defaultSessionId);
166
+ this.connected = this.hasConnectedSockets(); // remain connected throughout
144
167
 
145
- this._emit('event:mercury_shutdown_switchover_complete', {url: webSocketUrl});
168
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_complete', {
169
+ url: webSocketUrl,
170
+ });
146
171
 
147
172
  if (oldSocket) {
148
173
  this.logger.info(
@@ -153,20 +178,25 @@ const Mercury = WebexPlugin.extend({
153
178
  },
154
179
  })
155
180
  .then(() => {
156
- this.logger.info(`${this.namespace}: [shutdown] switchover completed successfully`);
181
+ this.logger.info(
182
+ `${this.namespace}: [shutdown] switchover completed successfully for ${sessionId}`
183
+ );
157
184
  })
158
185
  .catch((err) => {
159
186
  this.logger.info(
160
- `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection`,
187
+ `${this.namespace}: [shutdown] switchover exhausted retries; will fall back to normal reconnection for ${sessionId}: `,
161
188
  err
162
189
  );
163
- this._emit('event:mercury_shutdown_switchover_failed', {reason: err});
190
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: err});
164
191
  // Old socket will eventually close with 4001, triggering normal reconnection
165
192
  });
166
193
  } 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});
194
+ this.logger.error(
195
+ `${this.namespace}: [shutdown] error during switchover for ${sessionId}`,
196
+ e
197
+ );
198
+ this._shutdownSwitchoverBackoffCalls.delete(sessionId);
199
+ this._emit(sessionId, 'event:mercury_shutdown_switchover_failed', {reason: e});
170
200
  }
171
201
  },
172
202
 
@@ -178,29 +208,95 @@ const Mercury = WebexPlugin.extend({
178
208
  return this.lastError;
179
209
  },
180
210
 
181
- @oneFlight
182
- connect(webSocketUrl) {
183
- if (this.connected) {
184
- this.logger.info(`${this.namespace}: already connected, will not connect again`);
211
+ /**
212
+ * Get all active socket connections
213
+ * @returns {Map} Map of sessionId to socket instances
214
+ */
215
+ getSockets() {
216
+ return this.sockets;
217
+ },
218
+
219
+ /**
220
+ * Get a specific socket by connection ID
221
+ * @param {string} sessionId - The connection identifier
222
+ * @returns {Socket|undefined} The socket instance or undefined if not found
223
+ */
224
+ getSocket(sessionId = this.defaultSessionId) {
225
+ return this.sockets.get(sessionId);
226
+ },
227
+
228
+ /**
229
+ * Check if a socket is connected
230
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier
231
+ * @returns {boolean|undefined} True if the socket is connected
232
+ */
233
+ hasConnectedSockets(sessionId = this.defaultSessionId) {
234
+ const socket = this.sockets.get(sessionId || this.defaultSessionId);
235
+
236
+ return socket?.connected;
237
+ },
238
+
239
+ /**
240
+ * Check if any sockets are connecting
241
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier
242
+ * @returns {boolean|undefined} True if the socket is connecting
243
+ */
244
+ hasConnectingSockets(sessionId = this.defaultSessionId) {
245
+ const socket = this.sockets.get(sessionId || this.defaultSessionId);
246
+
247
+ return socket?.connecting;
248
+ },
249
+
250
+ /**
251
+ * Connect to Mercury for a specific session.
252
+ * @param {string} [webSocketUrl] - Optional websocket URL override. Falls back to the device websocket URL.
253
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier for this connection.
254
+ * @returns {Promise<void>} Resolves when connection flow completes for the session.
255
+ */
256
+ connect(webSocketUrl, sessionId = this.defaultSessionId) {
257
+ if (!this._connectPromises) this._connectPromises = new Map();
258
+
259
+ // First check if there's already a connection promise for this session
260
+ if (this._connectPromises.has(sessionId)) {
261
+ this.logger.info(
262
+ `${this.namespace}: connection ${sessionId} already in progress, returning existing promise`
263
+ );
264
+
265
+ return this._connectPromises.get(sessionId);
266
+ }
267
+
268
+ const sessionSocket = this.sockets.get(sessionId);
269
+ if (sessionSocket?.connected || sessionSocket?.connecting) {
270
+ this.logger.info(
271
+ `${this.namespace}: connection ${sessionId} already connected, will not connect again`
272
+ );
185
273
 
186
274
  return Promise.resolve();
187
275
  }
188
276
 
189
277
  this.connecting = true;
190
278
 
191
- this.logger.info(`${this.namespace}: starting connection attempt`);
279
+ this.logger.info(`${this.namespace}: starting connection attempt for ${sessionId}`);
192
280
  this.logger.info(
193
281
  `${this.namespace}: debug_mercury_logging stack: `,
194
282
  new Error('debug_mercury_logging').stack
195
283
  );
196
284
 
197
- return Promise.resolve(
285
+ const connectPromise = Promise.resolve(
198
286
  this.webex.internal.device.registered || this.webex.internal.device.register()
199
- ).then(() => {
200
- this.logger.info(`${this.namespace}: connecting`);
287
+ )
288
+ .then(() => {
289
+ this.logger.info(`${this.namespace}: connecting ${sessionId}`);
201
290
 
202
- return this._connectWithBackoff(webSocketUrl);
203
- });
291
+ return this._connectWithBackoff(webSocketUrl, sessionId);
292
+ })
293
+ .finally(() => {
294
+ this._connectPromises.delete(sessionId);
295
+ });
296
+
297
+ this._connectPromises.set(sessionId, connectPromise);
298
+
299
+ return connectPromise;
204
300
  },
205
301
 
206
302
  logout() {
@@ -210,7 +306,7 @@ const Mercury = WebexPlugin.extend({
210
306
  new Error('debug_mercury_logging').stack
211
307
  );
212
308
 
213
- return this.disconnect(
309
+ return this.disconnectAll(
214
310
  this.config.beforeLogoutOptionsCloseReason &&
215
311
  !normalReconnectReasons.includes(this.config.beforeLogoutOptionsCloseReason)
216
312
  ? {code: 3050, reason: this.config.beforeLogoutOptionsCloseReason}
@@ -218,26 +314,74 @@ const Mercury = WebexPlugin.extend({
218
314
  );
219
315
  },
220
316
 
221
- @oneFlight
222
- disconnect(options) {
317
+ /**
318
+ * Disconnect a Mercury socket for a specific session.
319
+ * @param {object} [options] - Optional websocket close options (for example: `{code, reason}`).
320
+ * @param {string} [sessionId=this.defaultSessionId] - The session identifier to disconnect.
321
+ * @returns {Promise<void>} Resolves after disconnect cleanup and close handling are initiated/completed.
322
+ */
323
+ disconnect(options, sessionId = this.defaultSessionId) {
324
+ this.logger.info(
325
+ `${this.namespace}#disconnect: connecting state: ${this.connecting}, connected state: ${
326
+ this.connected
327
+ }, socket exists: ${!!this.socket}, options: ${JSON.stringify(options)}`
328
+ );
329
+
223
330
  return new Promise((resolve) => {
224
- if (this.backoffCall) {
225
- this.logger.info(`${this.namespace}: aborting connection`);
226
- this.backoffCall.abort();
331
+ const backoffCall = this.backoffCalls.get(sessionId);
332
+ if (backoffCall) {
333
+ this.logger.info(`${this.namespace}: aborting connection ${sessionId}`);
334
+ backoffCall.abort();
335
+ this.backoffCalls.delete(sessionId);
227
336
  }
228
-
229
- if (this._shutdownSwitchoverBackoffCall) {
230
- this.logger.info(`${this.namespace}: aborting shutdown switchover`);
231
- this._shutdownSwitchoverBackoffCall.abort();
337
+ const shutdownSwitchoverBackoffCall = this._shutdownSwitchoverBackoffCalls.get(sessionId);
338
+ if (shutdownSwitchoverBackoffCall) {
339
+ this.logger.info(`${this.namespace}: aborting shutdown switchover connection ${sessionId}`);
340
+ shutdownSwitchoverBackoffCall.abort();
341
+ this._shutdownSwitchoverBackoffCalls.delete(sessionId);
232
342
  }
233
-
234
- if (this.socket) {
235
- this.socket.removeAllListeners('message');
236
- this.once('offline', resolve);
237
- resolve(this.socket.close(options || undefined));
343
+ // Clean up any pending connection promises
344
+ if (this._connectPromises) {
345
+ this._connectPromises.delete(sessionId);
238
346
  }
239
347
 
348
+ const sessionSocket = this.sockets.get(sessionId);
349
+ const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`;
350
+
351
+ if (sessionSocket) {
352
+ sessionSocket.removeAllListeners('message');
353
+ sessionSocket.connecting = false;
354
+ sessionSocket.connected = false;
355
+ this.once(sessionId === this.defaultSessionId ? 'offline' : `offline${suffix}`, resolve);
356
+ resolve(sessionSocket.close(options || undefined));
357
+ }
240
358
  resolve();
359
+
360
+ // Update overall connected status
361
+ this.connected = this.hasConnectedSockets();
362
+ });
363
+ },
364
+
365
+ /**
366
+ * Disconnect all socket connections
367
+ * @param {object} options - Close options
368
+ * @returns {Promise} Promise that resolves when all connections are closed
369
+ */
370
+ disconnectAll(options) {
371
+ const disconnectPromises = [];
372
+
373
+ for (const sessionId of this.sockets.keys()) {
374
+ disconnectPromises.push(this.disconnect(options, sessionId));
375
+ }
376
+
377
+ return Promise.all(disconnectPromises).then(() => {
378
+ this.connected = false;
379
+ this.sockets.clear();
380
+ this.backoffCalls.clear();
381
+ // Clear connection promises to prevent stale promises
382
+ if (this._connectPromises) {
383
+ this._connectPromises.clear();
384
+ }
241
385
  });
242
386
  },
243
387
 
@@ -329,34 +473,29 @@ const Mercury = WebexPlugin.extend({
329
473
  }
330
474
 
331
475
  webSocketUrl.query.clientTimestamp = Date.now();
476
+ delete webSocketUrl.search;
332
477
 
333
478
  return url.format(webSocketUrl);
334
479
  });
335
480
  },
336
481
 
337
- _attemptConnection(socketUrl, callback, options = {}) {
482
+ _attemptConnection(socketUrl, sessionId, callback, options = {}) {
338
483
  const {isShutdownSwitchover = false, onSuccess = null} = options;
339
484
 
340
485
  const socket = new Socket();
486
+ socket.connecting = true;
341
487
  let newWSUrl;
342
488
 
343
- this._attachSocketEventListeners(socket);
489
+ this._attachSocketEventListeners(socket, sessionId);
344
490
 
345
- // Check appropriate backoff call based on connection type
346
- if (isShutdownSwitchover && !this._shutdownSwitchoverBackoffCall) {
347
- const msg = `${this.namespace}: prevent socket open when switchover backoff call no longer defined`;
348
- const err = new Error(msg);
491
+ const backoffCall = isShutdownSwitchover
492
+ ? this._shutdownSwitchoverBackoffCalls.get(sessionId)
493
+ : this.backoffCalls.get(sessionId);
349
494
 
350
- this.logger.info(msg);
351
-
352
- // Call the callback with the error before rejecting
353
- callback(err);
354
-
355
- return Promise.reject(err);
356
- }
357
-
358
- if (!isShutdownSwitchover && !this.backoffCall) {
359
- const msg = `${this.namespace}: prevent socket open when backoffCall no longer defined`;
495
+ // Check appropriate backoff call based on connection type
496
+ if (!backoffCall) {
497
+ const mode = isShutdownSwitchover ? 'switchover backoff call' : 'backoffCall';
498
+ const msg = `${this.namespace}: prevent socket open when ${mode} no longer defined for ${sessionId}`;
360
499
  const err = new Error(msg);
361
500
 
362
501
  this.logger.info(msg);
@@ -370,16 +509,17 @@ const Mercury = WebexPlugin.extend({
370
509
  // For shutdown switchover, don't set socket yet (make-before-break)
371
510
  // For normal connection, set socket before opening to allow disconnect() to close it
372
511
  if (!isShutdownSwitchover) {
373
- this.socket = socket;
512
+ this.sockets.set(sessionId, socket);
374
513
  }
375
514
 
376
- return this._prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover)
515
+ return this._prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover)
377
516
  .then((webSocketUrl) => {
378
517
  newWSUrl = webSocketUrl;
518
+
379
519
  this.logger.info(
380
520
  `${this.namespace}: ${
381
521
  isShutdownSwitchover ? '[shutdown] switchover' : ''
382
- } connected to mercury, success, action: connected, url: ${newWSUrl}`
522
+ } connected to mercury, success, action: connected for ${sessionId}, url: ${newWSUrl}`
383
523
  );
384
524
 
385
525
  // Custom success handler for shutdown switchover
@@ -406,7 +546,10 @@ const Mercury = WebexPlugin.extend({
406
546
  .catch((reason) => {
407
547
  // For shutdown, simpler error handling - just callback for retry
408
548
  if (isShutdownSwitchover) {
409
- this.logger.info(`${this.namespace}: [shutdown] switchover attempt failed`, reason);
549
+ this.logger.info(
550
+ `${this.namespace}: [shutdown] switchover attempt failed for ${sessionId}`,
551
+ reason
552
+ );
410
553
 
411
554
  return callback(reason);
412
555
  }
@@ -414,30 +557,36 @@ const Mercury = WebexPlugin.extend({
414
557
  // Normal connection error handling (existing complex logic)
415
558
  this.lastError = reason; // remember the last error
416
559
 
560
+ const backoffCallNormal = this.backoffCalls.get(sessionId);
417
561
  // Suppress connection errors that appear to be network related. This
418
562
  // may end up suppressing metrics during outages, but we might not care
419
563
  // (especially since many of our outages happen in a way that client
420
564
  // metrics can't be trusted).
421
- if (reason.code !== 1006 && this.backoffCall && this.backoffCall?.getNumRetries() > 0) {
422
- this._emit('connection_failed', reason, {retries: this.backoffCall?.getNumRetries()});
565
+ if (reason.code !== 1006 && backoffCallNormal && backoffCallNormal?.getNumRetries() > 0) {
566
+ this._emit(sessionId, 'connection_failed', reason, {
567
+ sessionId,
568
+ retries: backoffCallNormal?.getNumRetries(),
569
+ });
423
570
  }
424
571
  this.logger.info(
425
- `${this.namespace}: connection attempt failed`,
572
+ `${this.namespace}: connection attempt failed for ${sessionId}`,
426
573
  reason,
427
- this.backoffCall?.getNumRetries() === 0 ? reason.stack : ''
574
+ backoffCallNormal?.getNumRetries() === 0 ? reason.stack : ''
428
575
  );
429
576
  // UnknownResponse is produced by IE for any 4XXX; treated it like a bad
430
577
  // web socket url and let WDM handle the token checking
431
578
  if (reason instanceof UnknownResponse) {
432
579
  this.logger.info(
433
- `${this.namespace}: received unknown response code, refreshing device registration`
580
+ `${this.namespace}: received unknown response code for ${sessionId}, refreshing device registration`
434
581
  );
435
582
 
436
583
  return this.webex.internal.device.refresh().then(() => callback(reason));
437
584
  }
438
585
  // NotAuthorized implies expired token
439
586
  if (reason instanceof NotAuthorized) {
440
- this.logger.info(`${this.namespace}: received authorization error, reauthorizing`);
587
+ this.logger.info(
588
+ `${this.namespace}: received authorization error for ${sessionId}, reauthorizing`
589
+ );
441
590
 
442
591
  return this.webex.credentials.refresh({force: true}).then(() => callback(reason));
443
592
  }
@@ -450,8 +599,10 @@ const Mercury = WebexPlugin.extend({
450
599
  // BadRequest implies current credentials are for a Service Account
451
600
  // Forbidden implies current user is not entitle for Webex
452
601
  if (reason instanceof BadRequest || reason instanceof Forbidden) {
453
- this.logger.warn(`${this.namespace}: received unrecoverable response from mercury`);
454
- this.backoffCall.abort();
602
+ this.logger.warn(
603
+ `${this.namespace}: received unrecoverable response from mercury for ${sessionId}`
604
+ );
605
+ backoffCallNormal?.abort();
455
606
 
456
607
  return callback(reason);
457
608
  }
@@ -461,7 +612,7 @@ const Mercury = WebexPlugin.extend({
461
612
  .then((haMessagingEnabled) => {
462
613
  if (haMessagingEnabled) {
463
614
  this.logger.info(
464
- `${this.namespace}: received a generic connection error, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}`
615
+ `${this.namespace}: received a generic connection error for ${sessionId}, will try to connect to another datacenter. failed, action: 'failed', url: ${newWSUrl} error: ${reason.message}`
465
616
  );
466
617
 
467
618
  return this.webex.internal.services.markFailedUrl(newWSUrl);
@@ -475,12 +626,15 @@ const Mercury = WebexPlugin.extend({
475
626
  return callback(reason);
476
627
  })
477
628
  .catch((reason) => {
478
- this.logger.error(`${this.namespace}: failed to handle connection failure`, reason);
629
+ this.logger.error(
630
+ `${this.namespace}: failed to handle connection failure for ${sessionId}`,
631
+ reason
632
+ );
479
633
  callback(reason);
480
634
  });
481
635
  },
482
636
 
483
- _prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover = false) {
637
+ _prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover = false) {
484
638
  const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
485
639
 
486
640
  return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(
@@ -503,30 +657,32 @@ const Mercury = WebexPlugin.extend({
503
657
  options = {...options, ...this.webex.config.defaultMercuryOptions};
504
658
  }
505
659
 
506
- this.logger.info(`${this.namespace}: ${logPrefix} url: ${webSocketUrl}`);
660
+ // Set the socket before opening it. This allows a disconnect() to close
661
+ // the socket if it is in the process of being opened.
662
+ this.sockets.set(sessionId, socket);
663
+ this.socket = this.sockets.get(this.defaultSessionId);
664
+
665
+ this.logger.info(`${this.namespace} ${logPrefix} url for ${sessionId}: ${webSocketUrl}`);
507
666
 
508
667
  return socket.open(webSocketUrl, options).then(() => webSocketUrl);
509
668
  }
510
669
  );
511
670
  },
512
671
 
513
- _connectWithBackoff(webSocketUrl, context = {}) {
672
+ _connectWithBackoff(webSocketUrl, sessionId, context = {}) {
514
673
  const {isShutdownSwitchover = false, attemptOptions = {}} = context;
515
674
 
516
675
  return new Promise((resolve, reject) => {
517
- // eslint gets confused about whether or not call is actually used
676
+ // eslint gets confused about whether call is actually used
518
677
  // eslint-disable-next-line prefer-const
519
678
  let call;
520
- const onComplete = (err) => {
521
- // Clear state flags based on connection type
679
+ const onComplete = (err, sid = sessionId) => {
522
680
  if (isShutdownSwitchover) {
523
- this._shutdownSwitchoverInProgress = false;
524
- this._shutdownSwitchoverBackoffCall = undefined;
681
+ this._shutdownSwitchoverBackoffCalls.delete(sid);
525
682
  } else {
526
- this.connecting = false;
527
- this.backoffCall = undefined;
683
+ this.backoffCalls.delete(sid);
528
684
  }
529
-
685
+ const sessionSocket = this.sockets.get(sid);
530
686
  if (err) {
531
687
  const msg = isShutdownSwitchover
532
688
  ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
@@ -535,29 +691,45 @@ const Mercury = WebexPlugin.extend({
535
691
  this.logger.info(
536
692
  `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}`
537
693
  );
694
+ if (sessionSocket) {
695
+ sessionSocket.connecting = false;
696
+ sessionSocket.connected = false;
697
+ }
538
698
 
539
699
  return reject(err);
540
700
  }
541
701
 
702
+ // Update overall connected status
703
+ if (sessionSocket) {
704
+ sessionSocket.connecting = false;
705
+ sessionSocket.connected = true;
706
+ }
542
707
  // Default success handling for normal connections
543
708
  if (!isShutdownSwitchover) {
544
- this.connected = true;
709
+ this.connecting = this.hasConnectingSockets();
710
+ this.connected = this.hasConnectedSockets();
545
711
  this.hasEverConnected = true;
546
- this._emit('online');
547
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
712
+ this._emit(sid, 'online');
713
+ if (this.connected) {
714
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(true);
715
+ }
548
716
  }
549
717
 
550
718
  return resolve();
551
719
  };
552
-
553
720
  // eslint-disable-next-line prefer-reflect
554
- call = backoff.call((callback) => {
555
- const attemptNum = call.getNumRetries();
556
- const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
721
+ call = backoff.call(
722
+ (callback) => {
723
+ const attemptNum = call.getNumRetries();
724
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
557
725
 
558
- this.logger.info(`${this.namespace}: executing ${logPrefix} attempt ${attemptNum}`);
559
- this._attemptConnection(webSocketUrl, callback, attemptOptions);
560
- }, onComplete);
726
+ this.logger.info(
727
+ `${this.namespace}: executing ${logPrefix} attempt ${attemptNum} for ${sessionId}`
728
+ );
729
+ this._attemptConnection(webSocketUrl, sessionId, callback, attemptOptions);
730
+ },
731
+ (err) => onComplete(err, sessionId)
732
+ );
561
733
 
562
734
  call.setStrategy(
563
735
  new backoff.ExponentialStrategy({
@@ -576,23 +748,32 @@ const Mercury = WebexPlugin.extend({
576
748
  call.failAfter(this.config.maxRetries);
577
749
  }
578
750
 
751
+ // Store the call BEFORE setting up event handlers to prevent race conditions
752
+ // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
753
+ if (isShutdownSwitchover) {
754
+ this._shutdownSwitchoverBackoffCalls.set(sessionId, call);
755
+ } else {
756
+ this.backoffCalls.set(sessionId, call);
757
+ }
758
+
579
759
  call.on('abort', () => {
580
760
  const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
581
761
 
582
- this.logger.info(`${this.namespace}: ${msg} aborted`);
583
- reject(new Error(`Mercury ${msg} Aborted`));
762
+ this.logger.info(`${this.namespace}: ${msg} aborted for ${sessionId}`);
763
+ reject(new Error(`Mercury ${msg} Aborted for ${sessionId}`));
584
764
  });
585
765
 
586
766
  call.on('callback', (err) => {
587
767
  if (err) {
588
768
  const number = call.getNumRetries();
589
769
  const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax);
770
+
590
771
  const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
591
772
 
592
773
  this.logger.info(
593
774
  `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${
594
775
  number + 1
595
- } in ${delay} ms`
776
+ } in ${delay} ms for ${sessionId}`
596
777
  );
597
778
  /* istanbul ignore if */
598
779
  if (process.env.NODE_ENV === 'development') {
@@ -601,30 +782,48 @@ const Mercury = WebexPlugin.extend({
601
782
 
602
783
  return;
603
784
  }
604
- this.logger.info(`${this.namespace}: connected`);
785
+ this.logger.info(`${this.namespace}: connected ${sessionId}`);
605
786
  });
606
787
 
607
- // Store backoff call reference BEFORE starting (so it's available in _attemptConnection)
608
- if (isShutdownSwitchover) {
609
- this._shutdownSwitchoverBackoffCall = call;
610
- } else {
611
- this.backoffCall = call;
612
- }
613
-
614
788
  call.start();
615
789
  });
616
790
  },
617
791
 
618
792
  _emit(...args) {
619
793
  try {
620
- this.trigger(...args);
794
+ if (!args || args.length === 0) {
795
+ return;
796
+ }
797
+
798
+ // New signature: _emit(sessionId, eventName, ...rest)
799
+ // Backwards compatibility: if the first arg isn't a known sessionId (or defaultSessionId),
800
+ // treat the call as the old signature and forward directly to trigger(...)
801
+ const [first, second, ...rest] = args;
802
+
803
+ if (typeof first === 'string' && typeof second === 'string') {
804
+ const sessionId = first;
805
+ const eventName = second;
806
+ const suffix = sessionId === this.defaultSessionId ? '' : `:${sessionId}`;
807
+
808
+ this.trigger(`${eventName}${suffix}`, ...rest);
809
+ } else {
810
+ // Old usage: _emit(eventName, ...args)
811
+ this.trigger(...args);
812
+ }
621
813
  } catch (error) {
622
- this.logger.error(
623
- `${this.namespace}: error occurred in event handler:`,
624
- error,
625
- ' with args: ',
626
- args
627
- );
814
+ // Safely handle errors without causing additional issues during cleanup
815
+ try {
816
+ this.logger.error(
817
+ `${this.namespace}: error occurred in event handler:`,
818
+ error,
819
+ ' with args: ',
820
+ args
821
+ );
822
+ } catch (logError) {
823
+ // If even logging fails, just ignore to prevent cascading errors during cleanup
824
+ // eslint-disable-next-line no-console
825
+ console.error('Mercury _emit error handling failed:', logError);
826
+ }
628
827
  }
629
828
  },
630
829
 
@@ -648,36 +847,40 @@ const Mercury = WebexPlugin.extend({
648
847
  return handlers;
649
848
  },
650
849
 
651
- _onclose(event, sourceSocket) {
850
+ _onclose(sessionId, event, sourceSocket) {
652
851
  // I don't see any way to avoid the complexity or statement count in here.
653
852
  /* eslint complexity: [0] */
654
853
 
655
854
  try {
656
- const isActiveSocket = sourceSocket === this.socket;
657
855
  const reason = event.reason && event.reason.toLowerCase();
658
-
856
+ const sessionSocket = this.sockets.get(sessionId);
659
857
  let socketUrl;
660
- if (isActiveSocket && this.socket) {
661
- // Active socket closed - get URL from current socket reference
662
- socketUrl = this.socket.url;
663
- } else if (sourceSocket) {
664
- // Old socket closed - get URL from the closed socket
858
+ event.sessionId = sessionId;
859
+
860
+ const isActiveSocket = sourceSocket === sessionSocket;
861
+ if (sourceSocket) {
665
862
  socketUrl = sourceSocket.url;
666
863
  }
864
+ this.sockets.delete(sessionId);
667
865
 
668
866
  if (isActiveSocket) {
669
867
  // Only tear down state if the currently active socket closed
670
- if (this.socket) {
671
- this.socket.removeAllListeners();
868
+ if (sessionSocket) {
869
+ sessionSocket.removeAllListeners();
870
+ if (sessionId === this.defaultSessionId) this.unset('socket');
871
+ this._emit(sessionId, 'offline', event);
872
+ }
873
+ // Update overall connected status
874
+ this.connecting = this.hasConnectingSockets();
875
+ this.connected = this.hasConnectedSockets();
876
+
877
+ if (!this.connected) {
878
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
672
879
  }
673
- this.unset('socket');
674
- this.connected = false;
675
- this._emit('offline', event);
676
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
677
880
  } else {
678
881
  // Old socket closed; do not flip connection state
679
882
  this.logger.info(
680
- `${this.namespace}: [shutdown] non-active socket closed, code=${event.code}`
883
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code} for ${sessionId}`
681
884
  );
682
885
  // Clean up listeners from old socket now that it's closed
683
886
  if (sourceSocket) {
@@ -689,14 +892,14 @@ const Mercury = WebexPlugin.extend({
689
892
  case 1003:
690
893
  // metric: disconnect
691
894
  this.logger.info(
692
- `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}`
895
+ `${this.namespace}: Mercury service rejected last message for ${sessionId}; will not reconnect: ${event.reason}`
693
896
  );
694
- if (isActiveSocket) this._emit('offline.permanent', event);
897
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
695
898
  break;
696
899
  case 4000:
697
900
  // metric: disconnect
698
- this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
699
- if (isActiveSocket) this._emit('offline.replaced', event);
901
+ this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`);
902
+ if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event);
700
903
  // If not active, nothing to do
701
904
  break;
702
905
  case 4001:
@@ -706,26 +909,28 @@ const Mercury = WebexPlugin.extend({
706
909
  // to be replaced, but the switchover in _handleImminentShutdown failed.
707
910
  // This is a permanent failure - do not reconnect.
708
911
  this.logger.warn(
709
- `${this.namespace}: active socket closed with 4001; shutdown switchover failed`
912
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed for ${sessionId}`
710
913
  );
711
- this._emit('offline.permanent', event);
914
+ this._emit(sessionId, 'offline.permanent', event);
712
915
  } else {
713
916
  // Expected: old socket closed after successful switchover
714
917
  this.logger.info(
715
- `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed`
918
+ `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed for ${sessionId}`
716
919
  );
717
- this._emit('offline.replaced', event);
920
+ this._emit(sessionId, 'offline.replaced', event);
718
921
  }
719
922
  break;
720
923
  case 1001:
721
924
  case 1005:
722
925
  case 1006:
723
926
  case 1011:
724
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
927
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
725
928
  if (isActiveSocket) {
726
- this._emit('offline.transient', event);
727
- this.logger.info(`${this.namespace}: [shutdown] reconnecting active socket to recover`);
728
- this._reconnect(socketUrl);
929
+ this._emit(sessionId, 'offline.transient', event);
930
+ this.logger.info(
931
+ `${this.namespace}: [shutdown] reconnecting active socket to recover for ${sessionId}`
932
+ );
933
+ this._reconnect(socketUrl, sessionId);
729
934
  }
730
935
  // metric: disconnect
731
936
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
@@ -733,51 +938,61 @@ const Mercury = WebexPlugin.extend({
733
938
  case 1000:
734
939
  case 3050: // 3050 indicates logout form of closure, default to old behavior, use config reason defined by consumer to proceed with the permanent block
735
940
  if (normalReconnectReasons.includes(reason)) {
736
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
941
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
737
942
  if (isActiveSocket) {
738
- this._emit('offline.transient', event);
739
- this.logger.info(`${this.namespace}: [shutdown] reconnecting due to normal close`);
740
- this._reconnect(socketUrl);
943
+ this._emit(sessionId, 'offline.transient', event);
944
+ this.logger.info(
945
+ `${this.namespace}: [shutdown] reconnecting due to normal close for ${sessionId}`
946
+ );
947
+ this._reconnect(socketUrl, sessionId);
741
948
  }
742
949
  // metric: disconnect
743
950
  // if (reason === done forced) metric: force closure
744
951
  } else {
745
952
  this.logger.info(
746
- `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
953
+ `${this.namespace}: socket ${sessionId} disconnected; will not reconnect: ${event.reason}`
747
954
  );
748
- if (isActiveSocket) this._emit('offline.permanent', event);
955
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
749
956
  }
750
957
  break;
751
958
  default:
752
959
  this.logger.info(
753
- `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
960
+ `${this.namespace}: socket ${sessionId} disconnected unexpectedly; will not reconnect`
754
961
  );
755
962
  // unexpected disconnect
756
- if (isActiveSocket) this._emit('offline.permanent', event);
963
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
757
964
  }
758
965
  } catch (error) {
759
- this.logger.error(`${this.namespace}: error occurred in close handler`, error);
966
+ this.logger.error(
967
+ `${this.namespace}: error occurred in close handler for ${sessionId}`,
968
+ error
969
+ );
760
970
  }
761
971
  },
762
972
 
763
- _onmessage(event) {
764
- this._setTimeOffset(event);
973
+ _onmessage(sessionId, event) {
974
+ this._setTimeOffset(sessionId, event);
765
975
  const envelope = event.data;
766
976
 
767
977
  if (process.env.ENABLE_MERCURY_LOGGING) {
768
- this.logger.debug(`${this.namespace}: message envelope: `, envelope);
978
+ this.logger.debug(`${this.namespace}: message envelope from ${sessionId}: `, envelope);
769
979
  }
770
980
 
981
+ envelope.sessionId = sessionId;
982
+
771
983
  // Handle shutdown message shape: { type: 'shutdown' }
772
984
  if (envelope && envelope.type === 'shutdown') {
773
- this.logger.info(`${this.namespace}: [shutdown] imminent shutdown message received`);
774
- this._emit('event:mercury_shutdown_imminent', envelope);
985
+ this.logger.info(
986
+ `${this.namespace}: [shutdown] imminent shutdown message received for ${sessionId}`
987
+ );
988
+ this._emit(sessionId, 'event:mercury_shutdown_imminent', envelope);
775
989
 
776
- this._handleImminentShutdown();
990
+ this._handleImminentShutdown(sessionId);
777
991
 
778
992
  return Promise.resolve();
779
993
  }
780
994
 
995
+ envelope.sessionId = sessionId;
781
996
  const {data} = envelope;
782
997
 
783
998
  this._applyOverrides(data);
@@ -792,7 +1007,7 @@ const Mercury = WebexPlugin.extend({
792
1007
  resolve((this.webex[namespace] || this.webex.internal[namespace])[name](data))
793
1008
  ).catch((reason) =>
794
1009
  this.logger.error(
795
- `${this.namespace}: error occurred in autowired event handler for ${data.eventType}`,
1010
+ `${this.namespace}: error occurred in autowired event handler for ${data.eventType} from ${sessionId}`,
796
1011
  reason
797
1012
  )
798
1013
  );
@@ -800,32 +1015,35 @@ const Mercury = WebexPlugin.extend({
800
1015
  Promise.resolve()
801
1016
  )
802
1017
  .then(() => {
803
- this._emit('event', event.data);
1018
+ this._emit(sessionId, 'event', envelope);
804
1019
  const [namespace] = data.eventType.split('.');
805
1020
 
806
1021
  if (namespace === data.eventType) {
807
- this._emit(`event:${namespace}`, envelope);
1022
+ this._emit(sessionId, `event:${namespace}`, envelope);
808
1023
  } else {
809
- this._emit(`event:${namespace}`, envelope);
810
- this._emit(`event:${data.eventType}`, envelope);
1024
+ this._emit(sessionId, `event:${namespace}`, envelope);
1025
+ this._emit(sessionId, `event:${data.eventType}`, envelope);
811
1026
  }
812
1027
  })
813
1028
  .catch((reason) => {
814
- this.logger.error(`${this.namespace}: error occurred processing socket message`, reason);
1029
+ this.logger.error(
1030
+ `${this.namespace}: error occurred processing socket message from ${sessionId}`,
1031
+ reason
1032
+ );
815
1033
  });
816
1034
  },
817
1035
 
818
- _setTimeOffset(event) {
1036
+ _setTimeOffset(sessionId, event) {
819
1037
  const {wsWriteTimestamp} = event.data;
820
1038
  if (typeof wsWriteTimestamp === 'number' && wsWriteTimestamp > 0) {
821
1039
  this.mercuryTimeOffset = Date.now() - wsWriteTimestamp;
822
1040
  }
823
1041
  },
824
1042
 
825
- _reconnect(webSocketUrl) {
826
- this.logger.info(`${this.namespace}: reconnecting`);
1043
+ _reconnect(webSocketUrl, sessionId = this.defaultSessionId) {
1044
+ this.logger.info(`${this.namespace}: reconnecting ${sessionId}`);
827
1045
 
828
- return this.connect(webSocketUrl);
1046
+ return this.connect(webSocketUrl, sessionId);
829
1047
  },
830
1048
  });
831
1049