@webex/internal-plugin-mercury 3.10.0 → 3.11.0-next.10

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
 
@@ -277,7 +421,26 @@ const Mercury = WebexPlugin.extend({
277
421
  .getFeature('developer', 'web-high-availability')
278
422
  .then((haMessagingEnabled) => {
279
423
  if (haMessagingEnabled) {
280
- return this.webex.internal.services.convertUrlToPriorityHostUrl(webSocketUrl);
424
+ let highPrioritySocketUrl;
425
+ try {
426
+ highPrioritySocketUrl =
427
+ this.webex.internal.services.convertUrlToPriorityHostUrl(webSocketUrl);
428
+ } catch (e) {
429
+ this.logger.warn(`${this.namespace}: error converting to high priority url`, e);
430
+ }
431
+ if (!highPrioritySocketUrl) {
432
+ const hostFromUrl = url.parse(webSocketUrl, true)?.host;
433
+ const isValidHost = this.webex.internal.services.isValidHost(hostFromUrl);
434
+ if (!isValidHost) {
435
+ this.logger.error(
436
+ `${this.namespace}: host ${hostFromUrl} is not a valid host from host catalog`
437
+ );
438
+
439
+ return '';
440
+ }
441
+ }
442
+
443
+ return highPrioritySocketUrl || webSocketUrl;
281
444
  }
282
445
 
283
446
  return webSocketUrl;
@@ -287,6 +450,9 @@ const Mercury = WebexPlugin.extend({
287
450
  })
288
451
  .then(() => this.webex.internal.feature.getFeature('developer', 'web-shared-mercury'))
289
452
  .then((webSharedMercury) => {
453
+ if (!webSocketUrl) {
454
+ return '';
455
+ }
290
456
  webSocketUrl = url.parse(webSocketUrl, true);
291
457
  Object.assign(webSocketUrl.query, {
292
458
  outboundWireFormat: 'text',
@@ -307,34 +473,29 @@ const Mercury = WebexPlugin.extend({
307
473
  }
308
474
 
309
475
  webSocketUrl.query.clientTimestamp = Date.now();
476
+ delete webSocketUrl.search;
310
477
 
311
478
  return url.format(webSocketUrl);
312
479
  });
313
480
  },
314
481
 
315
- _attemptConnection(socketUrl, callback, options = {}) {
482
+ _attemptConnection(socketUrl, sessionId, callback, options = {}) {
316
483
  const {isShutdownSwitchover = false, onSuccess = null} = options;
317
484
 
318
485
  const socket = new Socket();
486
+ socket.connecting = true;
319
487
  let newWSUrl;
320
488
 
321
- this._attachSocketEventListeners(socket);
322
-
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);
327
-
328
- this.logger.info(msg);
329
-
330
- // Call the callback with the error before rejecting
331
- callback(err);
489
+ this._attachSocketEventListeners(socket, sessionId);
332
490
 
333
- return Promise.reject(err);
334
- }
491
+ const backoffCall = isShutdownSwitchover
492
+ ? this._shutdownSwitchoverBackoffCalls.get(sessionId)
493
+ : this.backoffCalls.get(sessionId);
335
494
 
336
- if (!isShutdownSwitchover && !this.backoffCall) {
337
- 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}`;
338
499
  const err = new Error(msg);
339
500
 
340
501
  this.logger.info(msg);
@@ -348,16 +509,17 @@ const Mercury = WebexPlugin.extend({
348
509
  // For shutdown switchover, don't set socket yet (make-before-break)
349
510
  // For normal connection, set socket before opening to allow disconnect() to close it
350
511
  if (!isShutdownSwitchover) {
351
- this.socket = socket;
512
+ this.sockets.set(sessionId, socket);
352
513
  }
353
514
 
354
- return this._prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover)
515
+ return this._prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover)
355
516
  .then((webSocketUrl) => {
356
517
  newWSUrl = webSocketUrl;
518
+
357
519
  this.logger.info(
358
520
  `${this.namespace}: ${
359
521
  isShutdownSwitchover ? '[shutdown] switchover' : ''
360
- } connected to mercury, success, action: connected, url: ${newWSUrl}`
522
+ } connected to mercury, success, action: connected for ${sessionId}, url: ${newWSUrl}`
361
523
  );
362
524
 
363
525
  // Custom success handler for shutdown switchover
@@ -384,7 +546,10 @@ const Mercury = WebexPlugin.extend({
384
546
  .catch((reason) => {
385
547
  // For shutdown, simpler error handling - just callback for retry
386
548
  if (isShutdownSwitchover) {
387
- 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
+ );
388
553
 
389
554
  return callback(reason);
390
555
  }
@@ -392,30 +557,36 @@ const Mercury = WebexPlugin.extend({
392
557
  // Normal connection error handling (existing complex logic)
393
558
  this.lastError = reason; // remember the last error
394
559
 
560
+ const backoffCallNormal = this.backoffCalls.get(sessionId);
395
561
  // Suppress connection errors that appear to be network related. This
396
562
  // may end up suppressing metrics during outages, but we might not care
397
563
  // (especially since many of our outages happen in a way that client
398
564
  // metrics can't be trusted).
399
- if (reason.code !== 1006 && this.backoffCall && this.backoffCall?.getNumRetries() > 0) {
400
- 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
+ });
401
570
  }
402
571
  this.logger.info(
403
- `${this.namespace}: connection attempt failed`,
572
+ `${this.namespace}: connection attempt failed for ${sessionId}`,
404
573
  reason,
405
- this.backoffCall?.getNumRetries() === 0 ? reason.stack : ''
574
+ backoffCallNormal?.getNumRetries() === 0 ? reason.stack : ''
406
575
  );
407
576
  // UnknownResponse is produced by IE for any 4XXX; treated it like a bad
408
577
  // web socket url and let WDM handle the token checking
409
578
  if (reason instanceof UnknownResponse) {
410
579
  this.logger.info(
411
- `${this.namespace}: received unknown response code, refreshing device registration`
580
+ `${this.namespace}: received unknown response code for ${sessionId}, refreshing device registration`
412
581
  );
413
582
 
414
583
  return this.webex.internal.device.refresh().then(() => callback(reason));
415
584
  }
416
585
  // NotAuthorized implies expired token
417
586
  if (reason instanceof NotAuthorized) {
418
- this.logger.info(`${this.namespace}: received authorization error, reauthorizing`);
587
+ this.logger.info(
588
+ `${this.namespace}: received authorization error for ${sessionId}, reauthorizing`
589
+ );
419
590
 
420
591
  return this.webex.credentials.refresh({force: true}).then(() => callback(reason));
421
592
  }
@@ -428,8 +599,10 @@ const Mercury = WebexPlugin.extend({
428
599
  // BadRequest implies current credentials are for a Service Account
429
600
  // Forbidden implies current user is not entitle for Webex
430
601
  if (reason instanceof BadRequest || reason instanceof Forbidden) {
431
- this.logger.warn(`${this.namespace}: received unrecoverable response from mercury`);
432
- this.backoffCall.abort();
602
+ this.logger.warn(
603
+ `${this.namespace}: received unrecoverable response from mercury for ${sessionId}`
604
+ );
605
+ backoffCallNormal?.abort();
433
606
 
434
607
  return callback(reason);
435
608
  }
@@ -439,7 +612,7 @@ const Mercury = WebexPlugin.extend({
439
612
  .then((haMessagingEnabled) => {
440
613
  if (haMessagingEnabled) {
441
614
  this.logger.info(
442
- `${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}`
443
616
  );
444
617
 
445
618
  return this.webex.internal.services.markFailedUrl(newWSUrl);
@@ -453,12 +626,15 @@ const Mercury = WebexPlugin.extend({
453
626
  return callback(reason);
454
627
  })
455
628
  .catch((reason) => {
456
- 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
+ );
457
633
  callback(reason);
458
634
  });
459
635
  },
460
636
 
461
- _prepareAndOpenSocket(socket, socketUrl, isShutdownSwitchover = false) {
637
+ _prepareAndOpenSocket(socket, socketUrl, sessionId, isShutdownSwitchover = false) {
462
638
  const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
463
639
 
464
640
  return Promise.all([this._prepareUrl(socketUrl), this.webex.credentials.getUserToken()]).then(
@@ -481,30 +657,32 @@ const Mercury = WebexPlugin.extend({
481
657
  options = {...options, ...this.webex.config.defaultMercuryOptions};
482
658
  }
483
659
 
484
- 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}`);
485
666
 
486
667
  return socket.open(webSocketUrl, options).then(() => webSocketUrl);
487
668
  }
488
669
  );
489
670
  },
490
671
 
491
- _connectWithBackoff(webSocketUrl, context = {}) {
672
+ _connectWithBackoff(webSocketUrl, sessionId, context = {}) {
492
673
  const {isShutdownSwitchover = false, attemptOptions = {}} = context;
493
674
 
494
675
  return new Promise((resolve, reject) => {
495
- // eslint gets confused about whether or not call is actually used
676
+ // eslint gets confused about whether call is actually used
496
677
  // eslint-disable-next-line prefer-const
497
678
  let call;
498
- const onComplete = (err) => {
499
- // Clear state flags based on connection type
679
+ const onComplete = (err, sid = sessionId) => {
500
680
  if (isShutdownSwitchover) {
501
- this._shutdownSwitchoverInProgress = false;
502
- this._shutdownSwitchoverBackoffCall = undefined;
681
+ this._shutdownSwitchoverBackoffCalls.delete(sid);
503
682
  } else {
504
- this.connecting = false;
505
- this.backoffCall = undefined;
683
+ this.backoffCalls.delete(sid);
506
684
  }
507
-
685
+ const sessionSocket = this.sockets.get(sid);
508
686
  if (err) {
509
687
  const msg = isShutdownSwitchover
510
688
  ? `[shutdown] switchover failed after ${call.getNumRetries()} retries`
@@ -513,29 +691,45 @@ const Mercury = WebexPlugin.extend({
513
691
  this.logger.info(
514
692
  `${this.namespace}: ${msg}; log statement about next retry was inaccurate; ${err}`
515
693
  );
694
+ if (sessionSocket) {
695
+ sessionSocket.connecting = false;
696
+ sessionSocket.connected = false;
697
+ }
516
698
 
517
699
  return reject(err);
518
700
  }
519
701
 
702
+ // Update overall connected status
703
+ if (sessionSocket) {
704
+ sessionSocket.connecting = false;
705
+ sessionSocket.connected = true;
706
+ }
520
707
  // Default success handling for normal connections
521
708
  if (!isShutdownSwitchover) {
522
- this.connected = true;
709
+ this.connecting = this.hasConnectingSockets();
710
+ this.connected = this.hasConnectedSockets();
523
711
  this.hasEverConnected = true;
524
- this._emit('online');
525
- 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
+ }
526
716
  }
527
717
 
528
718
  return resolve();
529
719
  };
530
-
531
720
  // eslint-disable-next-line prefer-reflect
532
- call = backoff.call((callback) => {
533
- const attemptNum = call.getNumRetries();
534
- const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
721
+ call = backoff.call(
722
+ (callback) => {
723
+ const attemptNum = call.getNumRetries();
724
+ const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : 'connection';
535
725
 
536
- this.logger.info(`${this.namespace}: executing ${logPrefix} attempt ${attemptNum}`);
537
- this._attemptConnection(webSocketUrl, callback, attemptOptions);
538
- }, 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
+ );
539
733
 
540
734
  call.setStrategy(
541
735
  new backoff.ExponentialStrategy({
@@ -554,23 +748,32 @@ const Mercury = WebexPlugin.extend({
554
748
  call.failAfter(this.config.maxRetries);
555
749
  }
556
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
+
557
759
  call.on('abort', () => {
558
760
  const msg = isShutdownSwitchover ? 'Shutdown Switchover' : 'Connection';
559
761
 
560
- this.logger.info(`${this.namespace}: ${msg} aborted`);
561
- 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}`));
562
764
  });
563
765
 
564
766
  call.on('callback', (err) => {
565
767
  if (err) {
566
768
  const number = call.getNumRetries();
567
769
  const delay = Math.min(call.strategy_.nextBackoffDelay_, this.config.backoffTimeMax);
770
+
568
771
  const logPrefix = isShutdownSwitchover ? '[shutdown] switchover' : '';
569
772
 
570
773
  this.logger.info(
571
774
  `${this.namespace}: ${logPrefix} failed to connect; attempting retry ${
572
775
  number + 1
573
- } in ${delay} ms`
776
+ } in ${delay} ms for ${sessionId}`
574
777
  );
575
778
  /* istanbul ignore if */
576
779
  if (process.env.NODE_ENV === 'development') {
@@ -579,30 +782,48 @@ const Mercury = WebexPlugin.extend({
579
782
 
580
783
  return;
581
784
  }
582
- this.logger.info(`${this.namespace}: connected`);
785
+ this.logger.info(`${this.namespace}: connected ${sessionId}`);
583
786
  });
584
787
 
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
- }
591
-
592
788
  call.start();
593
789
  });
594
790
  },
595
791
 
596
792
  _emit(...args) {
597
793
  try {
598
- 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
+ }
599
813
  } catch (error) {
600
- this.logger.error(
601
- `${this.namespace}: error occurred in event handler:`,
602
- error,
603
- ' with args: ',
604
- args
605
- );
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
+ }
606
827
  }
607
828
  },
608
829
 
@@ -626,36 +847,40 @@ const Mercury = WebexPlugin.extend({
626
847
  return handlers;
627
848
  },
628
849
 
629
- _onclose(event, sourceSocket) {
850
+ _onclose(sessionId, event, sourceSocket) {
630
851
  // I don't see any way to avoid the complexity or statement count in here.
631
852
  /* eslint complexity: [0] */
632
853
 
633
854
  try {
634
- const isActiveSocket = sourceSocket === this.socket;
635
855
  const reason = event.reason && event.reason.toLowerCase();
636
-
856
+ const sessionSocket = this.sockets.get(sessionId);
637
857
  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
858
+ event.sessionId = sessionId;
859
+
860
+ const isActiveSocket = sourceSocket === sessionSocket;
861
+ if (sourceSocket) {
643
862
  socketUrl = sourceSocket.url;
644
863
  }
864
+ this.sockets.delete(sessionId);
645
865
 
646
866
  if (isActiveSocket) {
647
867
  // Only tear down state if the currently active socket closed
648
- if (this.socket) {
649
- 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);
650
879
  }
651
- this.unset('socket');
652
- this.connected = false;
653
- this._emit('offline', event);
654
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
655
880
  } else {
656
881
  // Old socket closed; do not flip connection state
657
882
  this.logger.info(
658
- `${this.namespace}: [shutdown] non-active socket closed, code=${event.code}`
883
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code} for ${sessionId}`
659
884
  );
660
885
  // Clean up listeners from old socket now that it's closed
661
886
  if (sourceSocket) {
@@ -667,14 +892,14 @@ const Mercury = WebexPlugin.extend({
667
892
  case 1003:
668
893
  // metric: disconnect
669
894
  this.logger.info(
670
- `${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}`
671
896
  );
672
- if (isActiveSocket) this._emit('offline.permanent', event);
897
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
673
898
  break;
674
899
  case 4000:
675
900
  // metric: disconnect
676
- this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
677
- 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);
678
903
  // If not active, nothing to do
679
904
  break;
680
905
  case 4001:
@@ -684,26 +909,28 @@ const Mercury = WebexPlugin.extend({
684
909
  // to be replaced, but the switchover in _handleImminentShutdown failed.
685
910
  // This is a permanent failure - do not reconnect.
686
911
  this.logger.warn(
687
- `${this.namespace}: active socket closed with 4001; shutdown switchover failed`
912
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed for ${sessionId}`
688
913
  );
689
- this._emit('offline.permanent', event);
914
+ this._emit(sessionId, 'offline.permanent', event);
690
915
  } else {
691
916
  // Expected: old socket closed after successful switchover
692
917
  this.logger.info(
693
- `${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}`
694
919
  );
695
- this._emit('offline.replaced', event);
920
+ this._emit(sessionId, 'offline.replaced', event);
696
921
  }
697
922
  break;
698
923
  case 1001:
699
924
  case 1005:
700
925
  case 1006:
701
926
  case 1011:
702
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
927
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
703
928
  if (isActiveSocket) {
704
- this._emit('offline.transient', event);
705
- this.logger.info(`${this.namespace}: [shutdown] reconnecting active socket to recover`);
706
- 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);
707
934
  }
708
935
  // metric: disconnect
709
936
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
@@ -711,51 +938,61 @@ const Mercury = WebexPlugin.extend({
711
938
  case 1000:
712
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
713
940
  if (normalReconnectReasons.includes(reason)) {
714
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
941
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
715
942
  if (isActiveSocket) {
716
- this._emit('offline.transient', event);
717
- this.logger.info(`${this.namespace}: [shutdown] reconnecting due to normal close`);
718
- 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);
719
948
  }
720
949
  // metric: disconnect
721
950
  // if (reason === done forced) metric: force closure
722
951
  } else {
723
952
  this.logger.info(
724
- `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
953
+ `${this.namespace}: socket ${sessionId} disconnected; will not reconnect: ${event.reason}`
725
954
  );
726
- if (isActiveSocket) this._emit('offline.permanent', event);
955
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
727
956
  }
728
957
  break;
729
958
  default:
730
959
  this.logger.info(
731
- `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
960
+ `${this.namespace}: socket ${sessionId} disconnected unexpectedly; will not reconnect`
732
961
  );
733
962
  // unexpected disconnect
734
- if (isActiveSocket) this._emit('offline.permanent', event);
963
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
735
964
  }
736
965
  } catch (error) {
737
- 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
+ );
738
970
  }
739
971
  },
740
972
 
741
- _onmessage(event) {
742
- this._setTimeOffset(event);
973
+ _onmessage(sessionId, event) {
974
+ this._setTimeOffset(sessionId, event);
743
975
  const envelope = event.data;
744
976
 
745
977
  if (process.env.ENABLE_MERCURY_LOGGING) {
746
- this.logger.debug(`${this.namespace}: message envelope: `, envelope);
978
+ this.logger.debug(`${this.namespace}: message envelope from ${sessionId}: `, envelope);
747
979
  }
748
980
 
981
+ envelope.sessionId = sessionId;
982
+
749
983
  // Handle shutdown message shape: { type: 'shutdown' }
750
984
  if (envelope && envelope.type === 'shutdown') {
751
- this.logger.info(`${this.namespace}: [shutdown] imminent shutdown message received`);
752
- 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);
753
989
 
754
- this._handleImminentShutdown();
990
+ this._handleImminentShutdown(sessionId);
755
991
 
756
992
  return Promise.resolve();
757
993
  }
758
994
 
995
+ envelope.sessionId = sessionId;
759
996
  const {data} = envelope;
760
997
 
761
998
  this._applyOverrides(data);
@@ -770,7 +1007,7 @@ const Mercury = WebexPlugin.extend({
770
1007
  resolve((this.webex[namespace] || this.webex.internal[namespace])[name](data))
771
1008
  ).catch((reason) =>
772
1009
  this.logger.error(
773
- `${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}`,
774
1011
  reason
775
1012
  )
776
1013
  );
@@ -778,32 +1015,35 @@ const Mercury = WebexPlugin.extend({
778
1015
  Promise.resolve()
779
1016
  )
780
1017
  .then(() => {
781
- this._emit('event', event.data);
1018
+ this._emit(sessionId, 'event', envelope);
782
1019
  const [namespace] = data.eventType.split('.');
783
1020
 
784
1021
  if (namespace === data.eventType) {
785
- this._emit(`event:${namespace}`, envelope);
1022
+ this._emit(sessionId, `event:${namespace}`, envelope);
786
1023
  } else {
787
- this._emit(`event:${namespace}`, envelope);
788
- this._emit(`event:${data.eventType}`, envelope);
1024
+ this._emit(sessionId, `event:${namespace}`, envelope);
1025
+ this._emit(sessionId, `event:${data.eventType}`, envelope);
789
1026
  }
790
1027
  })
791
1028
  .catch((reason) => {
792
- 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
+ );
793
1033
  });
794
1034
  },
795
1035
 
796
- _setTimeOffset(event) {
1036
+ _setTimeOffset(sessionId, event) {
797
1037
  const {wsWriteTimestamp} = event.data;
798
1038
  if (typeof wsWriteTimestamp === 'number' && wsWriteTimestamp > 0) {
799
1039
  this.mercuryTimeOffset = Date.now() - wsWriteTimestamp;
800
1040
  }
801
1041
  },
802
1042
 
803
- _reconnect(webSocketUrl) {
804
- this.logger.info(`${this.namespace}: reconnecting`);
1043
+ _reconnect(webSocketUrl, sessionId = this.defaultSessionId) {
1044
+ this.logger.info(`${this.namespace}: reconnecting ${sessionId}`);
805
1045
 
806
- return this.connect(webSocketUrl);
1046
+ return this.connect(webSocketUrl, sessionId);
807
1047
  },
808
1048
  });
809
1049