From 8e0f835eeedb9cdc61d6042bc5b22fd70706278d Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Mon, 20 Jul 2026 12:36:56 +0530 Subject: [PATCH 1/5] fix(mercury): gracefully handle mercury rebalancing --- .../internal-plugin-mercury/src/mercury.js | 15 +++- .../test/unit/spec/mercury.js | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index ba258e44f5e..fc6d190e77b 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -901,9 +901,18 @@ const Mercury = WebexPlugin.extend({ break; case 4000: // metric: disconnect - this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`); - if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event); - // If not active, nothing to do + if (reason === 'replaced') { + this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`); + if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event); + } else { + this.logger.info( + `${this.namespace}: socket ${sessionId} disconnected with 4000: ${event.reason}; reconnecting` + ); + if (isActiveSocket) { + this._emit(sessionId, 'offline.transient', event); + this._reconnect(socketUrl, sessionId); + } + } break; case 4001: // replaced during shutdown diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js index 5f7b4f911e8..d5a5ab6c3b6 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js @@ -1280,6 +1280,74 @@ describe('plugin-mercury', () => { }); }); + describe('#_onclose() with code 4000', () => { + let mockSocket, anotherSocket; + + beforeEach(() => { + mockSocket = { + url: 'ws://active-socket.com', + removeAllListeners: sinon.stub(), + }; + anotherSocket = { + url: 'ws://old-socket.com', + removeAllListeners: sinon.stub(), + }; + mercury.socket = mockSocket; + mercury.sockets.set(mercury.defaultSessionId, mockSocket); + mercury.connected = true; + sinon.stub(mercury, '_emit'); + sinon.stub(mercury, '_reconnect'); + sinon.stub(mercury, 'unset'); + }); + + afterEach(() => { + mercury._emit.restore(); + mercury._reconnect.restore(); + mercury.unset.restore(); + }); + + it('should not reconnect when active socket is replaced', () => { + const closeEvent = { + code: 4000, + reason: 'Replaced', + }; + + mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket); + + assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent); + assert.notCalled(mercury._reconnect); + }); + + it('should reconnect active socket when 4000 has an unknown reason', () => { + const closeEvent = { + code: 4000, + reason: 'Unexpected close', + }; + + mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket); + + assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.transient', closeEvent); + assert.calledWith( + mercury._reconnect, + mockSocket.url, + mercury.defaultSessionId + ); + }); + + it('should not reconnect a non-active socket with an unknown 4000 reason', () => { + const closeEvent = { + code: 4000, + reason: 'Unexpected close', + }; + + mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket); + + assert.notCalled(mercury._emit); + assert.notCalled(mercury._reconnect); + assert.isTrue(mercury.connected); + }); + }); + describe('#_onclose() with code 4001 (shutdown replacement)', () => { let mockSocket, anotherSocket; From 9c0d1145da793d440bf3c6b02fcb03e5490c4a82 Mon Sep 17 00:00:00 2001 From: Shreyas Sharma Date: Mon, 27 Jul 2026 20:16:02 +0530 Subject: [PATCH 2/5] fix(mercury): add JS_SDK_MERCURY_CLOSE_4000 metric --- .../internal-plugin-mercury/src/mercury.js | 34 ++++++++++++ .../test/unit/spec/mercury.js | 54 +++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index fc6d190e77b..5ca0a45b16f 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -21,6 +21,7 @@ import { } from './errors'; const normalReconnectReasons = ['idle', 'done (forced)', 'pong not received', 'pong mismatch']; +const MERCURY_CLOSE_4000_METRIC = 'JS_SDK_MERCURY_CLOSE_4000'; const Mercury = WebexPlugin.extend({ namespace: 'Mercury', @@ -850,6 +851,29 @@ const Mercury = WebexPlugin.extend({ return handlers; }, + _submitMercuryClose4000Metric(sessionId, event, options) { + const {action, isActiveSocket, messageType} = options; + + try { + this.webex.internal.metrics.submitClientMetrics(MERCURY_CLOSE_4000_METRIC, { + fields: { + action, + close_code: event.code, + close_reason: event.reason || '', + is_active_socket: isActiveSocket, + message_type: messageType, + session_id: sessionId, + }, + tags: { + action, + message_type: messageType, + }, + }); + } catch (error) { + this.logger.warn(`${this.namespace}: failed to submit Mercury 4000 close metric`, error); + } + }, + _onclose(sessionId, event, sourceSocket) { // I don't see any way to avoid the complexity or statement count in here. /* eslint complexity: [0] */ @@ -902,9 +926,19 @@ const Mercury = WebexPlugin.extend({ case 4000: // metric: disconnect if (reason === 'replaced') { + this._submitMercuryClose4000Metric(sessionId, event, { + action: 'no_action', + isActiveSocket, + messageType: 'replaced', + }); this.logger.info(`${this.namespace}: socket ${sessionId} replaced; will not reconnect`); if (isActiveSocket) this._emit(sessionId, 'offline.replaced', event); } else { + this._submitMercuryClose4000Metric(sessionId, event, { + action: isActiveSocket ? 'reconnect' : 'ignore_non_active', + isActiveSocket, + messageType: 'other', + }); this.logger.info( `${this.namespace}: socket ${sessionId} disconnected with 4000: ${event.reason}; reconnecting` ); diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js index d5a5ab6c3b6..f7a1319ab97 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js @@ -1314,6 +1314,24 @@ describe('plugin-mercury', () => { mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket); + assert.calledOnceWithExactly( + webex.internal.metrics.submitClientMetrics, + 'JS_SDK_MERCURY_CLOSE_4000', + { + fields: { + action: 'no_action', + close_code: 4000, + close_reason: 'Replaced', + is_active_socket: true, + message_type: 'replaced', + session_id: mercury.defaultSessionId, + }, + tags: { + action: 'no_action', + message_type: 'replaced', + }, + } + ); assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.replaced', closeEvent); assert.notCalled(mercury._reconnect); }); @@ -1326,6 +1344,24 @@ describe('plugin-mercury', () => { mercury._onclose(mercury.defaultSessionId, closeEvent, mockSocket); + assert.calledOnceWithExactly( + webex.internal.metrics.submitClientMetrics, + 'JS_SDK_MERCURY_CLOSE_4000', + { + fields: { + action: 'reconnect', + close_code: 4000, + close_reason: 'Unexpected close', + is_active_socket: true, + message_type: 'other', + session_id: mercury.defaultSessionId, + }, + tags: { + action: 'reconnect', + message_type: 'other', + }, + } + ); assert.calledWith(mercury._emit, mercury.defaultSessionId, 'offline.transient', closeEvent); assert.calledWith( mercury._reconnect, @@ -1342,6 +1378,24 @@ describe('plugin-mercury', () => { mercury._onclose(mercury.defaultSessionId, closeEvent, anotherSocket); + assert.calledOnceWithExactly( + webex.internal.metrics.submitClientMetrics, + 'JS_SDK_MERCURY_CLOSE_4000', + { + fields: { + action: 'ignore_non_active', + close_code: 4000, + close_reason: 'Unexpected close', + is_active_socket: false, + message_type: 'other', + session_id: mercury.defaultSessionId, + }, + tags: { + action: 'ignore_non_active', + message_type: 'other', + }, + } + ); assert.notCalled(mercury._emit); assert.notCalled(mercury._reconnect); assert.isTrue(mercury.connected); From a2edad193d5154e31127198d85a1f3939e36eb9c Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Fri, 14 Aug 2026 11:32:25 +0530 Subject: [PATCH 3/5] test(mercury): cover close reason handling --- .../test/unit/spec/mercury-events.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js index a2dd39d77aa..9dfb599575b 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury-events.js @@ -267,8 +267,14 @@ describe('plugin-mercury', () => { }, { code: 4000, + reason: 'Replaced', action: 'replace', }, + { + code: 4000, + reason: 'Unexpected close', + action: 'reconnect', + }, { action: 'close', }, From 63518cacfa9b4c76fa23a93165099cd109017272 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Tue, 18 Aug 2026 17:05:21 +0530 Subject: [PATCH 4/5] fix(mercury): normalize replaced close reason --- packages/@webex/internal-plugin-mercury/src/mercury.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index 3ecfb58166a..faac4106e30 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -911,7 +911,7 @@ const Mercury = WebexPlugin.extend({ /* eslint complexity: [0] */ try { - const reason = event.reason && event.reason.toLowerCase(); + const {reason} = event; const sessionSocket = this.sockets.get(sessionId); event.sessionId = sessionId; @@ -962,7 +962,7 @@ const Mercury = WebexPlugin.extend({ break; case 4000: // metric: disconnect - if (reason === 'replaced') { + if (reason?.toLowerCase() === 'replaced') { this._submitMercuryClose4000Metric(sessionId, event, { action: 'no_action', isActiveSocket, From 5c9472eadfe07d8cea07950e8d71faa2e24a22a2 Mon Sep 17 00:00:00 2001 From: Ravi Chandra Sekhar Sarika Date: Tue, 18 Aug 2026 17:23:13 +0530 Subject: [PATCH 5/5] fix(mercury): preserve normalized close reasons --- packages/@webex/internal-plugin-mercury/src/mercury.js | 4 ++-- .../@webex/internal-plugin-mercury/test/unit/spec/mercury.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/@webex/internal-plugin-mercury/src/mercury.js b/packages/@webex/internal-plugin-mercury/src/mercury.js index faac4106e30..3ecfb58166a 100644 --- a/packages/@webex/internal-plugin-mercury/src/mercury.js +++ b/packages/@webex/internal-plugin-mercury/src/mercury.js @@ -911,7 +911,7 @@ const Mercury = WebexPlugin.extend({ /* eslint complexity: [0] */ try { - const {reason} = event; + const reason = event.reason && event.reason.toLowerCase(); const sessionSocket = this.sockets.get(sessionId); event.sessionId = sessionId; @@ -962,7 +962,7 @@ const Mercury = WebexPlugin.extend({ break; case 4000: // metric: disconnect - if (reason?.toLowerCase() === 'replaced') { + if (reason === 'replaced') { this._submitMercuryClose4000Metric(sessionId, event, { action: 'no_action', isActiveSocket, diff --git a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js index a1d3f27c5ee..8aa660d3486 100644 --- a/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js +++ b/packages/@webex/internal-plugin-mercury/test/unit/spec/mercury.js @@ -1348,6 +1348,7 @@ describe('plugin-mercury', () => { }; mercury.socket = mockSocket; mercury.sockets.set(mercury.defaultSessionId, mockSocket); + mercury.sessionWebSocketUrls.set(mercury.defaultSessionId, mockSocket.url); mercury.connected = true; sinon.stub(mercury, '_emit'); sinon.stub(mercury, '_reconnect');