Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
e9f103e
fix(socket-mode): open the replacement connection before closing the …
WilliamBergamin Nov 5, 2026
827ce21
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 6, 2026
5d010e6
fix(socket-mode): skip the reconnect backoff only for Slack's refresh…
WilliamBergamin Nov 6, 2026
fe6d993
refactor(socket-mode): name the replacement and retiring-socket steps
WilliamBergamin Nov 6, 2026
ae4cff4
refactor(socket-mode): drop the retire() docstring
WilliamBergamin Nov 6, 2026
63544b1
refactor(socket-mode): shorten the retiringWebsocket docstring
WilliamBergamin Nov 6, 2026
5017bc2
refactor(socket-mode): await the replacement start() in a try/catch
WilliamBergamin Nov 6, 2026
e493bcf
fix(socket-mode): open a replacement on every disconnect reason
WilliamBergamin Nov 6, 2026
f1d0382
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 6, 2026
f127bf7
Merge remote-tracking branch 'origin/fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 6, 2026
e542c96
refactor(socket-mode): drop the close guard the identity check made dead
WilliamBergamin Nov 6, 2026
5c5a8b6
Merge remote-tracking branch 'origin/fix-issue-2767' into fix-issue-2767
WilliamBergamin Nov 6, 2026
2adb58e
fix(socket-mode): close instead of overlapping on link_disabled
WilliamBergamin Nov 6, 2026
791caeb
fix(socket-mode): only act on hello from the current connection
WilliamBergamin Nov 9, 2026
c28f8c3
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 9, 2026
ea92e5c
fix(socket-mode): skip a replacement while one is in flight, or from …
WilliamBergamin Nov 9, 2026
dca9696
fix(socket-mode): keep a failed replacement as loud as a failed recon…
WilliamBergamin Nov 9, 2026
2e2669a
test(socket-mode): pin overlapping refreshes to two connections
WilliamBergamin Nov 9, 2026
c7e980d
docs(socket-mode): describe overlapping refreshes in the changeset
WilliamBergamin Nov 9, 2026
4028603
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 9, 2026
3934ce6
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 9, 2026
554aef5
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 9, 2026
c923238
fix(socket-mode): keep the old connection open until Slack sends it d…
WilliamBergamin Nov 9, 2026
0f05a1e
style(socket-mode): realign the README lifecycle table
WilliamBergamin Nov 9, 2026
88bf02f
test(socket-mode): wait for the replacement instead of a fixed sleep
WilliamBergamin Nov 9, 2026
e7efe98
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 9, 2026
3d17211
docs(socket-mode): note the two-connection overlap during a refresh
WilliamBergamin Nov 9, 2026
f4dd1f4
Merge branch 'fix-issue-2656' into fix-issue-2767
WilliamBergamin Nov 9, 2026
d381bb4
test(socket-mode): pin the guards that ignore stale disconnect messages
WilliamBergamin Nov 9, 2026
e983ce4
refactor(socket-mode): drop the hello identity check
WilliamBergamin Nov 9, 2026
5e1e422
test(socket-mode): trim the refresh tests to what they pin
WilliamBergamin Nov 9, 2026
49629b1
refactor(socket-mode): drop comments the names already carry
WilliamBergamin Nov 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/socket-mode-refresh-overlap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@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 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.
14 changes: 7 additions & 7 deletions packages/socket-mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
| `reconnecting` | | The client is no longer connected to the platform and cleaning up its resources. 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). |
| 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). |

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.

Expand Down
30 changes: 30 additions & 0 deletions packages/socket-mode/src/SlackWebSocket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/socket-mode/src/SlackWebSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
289 changes: 288 additions & 1 deletion packages/socket-mode/src/SocketModeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ describe('SocketModeClient', () => {
assert.strictEqual(args.type, 'events_api');
assert.deepEqual(args.body, body);
await args.ack({});
sinon.assert.calledOnceWithExactly(send, 'malformed', {});
sinon.assert.calledOnceWithExactly(send, 'malformed', {}, undefined);
}
});

Expand Down Expand Up @@ -789,6 +789,293 @@ describe('SocketModeClient', () => {
await assert.rejects(starting, { name: 'SMConnectAbortedError' });
});
});

describe('server-requested disconnect (issue #2767)', () => {
const refreshRequested = { type: 'disconnect', reason: 'refresh_requested' };
const envelope = { type: 'interactive', envelope_id: 'e-1', payload: {} };

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();
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, 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 acknowledge each envelope on the connection it arrived on', 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);
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<void>> = [];
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 () => {
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, 0);
assert.strictEqual(client.websocket, sockets[2]);
});

it('should open only the replacement when the old connection closes while its URL is fetched', async () => {
const { client, holdNextURL } = createClient({ clientPingTimeout: 1 });
const old = await connect(client);
const releaseURL = holdNextURL();
const events = { connected: sandbox.spy(), reconnecting: sandbox.spy() };
for (const [name, spy] of Object.entries(events)) client.on(name, spy);

old.receive(refreshRequested);
await sleep(0);
old.drop();
await sleep(10);
releaseURL();
await sleep(0);
sockets[1].open().hello();
await sleep(10);

assert.deepStrictEqual(
sockets.map((socket) => socket.isActive()),
[false, true],
);
assert.strictEqual(client.websocket, sockets[1]);
sinon.assert.calledOnce(events.connected);
sinon.assert.calledOnce(events.reconnecting);
});

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[1].open().hello();
await sleep(10);

assert.strictEqual(sockets.length, 2);
assert.strictEqual(client.websocket, sockets[1]);
sinon.assert.calledOnce(reconnecting);
});

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);

old.receive({ type: 'disconnect', reason: 'warning' });
await sleep(0);
const replacement = sockets[1].open();
old.receive(refreshRequested);
await sleep(0);

assert.strictEqual(sockets.length, 2);
assert.strictEqual(old.disconnectCalls, 1);

replacement.hello();
replacement.receive(envelope);
await sleep(0);

assert.strictEqual(replacement.disconnectCalls, 0);
assert.strictEqual(replacement.sent.length, 1);
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 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, 0);
assert.strictEqual(client.websocket, sockets[2]);
});

it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Thanks for covering disconnect() mid-refresh and the back to back disconnect messages. Those are the races I'd have worried about most.

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);
});

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, 1);
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);
});
});
});

async function sleep(ms: number): Promise<void> {
Expand Down
Loading
Loading