Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/socket-mode-refresh-gap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@slack/socket-mode": patch
---

fix(socket-mode): reconnect immediately on Slack `disconnect` without a connection gap ([#2767](https://github.com/slackapi/node-slack-sdk/issues/2767)). If the replacement socket never receives `hello`, abort the refresh and recover with the usual reconnect backoff.
317 changes: 317 additions & 0 deletions packages/socket-mode/src/SocketModeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,323 @@ describe('SocketModeClient', () => {
});
});

describe('server-requested disconnect (issue #2767)', () => {
it('should open a new connection immediately and keep the old socket until it is connected', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
clientPingTimeout: 5000,
});
const oldWs = {
disconnect: sandbox.stub(),
isActive: () => true,
};
client.websocket = oldWs as unknown as SlackWebSocket;

let resolveStart: ((value: unknown) => void) | undefined;
const startStub = sandbox.stub(client, 'start').callsFake(() => {
// Real start() replaces `websocket` with the new connection before hello.
client.websocket = {
disconnect: sandbox.stub(),
isActive: () => true,
} as unknown as SlackWebSocket;
return new Promise((resolve) => {
resolveStart = resolve;
});
});

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(30);

sinon.assert.calledOnce(startStub);
sinon.assert.notCalled(oldWs.disconnect);

resolveStart?.({});
await sleep(30);

sinon.assert.calledOnce(oldWs.disconnect);
});

it('new socket closes before hello', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
clientPingTimeout: 50,
});
const oldWs = {
disconnect: sandbox.stub(),
isActive: () => true,
};
client.websocket = oldWs as unknown as SlackWebSocket;
const newWs = {
disconnect: sandbox.stub(),
isActive: () => false,
};
const startStub = sandbox.stub(client, 'start').callsFake(() => {
client.websocket = newWs as unknown as SlackWebSocket;
return new Promise((resolve, reject) => {
client.once('connected', resolve);
client.once('disconnected', reject);
});
});

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(20);

sinon.assert.calledOnce(startStub);
sinon.assert.notCalled(oldWs.disconnect);

client.emit('close');
await sleep(20);

sinon.assert.called(oldWs.disconnect);
sinon.assert.calledOnce(startStub);

await sleep(80);
sinon.assert.calledTwice(startStub);
});

it('old socket closes while the refresh is pending', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
clientPingTimeout: 50,
});
const oldWs = {
disconnect: sandbox.stub(),
isActive: () => true,
};
client.websocket = oldWs as unknown as SlackWebSocket;
const newWs = {
disconnect: sandbox.stub(),
isActive: () => false,
};
const startStub = sandbox.stub(client, 'start').callsFake(() => {
client.websocket = newWs as unknown as SlackWebSocket;
return new Promise((resolve, reject) => {
client.once('connected', resolve);
client.once('disconnected', reject);
});
});

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(20);

sinon.assert.calledOnce(startStub);

oldWs.isActive = () => false;
client.emit('close');
await sleep(20);

sinon.assert.calledOnce(startStub);

await sleep(80);
sinon.assert.calledTwice(startStub);
});

it('should acknowledge on the retiring socket while the replacement is connecting', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
clientPingTimeout: 5000,
});
const oldSend = sandbox.stub();
const oldWs = {
disconnect: sandbox.stub(),
isActive: () => true,
readyState: 1,
send: (data: string, cb: (err: Error | undefined) => void) => {
oldSend(data);
cb(undefined);
},
};
client.websocket = oldWs as unknown as SlackWebSocket;
sandbox.stub(client, 'start').callsFake(() => {
client.websocket = {
disconnect: sandbox.stub(),
isActive: () => false,
readyState: 0,
send: sandbox.stub(),
} as unknown as SlackWebSocket;
return new Promise(() => {});
});

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(20);

let ackError: unknown;
client.on('slash_commands', async ({ ack }) => {
try {
await ack({});
} catch (error) {
ackError = error;
}
});
client.emit(
'ws_message',
JSON.stringify({
envelope_id: 'ack-during-refresh',
type: 'slash_commands',
payload: { command: '/hello-socket-mode' },
}),
false /* isBinary */,
);
await sleep(20);

assert.equal(ackError, undefined);
sinon.assert.calledOnce(oldSend);
});

it('should acknowledge on the old socket while the new socket is OPEN but has not received hello', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
clientPingTimeout: 5000,
});
const oldSend = sandbox.stub();
const newSend = sandbox.stub();
const oldWs = {
disconnect: sandbox.stub(),
isActive: () => true,
readyState: 1,
send: (data: string, cb: (err: Error | undefined) => void) => {
oldSend(data);
cb(undefined);
},
};
client.websocket = oldWs as unknown as SlackWebSocket;
sandbox.stub(client, 'start').callsFake(() => {
// Real WebSocket is OPEN well before Slack sends `hello`.
client.websocket = {
disconnect: sandbox.stub(),
isActive: () => true,
readyState: 1,
send: (data: string, cb: (err: Error | undefined) => void) => {
newSend(data);
cb(undefined);
},
} as unknown as SlackWebSocket;
return new Promise(() => {});
});

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(20);

let ackError: unknown;
client.on('slash_commands', async ({ ack }) => {
try {
await ack({});
} catch (error) {
ackError = error;
}
});
client.emit(
'ws_message',
JSON.stringify({
envelope_id: 'ack-before-hello',
type: 'slash_commands',
payload: { command: '/hello-socket-mode' },
}),
false /* isBinary */,
);
await sleep(20);

assert.equal(ackError, undefined);
sinon.assert.calledOnce(oldSend);
sinon.assert.notCalled(newSend);
});

it('should emit disconnected once if disconnect() is called during a refresh', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
clientPingTimeout: 5000,
});
const disconnected = sandbox.stub();
client.on('disconnected', disconnected);

const oldWs = {
isActive: () => true,
disconnect: sandbox.stub().callsFake(() => {
oldWs.isActive = () => false;
client.emit('close');
}),
};
client.websocket = oldWs as unknown as SlackWebSocket;

const newWs = {
isActive: () => true,
disconnect: sandbox.stub().callsFake(() => {
newWs.isActive = () => false;
client.emit('close');
}),
};
sandbox.stub(client, 'start').callsFake(() => {
client.websocket = newWs as unknown as SlackWebSocket;
return new Promise((resolve, reject) => {
client.once('connected', resolve);
client.once('disconnected', reject);
});
});

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(20);

await client.disconnect();
await sleep(20);

sinon.assert.calledOnce(disconnected);
});

it('should close the current socket immediately when auto-reconnect is disabled', async () => {
const client = new SocketModeClient({
appToken: 'xapp-',
logLevel: LogLevel.ERROR,
autoReconnectEnabled: false,
});
const oldWs = {
disconnect: sandbox.stub(),
isActive: () => true,
};
client.websocket = oldWs as unknown as SlackWebSocket;
const startStub = sandbox.stub(client, 'start').resolves(undefined as never);

client.emit(
'ws_message',
JSON.stringify({ type: 'disconnect', reason: 'refresh_requested' }),
false /* isBinary */,
);
await sleep(30);

sinon.assert.calledOnce(oldWs.disconnect);
sinon.assert.notCalled(startStub);
});
});

describe("reconnection on 'close' (issue #2709)", () => {
it('should not reconnect on a stale close event while the current connection is still active', () => {
const client = new SocketModeClient({ appToken: 'xapp-', logLevel: LogLevel.ERROR });
Expand Down
Loading