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