From e9f103e9230295c17230fc6795edec095ee2e120 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Thu, 5 Nov 2026 12:16:21 -0500 Subject: [PATCH 01/22] fix(socket-mode): open the replacement connection before closing the old one on refresh On a Slack "disconnect" message (warning, refresh_requested) the client closed its only connection first and then reconnected through the failure backoff, leaving apps without a connection for 5 to 12 seconds every few hours. Slack allows up to 10 connections and recommends opening the next one before the restart, as python-slack-sdk and java-slack-sdk do. - start() swaps in the new connection and keeps a still-open previous one serving (and acknowledging) until the new one receives hello, then closes it. A previous connection that is not open is closed right away, which also stops start() from orphaning a socket when it runs twice. - A "disconnect" message calls start() immediately, without delayReconnectAttempt, so a refresh is no longer counted as a failure. - "close" now carries the SlackWebSocket that closed; a replaced connection's close no longer triggers a reconnect or a second "disconnected". - disconnect() also closes a connection that is still being retired. Fixes #2767. Co-authored-by: hippi345 <13539685+hippi345@users.noreply.github.com> Co-authored-by: Claude --- .changeset/socket-mode-refresh-overlap.md | 5 + packages/socket-mode/README.md | 2 +- .../socket-mode/src/SlackWebSocket.test.ts | 18 ++ packages/socket-mode/src/SlackWebSocket.ts | 4 +- .../socket-mode/src/SocketModeClient.test.ts | 209 ++++++++++++++++++ packages/socket-mode/src/SocketModeClient.ts | 55 ++++- 6 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 .changeset/socket-mode-refresh-overlap.md diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md new file mode 100644 index 000000000..7dc9bcbea --- /dev/null +++ b/.changeset/socket-mode-refresh-overlap.md @@ -0,0 +1,5 @@ +--- +"@slack/socket-mode": patch +--- + +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index 6463d578b..3cc4aa70b 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -132,7 +132,7 @@ In the table below, the client's states are listed, which are also the names of | `connecting` | | The client is in the process of connecting to the platform. | | `connected` | | The client is connected to the platform and incoming events will start being emitted. | | `disconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `disconnected`. | -| `reconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `connecting`. | +| `reconnecting` | | The client is replacing its connection. On a Slack refresh, the current connection keeps serving until the new one is `connected`. It will soon transition to `connecting`. | | `disconnected` | `(error)` | The client is not connected to the platform. This is a steady state - no attempt to connect is occurring. The `error` argument will be `undefined` when the client initiated the disconnect (normal). | The client also emits events that are part of its lifecycle, but aren't states. Instead, they represent specific moments that might be helpful to your app. The following table lists these events, their description, and includes the arguments that a listener would receive. diff --git a/packages/socket-mode/src/SlackWebSocket.test.ts b/packages/socket-mode/src/SlackWebSocket.test.ts index 00f0bce04..cda085831 100644 --- a/packages/socket-mode/src/SlackWebSocket.test.ts +++ b/packages/socket-mode/src/SlackWebSocket.test.ts @@ -272,6 +272,24 @@ describe('SlackWebSocket', () => { }); }); + describe('close notification', () => { + it('should emit "close" with the closed SlackWebSocket, so the client can tell connections apart', () => { + const client = new EventEmitter(); + const closed = sandbox.spy(); + client.on('close', closed); + const sws = new SlackWebSocket({ + url: 'ws://127.0.0.1/', + client, + clientPingTimeoutMS: 1, + serverPingTimeoutMS: 1, + }); + + sws.disconnect(); + + sinon.assert.calledOnceWithExactly(closed, sws); + }); + }); + describe('buildDefaultDispatcher() connect hook', () => { type ConnectCb = (err: Error | null, socket: unknown) => void; type ConnectFn = (opts: unknown, cb: ConnectCb) => void; diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index c5540197a..277219448 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -294,9 +294,9 @@ export class SlackWebSocket { clearTimeout(this.serverPingTimeout); clearInterval(this.clientPingTimeout); clearTimeout(this.closeHandshakeTimeout); - // Emit event back to client letting it know connection has closed (in case it needs to reconnect if + // Emit event back to client letting it know which connection has closed (in case it needs to reconnect if // reconnecting is enabled) - this.options.client.emit('close'); + this.options.client.emit('close', this); } /** diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index bdb34d06a..940984ea9 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -576,6 +576,215 @@ describe('SocketModeClient', () => { assert.strictEqual(client.websocket, undefined); }); }); + + describe('server-requested disconnect (issue #2767)', () => { + const CONNECTING = 0; + const OPEN = 1; + + class FakeSlackWebSocket { + static instances: FakeSlackWebSocket[] = []; + readyState: number | undefined = CONNECTING; + sent: string[] = []; + disconnectCalls = 0; + private client: { emit: (event: string, ...args: unknown[]) => boolean }; + constructor({ client }: { client: { emit: (event: string, ...args: unknown[]) => boolean } }) { + this.client = client; + FakeSlackWebSocket.instances.push(this); + } + connect() {} + isActive() { + return this.readyState === OPEN; + } + open() { + this.readyState = OPEN; + return this; + } + receive(message: Record) { + this.client.emit('ws_message', JSON.stringify(message), false); + } + hello() { + this.receive({ type: 'hello' }); + } + drop() { + this.readyState = undefined; + this.client.emit('close', this); + } + disconnect() { + this.disconnectCalls += 1; + this.drop(); + } + send(data: string, cb: (err: Error | undefined) => void) { + this.sent.push(data); + cb(undefined); + } + } + + const sockets = FakeSlackWebSocket.instances; + const refreshRequested = { type: 'disconnect', reason: 'refresh_requested' }; + const envelope = { type: 'interactive', envelope_id: 'e-1', payload: {} }; + + function createClient(options: { clientPingTimeout?: number; autoReconnectEnabled?: boolean } = {}) { + sockets.length = 0; + const ProxiedSocketModeClient: typeof SocketModeClient = proxyquire('./SocketModeClient', { + './SlackWebSocket': { SlackWebSocket: FakeSlackWebSocket }, + }).SocketModeClient; + const logger = new ConsoleLogger(); + const error = sandbox.stub(logger, 'error'); + sandbox.stub(logger, 'debug'); + const client = new ProxiedSocketModeClient({ appToken: 'xapp-', clientPingTimeout: 60_000, logger, ...options }); + const urls: Array> = []; + sandbox + .stub(client as unknown as { retrieveWSSURL: () => Promise }, 'retrieveWSSURL') + .callsFake(() => urls.shift() ?? Promise.resolve('wss://fake')); + const holdNextURL = () => { + let release: () => void = () => {}; + urls.push(new Promise((resolve) => (release = () => resolve('wss://fake')))); + return () => release(); + }; + client.on('interactive', ({ ack }) => ack()); + return { client, error, holdNextURL }; + } + + async function connect(client: SocketModeClient) { + const started = client.start(); + await sleep(0); + sockets[sockets.length - 1].open().hello(); + await started; + return sockets[sockets.length - 1]; + } + + it('should open the replacement right away and close the old connection only once it says hello', async () => { + const { client, error } = createClient(); + const old = await connect(client); + const disconnected = sandbox.spy(); + client.on('disconnected', disconnected); + + old.receive(refreshRequested); + await sleep(0); + + assert.strictEqual(sockets.length, 2, 'the replacement should open without waiting for a backoff'); + assert.strictEqual(old.disconnectCalls, 0); + + sockets[1].open().hello(); + await sleep(10); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(client.websocket, sockets[1]); + assert.strictEqual(sockets.length, 2, "the old connection's close must not trigger another reconnect"); + sinon.assert.notCalled(disconnected); + sinon.assert.notCalled(error); + }); + + it('should keep acknowledging on the old connection until the replacement says hello', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive(refreshRequested); + await sleep(0); + old.receive(envelope); + sockets[1].open(); + old.receive(envelope); + await sleep(0); + + assert.strictEqual(old.sent.length, 2); + assert.strictEqual(sockets[1].sent.length, 0); + + sockets[1].hello(); + sockets[1].receive(envelope); + await sleep(0); + + assert.strictEqual(sockets[1].sent.length, 1); + }); + + it('should back off and keep the old connection serving when the replacement fails before hello', async () => { + const { client } = createClient({ clientPingTimeout: 1 }); + const old = await connect(client); + + old.receive(refreshRequested); + await sleep(0); + sockets[1].drop(); + await sleep(10); + + assert.strictEqual(sockets.length, 3, 'the failed replacement should be retried with the usual backoff'); + assert.strictEqual(old.disconnectCalls, 0); + old.receive(envelope); + await sleep(0); + assert.strictEqual(old.sent.length, 1); + + sockets[2].open().hello(); + await sleep(0); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(client.websocket, sockets[2]); + }); + + it('should end with a single connection when the old one closes while the replacement URL is fetched', async () => { + const { client, holdNextURL } = createClient({ clientPingTimeout: 1 }); + const old = await connect(client); + const releaseURL = holdNextURL(); + + old.receive(refreshRequested); + await sleep(0); + old.drop(); + await sleep(10); + releaseURL(); + await sleep(0); + sockets[sockets.length - 1].open().hello(); + await sleep(10); + + assert.deepStrictEqual( + sockets.map((socket) => socket.isActive()), + [false, false, true], + ); + assert.strictEqual(client.websocket, sockets[2]); + }); + + it('should converge on one connection when disconnect messages arrive back to back', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive({ type: 'disconnect', reason: 'warning' }); + old.receive(refreshRequested); + await sleep(0); + sockets[sockets.length - 1].open().hello(); + await sleep(10); + + assert.strictEqual(sockets.filter((socket) => socket.isActive()).length, 1); + assert.strictEqual(client.websocket?.isActive(), true); + assert.strictEqual(old.disconnectCalls, 1); + }); + + it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => { + const { client, error } = createClient(); + const old = await connect(client); + const disconnected = sandbox.spy(); + client.on('disconnected', disconnected); + + old.receive(refreshRequested); + await sleep(0); + await client.disconnect(); + await sleep(10); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(sockets[1].disconnectCalls, 1); + sinon.assert.calledOnce(disconnected); + sinon.assert.notCalled(error); + }); + + it('should close without reconnecting when auto-reconnect is disabled', async () => { + const { client } = createClient({ autoReconnectEnabled: false }); + const old = await connect(client); + const disconnected = sandbox.spy(); + client.on('disconnected', disconnected); + + old.receive(refreshRequested); + await sleep(10); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(sockets.length, 1); + sinon.assert.calledOnce(disconnected); + }); + }); }); async function sleep(ms: number): Promise { diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index e0daa287c..8b05ad74c 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -76,6 +76,12 @@ export class SocketModeClient extends EventEmitter { public websocket?: SlackWebSocket; + /** + * The previous connection, kept open while its replacement connects so no payload is missed. + * It is closed as soon as the replacement receives `hello`. + */ + private retiringWebsocket?: SlackWebSocket; + /** * Enables ping-pong detailed logging if true */ @@ -160,8 +166,10 @@ export class SocketModeClient extends EventEmitter { this.on('error', (err) => { this.logger.error(`WebSocket error! ${err}`); }); - this.on('close', () => { + this.on('close', (socket?: SlackWebSocket) => { // Underlying WebSocket connection was closed, possibly reconnect. + // A connection that was already replaced has no say in reconnecting. + if (socket && socket !== this.websocket) return; if (this.websocket?.isActive()) return; if (this.reconnectionTimer) return; if (!this.shuttingDown && this.autoReconnectEnabled) { @@ -194,6 +202,7 @@ export class SocketModeClient extends EventEmitter { if (this.shuttingDown) { throw new SMDisconnectedWhileConnectingError(); } + const previous = this.websocket; // create a socket connection using SlackWebSocket this.websocket = new SlackWebSocket({ url, @@ -207,6 +216,7 @@ export class SocketModeClient extends EventEmitter { serverPingTimeoutMS: this.serverPingTimeoutMS, pingPongLoggingEnabled: this.pingPongLoggingEnabled, }); + this.retire(previous); // Return a promise that resolves with the connection information return new Promise((resolve, reject) => { @@ -237,6 +247,8 @@ export class SocketModeClient extends EventEmitter { this.shuttingDown = true; clearTimeout(this.reconnectionTimer); this.reconnectionTimer = undefined; + this.retiringWebsocket?.disconnect(); + this.retiringWebsocket = undefined; this.logger.debug('Manually disconnecting this Socket Mode client'); this.emit(State.Disconnecting); return new Promise((resolve, _reject) => { @@ -253,6 +265,19 @@ export class SocketModeClient extends EventEmitter { // PRIVATE/PROTECTED METHODS + /** + * Handles a connection that was just replaced by a new one. An open connection keeps serving until + * the new one receives `hello`; anything else is closed right away. + */ + private retire(previous: SlackWebSocket | undefined): void { + if (previous?.isActive()) { + this.retiringWebsocket?.disconnect(); + this.retiringWebsocket = previous; + } else if (previous?.readyState !== undefined) { + previous.disconnect(); + } + } + /** * Initiates a reconnect, taking into account configurable delays and number of reconnect attempts and failures. * Accepts a callback to invoke after any calculated delays. @@ -352,16 +377,27 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { + this.retiringWebsocket?.disconnect(); + this.retiringWebsocket = undefined; this.emit(State.Connected, this.connectionResponse); return; } // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { - this.logger.debug( - `Received "${event.type}" (${event.reason}) message - disconnecting.${this.autoReconnectEnabled ? ' Will reconnect.' : ''}`, - ); - this.websocket?.disconnect(); + if (this.autoReconnectEnabled && !this.shuttingDown) { + // Open the replacement first; the current connection keeps serving until the replacement says hello. + this.logger.debug(`Received "${event.type}" (${event.reason}) message - opening a replacement connection.`); + this.emit(State.Reconnecting); + this.start().catch((error) => { + if (!this.shuttingDown) { + this.logger.error(`Failed to open a replacement Socket Mode connection (error: ${error})`); + } + }); + } else { + this.logger.debug(`Received "${event.type}" (${event.reason}) message - disconnecting.`); + this.websocket?.disconnect(); + } return; } @@ -427,14 +463,15 @@ export class SocketModeClient extends EventEmitter { const message = { envelope_id: id, payload: { ..._body } }; return new Promise((resolve, reject) => { - const wsState = this.websocket?.readyState; + const websocket = this.retiringWebsocket?.isActive() ? this.retiringWebsocket : this.websocket; + const wsState = websocket?.readyState; this.logger.debug( `send() method was called (WebSocket state: ${wsState ? WS_READY_STATES[wsState] : 'uninitialized'})`, ); - if (this.websocket === undefined) { + if (websocket === undefined) { this.logger.error('Failed to send a message as the client is not connected'); reject(new SMSendWhileDisconnectedError()); - } else if (!this.websocket.isActive()) { + } else if (!websocket.isActive()) { this.logger.error('Failed to send a message as the client has no active connection'); reject(new SMSendWhileNotReadyError()); } else { @@ -442,7 +479,7 @@ export class SocketModeClient extends EventEmitter { const flatMessage = JSON.stringify(message); this.logger.debug(`Sending a WebSocket message: ${flatMessage}`); - this.websocket.send(flatMessage, (error) => { + websocket.send(flatMessage, (error) => { if (error) { this.logger.error(`Failed to send a WebSocket message (error: ${error})`); return reject(new SMWebsocketError(error)); From 5d010e67b3c048ae6acfaea8ce2fbbdc38985724 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 13:04:25 -0500 Subject: [PATCH 02/22] fix(socket-mode): skip the reconnect backoff only for Slack's refresh requests Opening a replacement right away is meant for `warning` and `refresh_requested`. Taking that path for every disconnect reason left no throttle when a server keeps sending `disconnect` (for example `link_disabled`), so other reasons close first and reconnect through the backoff again, as before this branch. Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- packages/socket-mode/src/SocketModeClient.test.ts | 12 ++++++++++++ packages/socket-mode/src/SocketModeClient.ts | 10 ++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 7dc9bcbea..d7cf97508 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `warning` or `refresh_requested` `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over (emitting `disconnected` with the error if that fails too). Other reasons, such as `link_disabled`, still close the connection first and reconnect with the backoff. diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 920242ee4..8b38d614a 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -889,6 +889,18 @@ describe('SocketModeClient', () => { assert.strictEqual(sockets.length, 1); sinon.assert.calledOnce(disconnected); }); + + it('should close first and reconnect through the backoff on a disconnect that is not a refresh', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive({ type: 'disconnect', reason: 'link_disabled' }); + await sleep(10); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(sockets.length, 1, 'the reconnect should wait for the backoff'); + await client.disconnect(); + }); }); }); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index a74f4e664..bc3b79208 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -389,7 +389,7 @@ export class SocketModeClient extends EventEmitter { // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { - if (this.autoReconnectEnabled && !this.shuttingDown) { + if (this.autoReconnectEnabled && !this.shuttingDown && isRefresh(event.reason)) { // Open the replacement first; the current connection keeps serving until the replacement says hello. this.logger.debug(`Received "${event.type}" (${event.reason}) message - opening a replacement connection.`); this.emit(State.Reconnecting); @@ -399,7 +399,9 @@ export class SocketModeClient extends EventEmitter { } }); } else { - this.logger.debug(`Received "${event.type}" (${event.reason}) message - disconnecting.`); + this.logger.debug( + `Received "${event.type}" (${event.reason}) message - disconnecting.${this.autoReconnectEnabled ? ' Will reconnect.' : ''}`, + ); this.websocket?.disconnect(); } return; @@ -495,6 +497,10 @@ export class SocketModeClient extends EventEmitter { } } +function isRefresh(reason: string): boolean { + return reason === 'warning' || reason === 'refresh_requested'; +} + /* Instrumentation */ addAppMetadata({ name: packageJson.name, version: packageJson.version }); From fe6d9936d581b4ee10caacf26a201fff9906c5b8 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 13:04:54 -0500 Subject: [PATCH 03/22] refactor(socket-mode): name the replacement and retiring-socket steps Move the refresh branch into openReplacement() and the repeated retiring-socket cleanup into closeRetiringWebsocket(), so onWebSocketMessage stays a flat dispatcher. No behavior change. Co-Authored-By: Claude --- packages/socket-mode/src/SocketModeClient.ts | 32 ++++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index bc3b79208..b735ae7dc 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -251,8 +251,7 @@ export class SocketModeClient extends EventEmitter { this.shuttingDown = true; this.reconnectDelay?.abort(); this.reconnectDelay = undefined; - this.retiringWebsocket?.disconnect(); - this.retiringWebsocket = undefined; + this.closeRetiringWebsocket(); this.logger.debug('Manually disconnecting this Socket Mode client'); this.emit(State.Disconnecting); return new Promise((resolve, _reject) => { @@ -275,13 +274,28 @@ export class SocketModeClient extends EventEmitter { */ private retire(previous: SlackWebSocket | undefined): void { if (previous?.isActive()) { - this.retiringWebsocket?.disconnect(); + this.closeRetiringWebsocket(); this.retiringWebsocket = previous; } else if (previous?.readyState !== undefined) { previous.disconnect(); } } + private closeRetiringWebsocket(): void { + this.retiringWebsocket?.disconnect(); + this.retiringWebsocket = undefined; + } + + private openReplacement(reason: string): void { + this.logger.debug(`Received "disconnect" (${reason}) message - opening a replacement connection.`); + this.emit(State.Reconnecting); + this.start().catch((error) => { + if (!this.shuttingDown) { + this.logger.error(`Failed to open a replacement Socket Mode connection (error: ${error})`); + } + }); + } + /** * Initiates a reconnect, taking into account configurable delays and number of reconnect attempts and failures. * Accepts a callback to invoke after any calculated delays. @@ -381,8 +395,7 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { - this.retiringWebsocket?.disconnect(); - this.retiringWebsocket = undefined; + this.closeRetiringWebsocket(); this.emit(State.Connected, this.connectionResponse); return; } @@ -390,14 +403,7 @@ export class SocketModeClient extends EventEmitter { // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { if (this.autoReconnectEnabled && !this.shuttingDown && isRefresh(event.reason)) { - // Open the replacement first; the current connection keeps serving until the replacement says hello. - this.logger.debug(`Received "${event.type}" (${event.reason}) message - opening a replacement connection.`); - this.emit(State.Reconnecting); - this.start().catch((error) => { - if (!this.shuttingDown) { - this.logger.error(`Failed to open a replacement Socket Mode connection (error: ${error})`); - } - }); + this.openReplacement(event.reason); } else { this.logger.debug( `Received "${event.type}" (${event.reason}) message - disconnecting.${this.autoReconnectEnabled ? ' Will reconnect.' : ''}`, From ae4cff41c9e8fb1a6ca08f60b030b1ce64e5fdb1 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 13:30:53 -0500 Subject: [PATCH 04/22] refactor(socket-mode): drop the retire() docstring Co-Authored-By: Claude --- packages/socket-mode/src/SocketModeClient.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index b735ae7dc..011b11e04 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -268,10 +268,6 @@ export class SocketModeClient extends EventEmitter { // PRIVATE/PROTECTED METHODS - /** - * Handles a connection that was just replaced by a new one. An open connection keeps serving until - * the new one receives `hello`; anything else is closed right away. - */ private retire(previous: SlackWebSocket | undefined): void { if (previous?.isActive()) { this.closeRetiringWebsocket(); From 63544b1202409800c4b1abe0f8655598b11140e2 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 13:33:27 -0500 Subject: [PATCH 05/22] refactor(socket-mode): shorten the retiringWebsocket docstring Co-Authored-By: Claude --- packages/socket-mode/src/SocketModeClient.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 011b11e04..78ee46843 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -77,10 +77,7 @@ export class SocketModeClient extends EventEmitter { public websocket?: SlackWebSocket; - /** - * The previous connection, kept open while its replacement connects so no payload is missed. - * It is closed as soon as the replacement receives `hello`. - */ + /** The previous connection, kept serving until its replacement receives `hello`. */ private retiringWebsocket?: SlackWebSocket; /** From 5017bc2e0bc140f79584dedf9d85786377219bf9 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 13:36:40 -0500 Subject: [PATCH 06/22] refactor(socket-mode): await the replacement start() in a try/catch Co-Authored-By: Claude --- packages/socket-mode/src/SocketModeClient.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 78ee46843..2daf958f8 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -279,14 +279,16 @@ export class SocketModeClient extends EventEmitter { this.retiringWebsocket = undefined; } - private openReplacement(reason: string): void { + private async openReplacement(reason: string): Promise { this.logger.debug(`Received "disconnect" (${reason}) message - opening a replacement connection.`); this.emit(State.Reconnecting); - this.start().catch((error) => { + try { + await this.start(); + } catch (error) { if (!this.shuttingDown) { this.logger.error(`Failed to open a replacement Socket Mode connection (error: ${error})`); } - }); + } } /** @@ -396,7 +398,7 @@ export class SocketModeClient extends EventEmitter { // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { if (this.autoReconnectEnabled && !this.shuttingDown && isRefresh(event.reason)) { - this.openReplacement(event.reason); + await this.openReplacement(event.reason); } else { this.logger.debug( `Received "${event.type}" (${event.reason}) message - disconnecting.${this.autoReconnectEnabled ? ' Will reconnect.' : ''}`, From e493bcf8aeacaf8425815ea35b57f9be735f3fad Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 13:44:02 -0500 Subject: [PATCH 07/22] fix(socket-mode): open a replacement on every disconnect reason python-slack-sdk and java-slack-sdk reconnect on any `disconnect` message without reading its reason. A list of refresh reasons gets any reason Slack adds later wrong, and when Slack refuses a new URL (for example after `link_disabled`), retrieveWSSURL already backs off, so the reason check bought no throttling. Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- packages/socket-mode/src/SocketModeClient.test.ts | 8 ++++---- packages/socket-mode/src/SocketModeClient.ts | 10 ++-------- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index d7cf97508..41279e57a 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `warning` or `refresh_requested` `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over (emitting `disconnected` with the error if that fails too). Other reasons, such as `link_disabled`, still close the connection first and reconnect with the backoff. +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over (emitting `disconnected` with the error if that fails too). diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 8b38d614a..d71460f99 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -890,15 +890,15 @@ describe('SocketModeClient', () => { sinon.assert.calledOnce(disconnected); }); - it('should close first and reconnect through the backoff on a disconnect that is not a refresh', async () => { + it('should open a replacement for any disconnect reason, like the other SDKs', async () => { const { client } = createClient(); const old = await connect(client); old.receive({ type: 'disconnect', reason: 'link_disabled' }); - await sleep(10); + await sleep(0); - assert.strictEqual(old.disconnectCalls, 1); - assert.strictEqual(sockets.length, 1, 'the reconnect should wait for the backoff'); + assert.strictEqual(sockets.length, 2); + assert.strictEqual(old.disconnectCalls, 0); await client.disconnect(); }); }); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 2daf958f8..131c725c8 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -397,12 +397,10 @@ export class SocketModeClient extends EventEmitter { // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { - if (this.autoReconnectEnabled && !this.shuttingDown && isRefresh(event.reason)) { + if (this.autoReconnectEnabled && !this.shuttingDown) { await this.openReplacement(event.reason); } else { - this.logger.debug( - `Received "${event.type}" (${event.reason}) message - disconnecting.${this.autoReconnectEnabled ? ' Will reconnect.' : ''}`, - ); + this.logger.debug(`Received "${event.type}" (${event.reason}) message - disconnecting.`); this.websocket?.disconnect(); } return; @@ -498,10 +496,6 @@ export class SocketModeClient extends EventEmitter { } } -function isRefresh(reason: string): boolean { - return reason === 'warning' || reason === 'refresh_requested'; -} - /* Instrumentation */ addAppMetadata({ name: packageJson.name, version: packageJson.version }); From e542c963dd2b6a3d39a5db5c31c2cbc7643b57be Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 15:02:12 -0500 Subject: [PATCH 08/22] refactor(socket-mode): drop the close guard the identity check made dead A SlackWebSocket nulls its inner socket before emitting close, so the current connection is never active when its own close arrives, and the identity check already ignores every other connection. The changeset no longer promises a `disconnected` error now that #2820 keeps a failed reconnect loud. Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- packages/socket-mode/src/SocketModeClient.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 41279e57a..954a10621 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over (emitting `disconnected` with the error if that fails too). +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over. diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index dc35c68dc..1121d54c9 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -168,7 +168,6 @@ export class SocketModeClient extends EventEmitter { // Underlying WebSocket connection was closed, possibly reconnect. // A connection that was already replaced has no say in reconnecting. if (socket && socket !== this.websocket) return; - if (this.websocket?.isActive()) return; if (this.reconnectDelay) return; if (!this.shuttingDown && this.autoReconnectEnabled) { try { From 2adb58edea8394a002e18358952d7ff3629d17e0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Fri, 6 Nov 2026 17:44:45 -0500 Subject: [PATCH 09/22] fix(socket-mode): close instead of overlapping on link_disabled link_disabled means Socket Mode is off, so a replacement can't connect. Overlapping kept the dead connection open until the pong timeout, which delayed the failure. Close it first, as before, and let the reconnect surface the error. Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- packages/socket-mode/src/SocketModeClient.test.ts | 6 +++--- packages/socket-mode/src/SocketModeClient.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 954a10621..5d9f43b11 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over. +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 32a48e10b..d2c41185e 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -874,15 +874,15 @@ describe('SocketModeClient', () => { sinon.assert.calledOnce(disconnected); }); - it('should open a replacement for any disconnect reason, like the other SDKs', async () => { + it('should close the connection instead of opening a replacement when the link is disabled', async () => { const { client } = createClient(); const old = await connect(client); old.receive({ type: 'disconnect', reason: 'link_disabled' }); await sleep(0); - assert.strictEqual(sockets.length, 2); - assert.strictEqual(old.disconnectCalls, 0); + assert.strictEqual(sockets.length, 1); + assert.strictEqual(old.disconnectCalls, 1); await client.disconnect(); }); }); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 1121d54c9..b69097e6a 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -395,7 +395,7 @@ export class SocketModeClient extends EventEmitter { // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { - if (this.autoReconnectEnabled && !this.shuttingDown) { + if (this.autoReconnectEnabled && !this.shuttingDown && event.reason !== 'link_disabled') { await this.openReplacement(event.reason); } else { this.logger.debug(`Received "${event.type}" (${event.reason}) message - disconnecting.`); From 791caeb576baa6f46e9378b16a659ca3d73915f5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 09:42:07 -0500 Subject: [PATCH 10/22] fix(socket-mode): only act on hello from the current connection A second disconnect message that arrived after the first replacement opened but before its hello retired that replacement, so its own hello then closed it, leaving no connection until the second replacement said hello. ws_message now carries the receiving SlackWebSocket, like close, and a hello from a replaced connection is ignored. Also rewords the changeset: the backoff starts when the replacement fails, not when the old connection closes. Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- .../socket-mode/src/SlackWebSocket.test.ts | 30 +++++++++++++++++++ packages/socket-mode/src/SlackWebSocket.ts | 2 +- .../socket-mode/src/SocketModeClient.test.ts | 26 +++++++++++++++- packages/socket-mode/src/SocketModeClient.ts | 7 ++++- 5 files changed, 63 insertions(+), 4 deletions(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 5d9f43b11..56e58e2d2 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving until it closes, then the usual reconnect takes over. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving while the usual reconnect retries. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. diff --git a/packages/socket-mode/src/SlackWebSocket.test.ts b/packages/socket-mode/src/SlackWebSocket.test.ts index fb620fd62..ceae6a8c9 100644 --- a/packages/socket-mode/src/SlackWebSocket.test.ts +++ b/packages/socket-mode/src/SlackWebSocket.test.ts @@ -93,6 +93,36 @@ describe('SlackWebSocket', () => { ws.dispatchEvent(new ErrorEvent('error', { error: new Error('boom'), message: 'boom' })); sinon.assert.calledOnce(discStub); }); + + it('should emit "ws_message" with the receiving SlackWebSocket, so the client can tell connections apart', () => { + const ws = new WSMock(); + SlackWebSocket = proxyquire.load('./SlackWebSocket', { + undici: { + WebSocket: class Fake { + constructor() { + // biome-ignore lint/correctness/noConstructorReturn: for test mocking purposes + return ws; + } + }, + CloseEvent, + ErrorEvent, + MessageEvent, + ping: () => {}, + }, + }).SlackWebSocket; + const client = new EventEmitter(); + const received = sandbox.spy(); + client.on('ws_message', received); + const sws = new SlackWebSocket({ + url: 'whatevs', + client, + clientPingTimeoutMS: 1, + serverPingTimeoutMS: 1, + }); + sws.connect(); + ws.dispatchEvent(new MessageEvent('message', { data: '{"type":"hello"}' })); + sinon.assert.calledOnceWithExactly(received, '{"type":"hello"}', false, sws); + }); }); describe('disconnect() with an unresponsive peer', () => { diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 727f86f01..851674497 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -179,7 +179,7 @@ export class SlackWebSocket { return; } const isBinary = typeof event.data !== 'string'; - this.options.client.emit('ws_message', event.data, isBinary); + this.options.client.emit('ws_message', event.data, isBinary, this); }; this.websocket.addEventListener('message', this.messageHandler); diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index d2c41185e..2c0138eba 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -689,7 +689,7 @@ describe('SocketModeClient', () => { return this; } receive(message: Record) { - this.client.emit('ws_message', JSON.stringify(message), false); + this.client.emit('ws_message', JSON.stringify(message), false, this); } hello() { this.receive({ type: 'hello' }); @@ -843,6 +843,30 @@ describe('SocketModeClient', () => { assert.strictEqual(old.disconnectCalls, 1); }); + it('should keep the first replacement serving when a second disconnect arrives before its hello', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive({ type: 'disconnect', reason: 'warning' }); + await sleep(0); + const first = sockets[1].open(); + old.receive(refreshRequested); + await sleep(0); + first.hello(); + first.receive(envelope); + await sleep(0); + + assert.strictEqual(first.disconnectCalls, 0, "a replaced connection's hello must not close it"); + assert.strictEqual(first.sent.length, 1); + + sockets[2].open().hello(); + await sleep(10); + + assert.strictEqual(first.disconnectCalls, 1); + assert.strictEqual(client.websocket, sockets[2]); + assert.strictEqual(sockets.filter((socket) => socket.isActive()).length, 1); + }); + it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => { const { client, error } = createClient(); const old = await connect(client); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index b69097e6a..ca41f803a 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -357,7 +357,11 @@ export class SocketModeClient extends EventEmitter { * - raising the State.Connected event (when Slack sends a type:hello message) * - disconnecting the underlying socket (when Slack sends a type:disconnect message) */ - protected async onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise { + protected async onWebSocketMessage( + data: string | ArrayBuffer, + isBinary: boolean, + socket?: SlackWebSocket, + ): Promise { if (isBinary) { this.logger.debug('Unexpected binary message received, ignoring.'); return; @@ -388,6 +392,7 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { + if (socket && socket !== this.websocket) return; this.closeRetiringWebsocket(); this.emit(State.Connected, this.connectionResponse); return; From ea92e5c71441487191640919acc7aa33ea060c1b Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 11:01:12 -0500 Subject: [PATCH 11/22] fix(socket-mode): skip a replacement while one is in flight, or from a retired connection A disconnect message from a connection that was already replaced is ignored, and a replacement isn't opened while another connect is waiting or fetching its URL. Back-to-back warning and refresh_requested, or a late refresh_requested on the retiring connection, now open one replacement instead of two. Co-Authored-By: Claude --- .../socket-mode/src/SocketModeClient.test.ts | 33 ++++++++++--------- packages/socket-mode/src/SocketModeClient.ts | 2 ++ 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 0eaed784a..8eac203ad 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -895,43 +895,44 @@ describe('SocketModeClient', () => { sinon.assert.calledOnce(events.reconnecting); }); - it('should converge on one connection when disconnect messages arrive back to back', async () => { + it('should open one replacement when disconnect messages arrive back to back', async () => { const { client } = createClient(); const old = await connect(client); + const reconnecting = sandbox.spy(); + client.on('reconnecting', reconnecting); old.receive({ type: 'disconnect', reason: 'warning' }); old.receive(refreshRequested); await sleep(0); - sockets[sockets.length - 1].open().hello(); + sockets[1].open().hello(); await sleep(10); - assert.strictEqual(sockets.filter((socket) => socket.isActive()).length, 1); - assert.strictEqual(client.websocket?.isActive(), true); + assert.strictEqual(sockets.length, 2); + assert.strictEqual(client.websocket, sockets[1]); assert.strictEqual(old.disconnectCalls, 1); + sinon.assert.calledOnce(reconnecting); }); - it('should keep the first replacement serving when a second disconnect arrives before its hello', async () => { + it('should ignore a disconnect from the retiring connection while the replacement handshakes', async () => { const { client } = createClient(); const old = await connect(client); old.receive({ type: 'disconnect', reason: 'warning' }); await sleep(0); - const first = sockets[1].open(); + const replacement = sockets[1].open(); old.receive(refreshRequested); await sleep(0); - first.hello(); - first.receive(envelope); - await sleep(0); - assert.strictEqual(first.disconnectCalls, 0, "a replaced connection's hello must not close it"); - assert.strictEqual(first.sent.length, 1); + assert.strictEqual(sockets.length, 2); - sockets[2].open().hello(); - await sleep(10); + replacement.hello(); + replacement.receive(envelope); + await sleep(0); - assert.strictEqual(first.disconnectCalls, 1); - assert.strictEqual(client.websocket, sockets[2]); - assert.strictEqual(sockets.filter((socket) => socket.isActive()).length, 1); + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(replacement.disconnectCalls, 0); + assert.strictEqual(replacement.sent.length, 1); + assert.strictEqual(client.websocket, replacement); }); it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => { diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index 3d1384799..eb527d79e 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -279,6 +279,7 @@ export class SocketModeClient extends EventEmitter { } private async openReplacement(reason: string): Promise { + if (this.connectsInFlight > 0) return; this.logger.debug(`Received "disconnect" (${reason}) message - opening a replacement connection.`); this.emit(State.Reconnecting); try { @@ -408,6 +409,7 @@ export class SocketModeClient extends EventEmitter { // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { + if (socket && socket !== this.websocket) return; if (this.autoReconnectEnabled && !this.shuttingDown && event.reason !== 'link_disabled') { await this.openReplacement(event.reason); } else { From dca9696e0bbd261f29370cd9268ba8af716b65d5 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 11:01:56 -0500 Subject: [PATCH 12/22] fix(socket-mode): keep a failed replacement as loud as a failed reconnect A replacement whose URL fetch fails for good now rejects with its cause instead of logging it. On main a refresh closes the connection and then reconnects, and the same failure rejects there, so apps keep crashing loud on, say, invalid_auth rather than idling once the old connection goes. Co-Authored-By: Claude --- packages/socket-mode/src/SocketModeClient.test.ts | 10 ++++++++++ packages/socket-mode/src/SocketModeClient.ts | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 8eac203ad..68d549ce9 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -935,6 +935,16 @@ describe('SocketModeClient', () => { assert.strictEqual(client.websocket, replacement); }); + it('should reject with the cause when the replacement fails for good', async () => { + const { client, failNextURL } = createClient(); + const old = await connect(client); + const cause = new Error('invalid_auth'); + failNextURL(cause); + const [onMessage] = client.listeners('ws_message'); + + await assert.rejects(async () => onMessage(JSON.stringify(refreshRequested), false, old), cause); + }); + it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => { const { client, error } = createClient(); const old = await connect(client); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index eb527d79e..d6982cdba 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -285,8 +285,8 @@ export class SocketModeClient extends EventEmitter { try { await this.start(); } catch (error) { - if (!this.shuttingDown) { - this.logger.error(`Failed to open a replacement Socket Mode connection (error: ${error})`); + if (!(error instanceof SMConnectAbortedError)) { + throw error; } } } From 2e2669a17962705cd1a5fa3e4ab827439d43fff0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 11:03:38 -0500 Subject: [PATCH 13/22] test(socket-mode): pin overlapping refreshes to two connections Against the mock servers: the old connection closing during a slow replacement URL fetch, and a refresh_requested on the old connection before the replacement says hello, each open exactly one replacement. A unit test covers start() closing a replacement that never opened. Co-Authored-By: Claude --- .../socket-mode/src/SocketModeClient.test.ts | 16 ++++++ .../test/integrations/integration.test.js | 57 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 68d549ce9..8a8bb04b8 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -945,6 +945,22 @@ describe('SocketModeClient', () => { await assert.rejects(async () => onMessage(JSON.stringify(refreshRequested), false, old), cause); }); + it('should close a replacement that never opened when start() replaces it', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive(refreshRequested); + await sleep(0); + const restarted = client.start(); + await sleep(0); + sockets[2].open().hello(); + await restarted; + + assert.strictEqual(sockets[1].disconnectCalls, 1); + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(client.websocket, sockets[2]); + }); + it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => { const { client, error } = createClient(); const old = await connect(client); diff --git a/packages/socket-mode/test/integrations/integration.test.js b/packages/socket-mode/test/integrations/integration.test.js index e80f6cc6f..bfe03e475 100644 --- a/packages/socket-mode/test/integrations/integration.test.js +++ b/packages/socket-mode/test/integrations/integration.test.js @@ -119,6 +119,63 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => await client.disconnect(); }); }); + describe('overlapping refreshes (issue #2767)', () => { + beforeEach(() => { + client = new SocketModeClient({ + appToken: 'whatever', + logLevel: LogLevel.ERROR, + clientOptions: { + slackApiUrl: `http://localhost:${HTTP_PORT}/`, + }, + clientPingTimeout: 100, + }); + }); + it('opens only the replacement when the old connection closes while its URL is fetched', async () => { + await client.start(); + const old = exposed_ws_connection; + server.close(); + server = createServer((_req, res) => { + setTimeout(() => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, url: `ws://localhost:${WSS_PORT}/` })); + }, 300); + }); + server.listen(HTTP_PORT); + let connections = 0; + wss.on('connection', () => { + connections += 1; + }); + + old.send(JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' })); + await sleep(50); + old.terminate(); + await sleep(600); + + assert.strictEqual(connections, 1); + assert.strictEqual(wss.clients.size, 1); + await client.disconnect(); + }); + it('opens one replacement when refresh_requested arrives on the old connection before the new one says hello', async () => { + await client.start(); + const old = exposed_ws_connection; + let connections = 0; + wss.removeAllListeners('connection'); + wss.on('connection', (ws) => { + connections += 1; + exposed_ws_connection = ws; + setTimeout(() => ws.send(JSON.stringify({ type: 'hello' })), 300); + }); + + old.send(JSON.stringify({ type: 'disconnect', reason: 'warning' })); + await sleep(100); + old.send(JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' })); + await sleep(500); + + assert.strictEqual(connections, 1); + assert.strictEqual(wss.clients.size, 1); + await client.disconnect(); + }); + }); describe('`apps.connections.open` API failure modes', () => { beforeEach(() => { client = new SocketModeClient({ From c7e980d12f278c16c8aa460732ab15f09fa16d94 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 11:03:50 -0500 Subject: [PATCH 14/22] docs(socket-mode): describe overlapping refreshes in the changeset Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 56e58e2d2..5c823f382 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. If the replacement can't be opened, the current connection keeps serving while the usual reconnect retries. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. While the replacement is being opened, the current connection keeps serving, and overlapping `disconnect` messages open one replacement. A replacement that fails for good rejects with its cause, as a failed reconnect does. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. From c9232385dd61d8cecd8eaa42e8b867c8a56e5819 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 14:07:02 -0500 Subject: [PATCH 15/22] fix(socket-mode): keep the old connection open until Slack sends it disconnect Closing the old connection at the replacement's hello dropped payloads Slack still routed to it: undici discards frames once a socket is closing, and Slack keeps delivering on both connections for several seconds after the switch. A soak against real Slack saw 2 of about 270 events need a retry this way. The old connection now stays open until Slack sends it its own disconnect message (refresh_requested, about 60s after the warning), and each payload is acknowledged on the connection it arrived on. With this, a soak saw payloads arrive on the old connection after the switch, all acknowledged, 0 retries. Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 +- packages/socket-mode/README.md | 2 +- .../socket-mode/src/SocketModeClient.test.ts | 89 +++++++++++++++++-- packages/socket-mode/src/SocketModeClient.ts | 15 ++-- .../test/integrations/integration.test.js | 22 +++++ 5 files changed, 114 insertions(+), 16 deletions(-) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 5c823f382..8f8a15054 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and keeps the current one acknowledging payloads until the replacement receives `hello`. Only then is the old connection closed. While the replacement is being opened, the current connection keeps serving, and overlapping `disconnect` messages open one replacement. A replacement that fails for good rejects with its cause, as a failed reconnect does. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. +Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and leaves the old connection open until Slack sends it a `disconnect` message too, so payloads Slack still sends on it during the switch are received instead of dropped. Each payload is acknowledged on the connection it arrived on. While the replacement is being opened, the current connection keeps serving, and overlapping `disconnect` messages open one replacement. A replacement that fails for good rejects with its cause, as a failed reconnect does. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index 3cc4aa70b..70d3a8105 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -132,7 +132,7 @@ In the table below, the client's states are listed, which are also the names of | `connecting` | | The client is in the process of connecting to the platform. | | `connected` | | The client is connected to the platform and incoming events will start being emitted. | | `disconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `disconnected`. | -| `reconnecting` | | The client is replacing its connection. On a Slack refresh, the current connection keeps serving until the new one is `connected`. It will soon transition to `connecting`. | +| `reconnecting` | | The client is replacing its connection. On a Slack refresh, the current connection keeps serving after the new one is `connected`, until Slack sends it `disconnect`. It will soon transition to `connecting`. | | `disconnected` | `(error)` | The client is not connected to the platform. This is a steady state - no attempt to connect is occurring. The `error` argument will be `undefined` when the client initiated the disconnect (normal). | The client also emits events that are part of its lifecycle, but aren't states. Instead, they represent specific moments that might be helpful to your app. The following table lists these events, their description, and includes the arguments that a listener would receive. diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index 8a8bb04b8..1ae2135c5 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -312,7 +312,7 @@ describe('SocketModeClient', () => { assert.strictEqual(args.type, 'events_api'); assert.deepEqual(args.body, body); await args.ack({}); - sinon.assert.calledOnceWithExactly(send, 'malformed', {}); + sinon.assert.calledOnceWith(send, 'malformed', {}); } }); @@ -805,7 +805,7 @@ describe('SocketModeClient', () => { const refreshRequested = { type: 'disconnect', reason: 'refresh_requested' }; const envelope = { type: 'interactive', envelope_id: 'e-1', payload: {} }; - it('should open the replacement right away and close the old connection only once it says hello', async () => { + it('should open the replacement right away and leave the old connection open after it says hello', async () => { const { client, error } = createClient(); const old = await connect(client); const disconnected = sandbox.spy(); @@ -820,14 +820,16 @@ describe('SocketModeClient', () => { sockets[1].open().hello(); await sleep(10); - assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(old.disconnectCalls, 0, 'Slack may still deliver on the old connection'); assert.strictEqual(client.websocket, sockets[1]); + old.drop(); + await sleep(10); assert.strictEqual(sockets.length, 2, "the old connection's close must not trigger another reconnect"); sinon.assert.notCalled(disconnected); sinon.assert.notCalled(error); }); - it('should keep acknowledging on the old connection until the replacement says hello', async () => { + it('should acknowledge each envelope on the connection it arrived on', async () => { const { client } = createClient(); const old = await connect(client); @@ -843,9 +845,64 @@ describe('SocketModeClient', () => { sockets[1].hello(); sockets[1].receive(envelope); + old.receive(envelope); await sleep(0); assert.strictEqual(sockets[1].sent.length, 1); + assert.strictEqual(old.sent.length, 3); + }); + + it('should acknowledge on the current connection when the arrival connection has closed', async () => { + const { client } = createClient(); + const old = await connect(client); + const acks: Array<() => Promise> = []; + client.on('slash_commands', ({ ack }) => acks.push(ack)); + + old.receive(refreshRequested); + await sleep(0); + sockets[1].open().hello(); + old.receive({ type: 'slash_commands', envelope_id: 'e-2', payload: {} }); + old.drop(); + await acks[0](); + + assert.strictEqual(old.sent.length, 0); + assert.strictEqual(sockets[1].sent.length, 1); + }); + + it('should close the old connection at the next refresh or on disconnect()', async () => { + const { client } = createClient(); + const old = await connect(client); + const disconnected = sandbox.spy(); + client.on('disconnected', disconnected); + + old.receive(refreshRequested); + await sleep(0); + sockets[1].open().hello(); + assert.strictEqual(old.disconnectCalls, 0); + sockets[1].receive(refreshRequested); + await sleep(0); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(sockets[1].disconnectCalls, 0); + + await client.disconnect(); + + assert.strictEqual(sockets[1].disconnectCalls, 1); + assert.strictEqual(sockets[2].disconnectCalls, 1); + sinon.assert.calledOnce(disconnected); + }); + + it('should not close an old connection again after Slack closed it', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive(refreshRequested); + await sleep(0); + sockets[1].open().hello(); + old.drop(); + await client.disconnect(); + + assert.strictEqual(old.disconnectCalls, 0); }); it('should back off and keep the old connection serving when the replacement fails before hello', async () => { @@ -866,7 +923,7 @@ describe('SocketModeClient', () => { sockets[2].open().hello(); await sleep(0); - assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(old.disconnectCalls, 0); assert.strictEqual(client.websocket, sockets[2]); }); @@ -909,11 +966,25 @@ describe('SocketModeClient', () => { assert.strictEqual(sockets.length, 2); assert.strictEqual(client.websocket, sockets[1]); - assert.strictEqual(old.disconnectCalls, 1); sinon.assert.calledOnce(reconnecting); }); - it('should ignore a disconnect from the retiring connection while the replacement handshakes', async () => { + it('should close the old connection, not open another, on its own disconnect message after hello', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive({ type: 'disconnect', reason: 'warning' }); + await sleep(0); + sockets[1].open().hello(); + old.receive(refreshRequested); + await sleep(0); + + assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(sockets.length, 2); + assert.strictEqual(client.websocket, sockets[1]); + }); + + it('should close the retiring connection, not open another, on its disconnect message while the replacement handshakes', async () => { const { client } = createClient(); const old = await connect(client); @@ -924,12 +995,12 @@ describe('SocketModeClient', () => { await sleep(0); assert.strictEqual(sockets.length, 2); + assert.strictEqual(old.disconnectCalls, 1); replacement.hello(); replacement.receive(envelope); await sleep(0); - assert.strictEqual(old.disconnectCalls, 1); assert.strictEqual(replacement.disconnectCalls, 0); assert.strictEqual(replacement.sent.length, 1); assert.strictEqual(client.websocket, replacement); @@ -957,7 +1028,7 @@ describe('SocketModeClient', () => { await restarted; assert.strictEqual(sockets[1].disconnectCalls, 1); - assert.strictEqual(old.disconnectCalls, 1); + assert.strictEqual(old.disconnectCalls, 0); assert.strictEqual(client.websocket, sockets[2]); }); diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index e37ea275f..72844a2aa 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -77,7 +77,7 @@ export class SocketModeClient extends EventEmitter { public websocket?: SlackWebSocket; - /** The previous connection, kept serving until its replacement receives `hello`. */ + /** The previous connection, left open after its replacement takes over until Slack sends it `disconnect`. */ private retiringWebsocket?: SlackWebSocket; /** @@ -167,6 +167,7 @@ export class SocketModeClient extends EventEmitter { }); this.on('close', async (socket: SlackWebSocket) => { // Underlying WebSocket connection was closed, possibly reconnect. + if (socket === this.retiringWebsocket) this.retiringWebsocket = undefined; if (socket !== this.websocket) return; if (this.shuttingDown || !this.autoReconnectEnabled) { // If reconnect is disabled or user explicitly called `disconnect()`, emit a disconnected state. @@ -408,13 +409,16 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { if (socket && socket !== this.websocket) return; - this.closeRetiringWebsocket(); this.emit(State.Connected, this.connectionResponse); return; } // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { + if (socket && socket === this.retiringWebsocket) { + this.closeRetiringWebsocket(); + return; + } if (socket && socket !== this.websocket) return; if (this.autoReconnectEnabled && !this.shuttingDown && event.reason !== 'link_disabled') { await this.openReplacement(event.reason); @@ -432,7 +436,7 @@ export class SocketModeClient extends EventEmitter { `Calling ack() - type: ${event.type}, envelope_id: ${event.envelope_id}, data: ${JSON.stringify(response)}`, ); } - await this.send(event.envelope_id, response); + await this.send(event.envelope_id, response, socket); }; // For events_api messages, expose the type of the event @@ -481,13 +485,14 @@ export class SocketModeClient extends EventEmitter { * Primarily used to send acknowledgements back to slack for incoming events * @param id the envelope id * @param body the message body or string text + * @param socket the connection the envelope arrived on, used while it is still open */ - private send(id: string, body = {}): Promise { + private send(id: string, body = {}, socket?: SlackWebSocket): Promise { const _body = typeof body === 'string' ? { text: body } : body; const message = { envelope_id: id, payload: { ..._body } }; return new Promise((resolve, reject) => { - const websocket = this.retiringWebsocket?.isActive() ? this.retiringWebsocket : this.websocket; + const websocket = socket?.isActive() ? socket : this.websocket; const wsState = websocket?.readyState; this.logger.debug( `send() method was called (WebSocket state: ${wsState ? WS_READY_STATES[wsState] : 'uninitialized'})`, diff --git a/packages/socket-mode/test/integrations/integration.test.js b/packages/socket-mode/test/integrations/integration.test.js index bfe03e475..099f5387c 100644 --- a/packages/socket-mode/test/integrations/integration.test.js +++ b/packages/socket-mode/test/integrations/integration.test.js @@ -175,6 +175,28 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => assert.strictEqual(wss.clients.size, 1); await client.disconnect(); }); + it('receives and acknowledges an envelope sent on the old connection after the new one says hello', async () => { + await client.start(); + const old = exposed_ws_connection; + const acks = []; + old.on('message', (data) => acks.push(JSON.parse(data).envelope_id)); + const connected = new Promise((resolve) => client.once('connected', resolve)); + const received = new Promise((resolve) => + client.once('interactive', async ({ ack, envelope_id }) => { + await ack(); + resolve(envelope_id); + }), + ); + + old.send(JSON.stringify({ type: 'disconnect', reason: 'warning' })); + await connected; + old.send(JSON.stringify({ type: 'interactive', envelope_id: 'e-old', payload: {} })); + + assert.strictEqual(await Promise.race([received, sleep(500)]), 'e-old'); + await sleep(50); + assert.deepStrictEqual(acks, ['e-old']); + await client.disconnect(); + }); }); describe('`apps.connections.open` API failure modes', () => { beforeEach(() => { From 0f05a1e0b86a95eee7dc25d260ead27e28f6ab4a Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 14:21:18 -0500 Subject: [PATCH 16/22] style(socket-mode): realign the README lifecycle table Co-Authored-By: Claude --- packages/socket-mode/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index 70d3a8105..42bc71073 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -127,13 +127,13 @@ The client's connection to Slack has a lifecycle. This means the client can be s In the table below, the client's states are listed, which are also the names of the events you can use to observe the transition to that state. The table also includes descriptions for the states and arguments that a listener would receive. -| Event Name | Arguments | Description | -| --------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `connecting` | | The client is in the process of connecting to the platform. | -| `connected` | | The client is connected to the platform and incoming events will start being emitted. | -| `disconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `disconnected`. | +| Event Name | Arguments | Description | +| --------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `connecting` | | The client is in the process of connecting to the platform. | +| `connected` | | The client is connected to the platform and incoming events will start being emitted. | +| `disconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `disconnected`. | | `reconnecting` | | The client is replacing its connection. On a Slack refresh, the current connection keeps serving after the new one is `connected`, until Slack sends it `disconnect`. It will soon transition to `connecting`. | -| `disconnected` | `(error)` | The client is not connected to the platform. This is a steady state - no attempt to connect is occurring. The `error` argument will be `undefined` when the client initiated the disconnect (normal). | +| `disconnected` | `(error)` | The client is not connected to the platform. This is a steady state - no attempt to connect is occurring. The `error` argument will be `undefined` when the client initiated the disconnect (normal). | The client also emits events that are part of its lifecycle, but aren't states. Instead, they represent specific moments that might be helpful to your app. The following table lists these events, their description, and includes the arguments that a listener would receive. From 88bf02f408b29b20b2f700e4b68f2f726dd6bd38 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 14:34:48 -0500 Subject: [PATCH 17/22] test(socket-mode): wait for the replacement instead of a fixed sleep The overlap test assumed the replacement connects within 650ms, which fails on Windows with undici 8. Waiting up to 5s for the connection still catches a missing or duplicate replacement, and reports how long it took. Co-Authored-By: Claude --- .../test/integrations/integration.test.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/socket-mode/test/integrations/integration.test.js b/packages/socket-mode/test/integrations/integration.test.js index 099f5387c..9d1d95ffc 100644 --- a/packages/socket-mode/test/integrations/integration.test.js +++ b/packages/socket-mode/test/integrations/integration.test.js @@ -130,7 +130,7 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => clientPingTimeout: 100, }); }); - it('opens only the replacement when the old connection closes while its URL is fetched', async () => { + it('opens only the replacement when the old connection closes while its URL is fetched', async (t) => { await client.start(); const old = exposed_ws_connection; server.close(); @@ -142,16 +142,22 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => }); server.listen(HTTP_PORT); let connections = 0; - wss.on('connection', () => { - connections += 1; + const refreshedAt = Date.now(); + const replaced = new Promise((resolve) => { + wss.on('connection', () => { + connections += 1; + resolve(`${Date.now() - refreshedAt}ms`); + }); }); old.send(JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' })); await sleep(50); old.terminate(); - await sleep(600); + const elapsed = await Promise.race([replaced, sleep(5000).then(() => 'never, within 5s')]); + t.diagnostic(`replacement connected after ${elapsed}`); + await sleep(300); - assert.strictEqual(connections, 1); + assert.strictEqual(connections, 1, `replacement connected after ${elapsed}`); assert.strictEqual(wss.clients.size, 1); await client.disconnect(); }); From 3d17211344f289334b5c4d4a96d7c1bc24636be0 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 14:53:29 -0500 Subject: [PATCH 18/22] docs(socket-mode): note the two-connection overlap during a refresh Co-Authored-By: Claude --- .changeset/socket-mode-refresh-overlap.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/socket-mode-refresh-overlap.md b/.changeset/socket-mode-refresh-overlap.md index 8f8a15054..565a2a2eb 100644 --- a/.changeset/socket-mode-refresh-overlap.md +++ b/.changeset/socket-mode-refresh-overlap.md @@ -3,3 +3,5 @@ --- Fix a 5 to 12 second gap without a connection each time Slack refreshes a Socket Mode connection ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). On a `disconnect` message the client now opens the replacement connection right away, without a reconnect backoff, and leaves the old connection open until Slack sends it a `disconnect` message too, so payloads Slack still sends on it during the switch are received instead of dropped. Each payload is acknowledged on the connection it arrived on. While the replacement is being opened, the current connection keeps serving, and overlapping `disconnect` messages open one replacement. A replacement that fails for good rejects with its cause, as a failed reconnect does. A `link_disabled` message still closes the connection first, since a replacement can't connect while Socket Mode is off. + +**Note:** during each refresh, a client now holds two connections for about 60 seconds, until Slack sends the old one its own `disconnect` (`refresh_requested`, about a minute after the `warning`). Both count toward Slack's limit of 10 open Socket Mode connections per app, so apps that run many instances at once should leave room for one extra connection per instance while they refresh. From d381bb4a76e5266413e999764930b816dff382ad Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 15:32:49 -0500 Subject: [PATCH 19/22] test(socket-mode): pin the guards that ignore stale disconnect messages A disconnect message from a connection a later refresh already closed, or one that arrives after disconnect(), must not open a replacement. Each test fails without its guard. Co-Authored-By: Claude --- .../socket-mode/src/SocketModeClient.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index e56c575c3..e18656814 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -1063,6 +1063,33 @@ describe('SocketModeClient', () => { assert.strictEqual(old.disconnectCalls, 1); await client.disconnect(); }); + + it('should ignore a disconnect message that arrives after disconnect()', async () => { + const { client } = createClient(); + const old = await connect(client); + + await client.disconnect(); + old.receive(refreshRequested); + await sleep(10); + + assert.strictEqual(sockets.length, 1); + }); + + it('should ignore a late disconnect message from a connection a later refresh closed', async () => { + const { client } = createClient(); + const old = await connect(client); + + old.receive(refreshRequested); + await sleep(0); + sockets[1].open().hello(); + sockets[1].receive(refreshRequested); + await sleep(0); + sockets[2].open().hello(); + old.receive(refreshRequested); + await sleep(10); + + assert.strictEqual(sockets.length, 3); + }); }); }); From e983ce4bd1affdb4614e331af0b5b62c8e52ee17 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 15:33:05 -0500 Subject: [PATCH 20/22] refactor(socket-mode): drop the hello identity check Every replaced connection is either retired after its own hello or disconnected while still connecting, so a hello from a connection that is no longer current only happens when start() is called again before the first hello, which isn't a supported flow. Co-Authored-By: Claude --- packages/socket-mode/src/SocketModeClient.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index d4f14b411..e8e0c25d1 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -409,7 +409,6 @@ export class SocketModeClient extends EventEmitter { // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { - if (socket && socket !== this.websocket) return; this.emit(State.Connected, this.connectionResponse); return; } From 5e1e4220e71aac7a570f9b9ca3d0dbfb571744c1 Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 15:33:40 -0500 Subject: [PATCH 21/22] test(socket-mode): trim the refresh tests to what they pin - Keep the malformed-payload ack assertion exact now that send() takes the arrival socket. - Drop the after-hello retiring test; the handshake variant covers the same branch and also checks the replacement keeps working. - Drop the timing instrumentation from the URL-fetch overlap integration test; awaiting the replacement under the suite timeout says the same. Co-Authored-By: Claude --- .../socket-mode/src/SocketModeClient.test.ts | 17 +---------------- .../test/integrations/integration.test.js | 10 ++++------ 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/packages/socket-mode/src/SocketModeClient.test.ts b/packages/socket-mode/src/SocketModeClient.test.ts index e18656814..8588346d8 100644 --- a/packages/socket-mode/src/SocketModeClient.test.ts +++ b/packages/socket-mode/src/SocketModeClient.test.ts @@ -311,7 +311,7 @@ describe('SocketModeClient', () => { assert.strictEqual(args.type, 'events_api'); assert.deepEqual(args.body, body); await args.ack({}); - sinon.assert.calledOnceWith(send, 'malformed', {}); + sinon.assert.calledOnceWithExactly(send, 'malformed', {}, undefined); } }); @@ -958,21 +958,6 @@ describe('SocketModeClient', () => { sinon.assert.calledOnce(reconnecting); }); - it('should close the old connection, not open another, on its own disconnect message after hello', async () => { - const { client } = createClient(); - const old = await connect(client); - - old.receive({ type: 'disconnect', reason: 'warning' }); - await sleep(0); - sockets[1].open().hello(); - old.receive(refreshRequested); - await sleep(0); - - assert.strictEqual(old.disconnectCalls, 1); - assert.strictEqual(sockets.length, 2); - assert.strictEqual(client.websocket, sockets[1]); - }); - it('should close the retiring connection, not open another, on its disconnect message while the replacement handshakes', async () => { const { client } = createClient(); const old = await connect(client); diff --git a/packages/socket-mode/test/integrations/integration.test.js b/packages/socket-mode/test/integrations/integration.test.js index 9d1d95ffc..2f490977d 100644 --- a/packages/socket-mode/test/integrations/integration.test.js +++ b/packages/socket-mode/test/integrations/integration.test.js @@ -130,7 +130,7 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => clientPingTimeout: 100, }); }); - it('opens only the replacement when the old connection closes while its URL is fetched', async (t) => { + it('opens only the replacement when the old connection closes while its URL is fetched', async () => { await client.start(); const old = exposed_ws_connection; server.close(); @@ -142,22 +142,20 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => }); server.listen(HTTP_PORT); let connections = 0; - const refreshedAt = Date.now(); const replaced = new Promise((resolve) => { wss.on('connection', () => { connections += 1; - resolve(`${Date.now() - refreshedAt}ms`); + resolve(); }); }); old.send(JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' })); await sleep(50); old.terminate(); - const elapsed = await Promise.race([replaced, sleep(5000).then(() => 'never, within 5s')]); - t.diagnostic(`replacement connected after ${elapsed}`); + await replaced; await sleep(300); - assert.strictEqual(connections, 1, `replacement connected after ${elapsed}`); + assert.strictEqual(connections, 1); assert.strictEqual(wss.clients.size, 1); await client.disconnect(); }); From 49629b1e030978b4140b32645e810194accf8bfe Mon Sep 17 00:00:00 2001 From: William Bergamin Date: Mon, 9 Nov 2026 15:33:56 -0500 Subject: [PATCH 22/22] refactor(socket-mode): drop comments the names already carry The retiringWebsocket field, send()'s socket parameter, and the close emitter read clearly without them. Co-Authored-By: Claude --- packages/socket-mode/src/SlackWebSocket.ts | 2 +- packages/socket-mode/src/SocketModeClient.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 851674497..8e40452b2 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -294,7 +294,7 @@ export class SlackWebSocket { this.websocket = null; this.stopMonitoringPings(); clearTimeout(this.closeHandshakeTimeout); - // Emit event back to client letting it know which connection has closed (in case it needs to reconnect if + // Emit event back to client letting it know connection has closed (in case it needs to reconnect if // reconnecting is enabled) this.options.client.emit('close', this); } diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index e8e0c25d1..d28eb53c7 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -77,7 +77,6 @@ export class SocketModeClient extends EventEmitter { public websocket?: SlackWebSocket; - /** The previous connection, left open after its replacement takes over until Slack sends it `disconnect`. */ private retiringWebsocket?: SlackWebSocket; /** @@ -485,7 +484,6 @@ export class SocketModeClient extends EventEmitter { * Primarily used to send acknowledgements back to slack for incoming events * @param id the envelope id * @param body the message body or string text - * @param socket the connection the envelope arrived on, used while it is still open */ private send(id: string, body = {}, socket?: SlackWebSocket): Promise { const _body = typeof body === 'string' ? { text: body } : body;