@webex/internal-plugin-mercury 3.11.0-webex-services-ready.1 → 3.12.0-mobius-socket.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);
344
-
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);
349
-
350
- this.logger.info(msg);
489
+ this._attachSocketEventListeners(socket, sessionId);
351
490
 
352
- // Call the callback with the error before rejecting
353
- callback(err);
491
+ const backoffCall = isShutdownSwitchover
492
+ ? this._shutdownSwitchoverBackoffCalls.get(sessionId)
493
+ : this.backoffCalls.get(sessionId);
354
494
 
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,34 +782,55 @@ 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
 
631
830
  _getEventHandlers(eventType) {
831
+ if (!eventType) {
832
+ return [];
833
+ }
632
834
  const [namespace, name] = eventType.split('.');
633
835
  const handlers = [];
634
836
 
@@ -648,36 +850,40 @@ const Mercury = WebexPlugin.extend({
648
850
  return handlers;
649
851
  },
650
852
 
651
- _onclose(event, sourceSocket) {
853
+ _onclose(sessionId, event, sourceSocket) {
652
854
  // I don't see any way to avoid the complexity or statement count in here.
653
855
  /* eslint complexity: [0] */
654
856
 
655
857
  try {
656
- const isActiveSocket = sourceSocket === this.socket;
657
858
  const reason = event.reason && event.reason.toLowerCase();
658
-
859
+ const sessionSocket = this.sockets.get(sessionId);
659
860
  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
861
+ event.sessionId = sessionId;
862
+
863
+ const isActiveSocket = sourceSocket === sessionSocket;
864
+ if (sourceSocket) {
665
865
  socketUrl = sourceSocket.url;
666
866
  }
867
+ this.sockets.delete(sessionId);
667
868
 
668
869
  if (isActiveSocket) {
669
870
  // Only tear down state if the currently active socket closed
670
- if (this.socket) {
671
- this.socket.removeAllListeners();
871
+ if (sessionSocket) {
872
+ sessionSocket.removeAllListeners();
873
+ if (sessionId === this.defaultSessionId) this.unset('socket');
874
+ this._emit(sessionId, 'offline', event);
875
+ }
876
+ // Update overall connected status
877
+ this.connecting = this.hasConnectingSockets();
878
+ this.connected = this.hasConnectedSockets();
879
+
880
+ if (!this.connected) {
881
+ this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
672
882
  }
673
- this.unset('socket');
674
- this.connected = false;
675
- this._emit('offline', event);
676
- this.webex.internal.newMetrics.callDiagnosticMetrics.setMercuryConnectedStatus(false);
677
883
  } else {
678
884
  // Old socket closed; do not flip connection state
679
885
  this.logger.info(
680
- `${this.namespace}: [shutdown] non-active socket closed, code=${event.code}`
886
+ `${this.namespace}: [shutdown] non-active socket closed, code=${event.code} for ${sessionId}`
681
887
  );
682
888
  // Clean up listeners from old socket now that it's closed
683
889
  if (sourceSocket) {
@@ -689,14 +895,14 @@ const Mercury = WebexPlugin.extend({
689
895
  case 1003:
690
896
  // metric: disconnect
691
897
  this.logger.info(
692
- `${this.namespace}: Mercury service rejected last message; will not reconnect: ${event.reason}`
898
+ `${this.namespace}: Mercury service rejected last message for ${sessionId}; will not reconnect: ${event.reason}`
693
899
  );
694
- if (isActiveSocket) this._emit('offline.permanent', event);
900
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
695
901
  break;
696
902
  case 4000:
697
903
  // metric: disconnect
698
- this.logger.info(`${this.namespace}: socket replaced; will not reconnect`);
699
- if (isActiveSocket) this._emit('offline.replaced', event);
904
+ this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`);
905
+ if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event);
700
906
  // If not active, nothing to do
701
907
  break;
702
908
  case 4001:
@@ -706,26 +912,28 @@ const Mercury = WebexPlugin.extend({
706
912
  // to be replaced, but the switchover in _handleImminentShutdown failed.
707
913
  // This is a permanent failure - do not reconnect.
708
914
  this.logger.warn(
709
- `${this.namespace}: active socket closed with 4001; shutdown switchover failed`
915
+ `${this.namespace}: active socket closed with 4001; shutdown switchover failed for ${sessionId}`
710
916
  );
711
- this._emit('offline.permanent', event);
917
+ this._emit(sessionId, 'offline.permanent', event);
712
918
  } else {
713
919
  // Expected: old socket closed after successful switchover
714
920
  this.logger.info(
715
- `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed`
921
+ `${this.namespace}: old socket closed with 4001 (replaced during shutdown); no reconnect needed for ${sessionId}`
716
922
  );
717
- this._emit('offline.replaced', event);
923
+ this._emit(sessionId, 'offline.replaced', event);
718
924
  }
719
925
  break;
720
926
  case 1001:
721
927
  case 1005:
722
928
  case 1006:
723
929
  case 1011:
724
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
930
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
725
931
  if (isActiveSocket) {
726
- this._emit('offline.transient', event);
727
- this.logger.info(`${this.namespace}: [shutdown] reconnecting active socket to recover`);
728
- this._reconnect(socketUrl);
932
+ this._emit(sessionId, 'offline.transient', event);
933
+ this.logger.info(
934
+ `${this.namespace}: [shutdown] reconnecting active socket to recover for ${sessionId}`
935
+ );
936
+ this._reconnect(socketUrl, sessionId);
729
937
  }
730
938
  // metric: disconnect
731
939
  // if (code == 1011 && reason !== ping error) metric: unexpected disconnect
@@ -733,55 +941,71 @@ const Mercury = WebexPlugin.extend({
733
941
  case 1000:
734
942
  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
943
  if (normalReconnectReasons.includes(reason)) {
736
- this.logger.info(`${this.namespace}: socket disconnected; reconnecting`);
944
+ this.logger.info(`${this.namespace}: socket ${sessionId} disconnected; reconnecting`);
737
945
  if (isActiveSocket) {
738
- this._emit('offline.transient', event);
739
- this.logger.info(`${this.namespace}: [shutdown] reconnecting due to normal close`);
740
- this._reconnect(socketUrl);
946
+ this._emit(sessionId, 'offline.transient', event);
947
+ this.logger.info(
948
+ `${this.namespace}: [shutdown] reconnecting due to normal close for ${sessionId}`
949
+ );
950
+ this._reconnect(socketUrl, sessionId);
741
951
  }
742
952
  // metric: disconnect
743
953
  // if (reason === done forced) metric: force closure
744
954
  } else {
745
955
  this.logger.info(
746
- `${this.namespace}: socket disconnected; will not reconnect: ${event.reason}`
956
+ `${this.namespace}: socket ${sessionId} disconnected; will not reconnect: ${event.reason}`
747
957
  );
748
- if (isActiveSocket) this._emit('offline.permanent', event);
958
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
749
959
  }
750
960
  break;
751
961
  default:
752
962
  this.logger.info(
753
- `${this.namespace}: socket disconnected unexpectedly; will not reconnect`
963
+ `${this.namespace}: socket ${sessionId} disconnected unexpectedly; will not reconnect`
754
964
  );
755
965
  // unexpected disconnect
756
- if (isActiveSocket) this._emit('offline.permanent', event);
966
+ if (isActiveSocket) this._emit(sessionId, 'offline.permanent', event);
757
967
  }
758
968
  } catch (error) {
759
- this.logger.error(`${this.namespace}: error occurred in close handler`, error);
969
+ this.logger.error(
970
+ `${this.namespace}: error occurred in close handler for ${sessionId}`,
971
+ error
972
+ );
760
973
  }
761
974
  },
762
975
 
763
- _onmessage(event) {
764
- this._setTimeOffset(event);
976
+ _onmessage(sessionId, event) {
977
+ this._setTimeOffset(sessionId, event);
765
978
  const envelope = event.data;
766
979
 
767
980
  if (process.env.ENABLE_MERCURY_LOGGING) {
768
- this.logger.debug(`${this.namespace}: message envelope: `, envelope);
981
+ this.logger.debug(`${this.namespace}: message envelope from ${sessionId}: `, envelope);
769
982
  }
770
983
 
984
+ envelope.sessionId = sessionId;
985
+
771
986
  // Handle shutdown message shape: { type: 'shutdown' }
772
987
  if (envelope && envelope.type === 'shutdown') {
773
- this.logger.info(`${this.namespace}: [shutdown] imminent shutdown message received`);
774
- this._emit('event:mercury_shutdown_imminent', envelope);
988
+ this.logger.info(
989
+ `${this.namespace}: [shutdown] imminent shutdown message received for ${sessionId}`
990
+ );
991
+ this._emit(sessionId, 'event:mercury_shutdown_imminent', envelope);
775
992
 
776
- this._handleImminentShutdown();
993
+ this._handleImminentShutdown(sessionId);
777
994
 
778
995
  return Promise.resolve();
779
996
  }
780
997
 
998
+ envelope.sessionId = sessionId;
781
999
  const {data} = envelope;
782
1000
 
783
1001
  this._applyOverrides(data);
784
1002
 
1003
+ if (!data || !data.eventType) {
1004
+ this._emit(sessionId, 'event', envelope);
1005
+
1006
+ return Promise.resolve();
1007
+ }
1008
+
785
1009
  return this._getEventHandlers(data.eventType)
786
1010
  .reduce(
787
1011
  (promise, handler) =>
@@ -792,7 +1016,7 @@ const Mercury = WebexPlugin.extend({
792
1016
  resolve((this.webex[namespace] || this.webex.internal[namespace])[name](data))
793
1017
  ).catch((reason) =>
794
1018
  this.logger.error(
795
- `${this.namespace}: error occurred in autowired event handler for ${data.eventType}`,
1019
+ `${this.namespace}: error occurred in autowired event handler for ${data.eventType} from ${sessionId}`,
796
1020
  reason
797
1021
  )
798
1022
  );
@@ -800,32 +1024,35 @@ const Mercury = WebexPlugin.extend({
800
1024
  Promise.resolve()
801
1025
  )
802
1026
  .then(() => {
803
- this._emit('event', event.data);
1027
+ this._emit(sessionId, 'event', envelope);
804
1028
  const [namespace] = data.eventType.split('.');
805
1029
 
806
1030
  if (namespace === data.eventType) {
807
- this._emit(`event:${namespace}`, envelope);
1031
+ this._emit(sessionId, `event:${namespace}`, envelope);
808
1032
  } else {
809
- this._emit(`event:${namespace}`, envelope);
810
- this._emit(`event:${data.eventType}`, envelope);
1033
+ this._emit(sessionId, `event:${namespace}`, envelope);
1034
+ this._emit(sessionId, `event:${data.eventType}`, envelope);
811
1035
  }
812
1036
  })
813
1037
  .catch((reason) => {
814
- this.logger.error(`${this.namespace}: error occurred processing socket message`, reason);
1038
+ this.logger.error(
1039
+ `${this.namespace}: error occurred processing socket message from ${sessionId}`,
1040
+ reason
1041
+ );
815
1042
  });
816
1043
  },
817
1044
 
818
- _setTimeOffset(event) {
1045
+ _setTimeOffset(sessionId, event) {
819
1046
  const {wsWriteTimestamp} = event.data;
820
1047
  if (typeof wsWriteTimestamp === 'number' && wsWriteTimestamp > 0) {
821
1048
  this.mercuryTimeOffset = Date.now() - wsWriteTimestamp;
822
1049
  }
823
1050
  },
824
1051
 
825
- _reconnect(webSocketUrl) {
826
- this.logger.info(`${this.namespace}: reconnecting`);
1052
+ _reconnect(webSocketUrl, sessionId = this.defaultSessionId) {
1053
+ this.logger.info(`${this.namespace}: reconnecting ${sessionId}`);
827
1054
 
828
- return this.connect(webSocketUrl);
1055
+ return this.connect(webSocketUrl, sessionId);
829
1056
  },
830
1057
  });
831
1058