Skip to content

Commit 5395b0c

Browse files
authored
fix(socket-mode): terminate closing connections earlier if normal close responses fail (#2565)
1 parent 62c5a3f commit 5395b0c

3 files changed

Lines changed: 56 additions & 1 deletion

File tree

.changeset/full-spiders-enter.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@slack/socket-mode": patch
3+
---
4+
5+
fix: terminate closing connections earlier if normal close responses fail
6+
7+
If Slack doesn't respond to a close frame, the WebSocket connection is now force-terminated instead of waiting for a response that won't arrive. Since [disconnects are expected](https://docs.slack.dev/apis/events-api/using-socket-mode/#disconnect) every few hours, this avoids repeated "pong wasn't received" warnings and speeds up reconnection.

packages/socket-mode/src/SlackWebSocket.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,16 @@ export class SlackWebSocket {
159159
if (this.closeFrameReceived) {
160160
this.logger.debug('Terminating WebSocket (close frame received).');
161161
this.terminate();
162+
} else if (this.websocket.readyState === WebSocket.CLOSING) {
163+
// A close frame was already sent but the peer hasn't responded. Force-terminate rather than
164+
// waiting for the ws library's closeTimeout (~30s) while the ping monitor logs repeated warnings.
165+
this.logger.debug('Terminating WebSocket (close frame sent but no response, force-terminating).');
166+
this.terminate();
162167
} else {
163168
// If we haven't received a close frame yet, then we send one to the peer, expecting to receive a close frame
164169
// in response.
165170
this.logger.debug('Sending close frame (status=1000).');
166-
this.websocket.close(1000); // send a close frame, 1000=Normal Closure
171+
this.websocket.close(1000); // 1000 = Normal Closure
167172
}
168173
} else {
169174
this.logger.debug('WebSocket already disconnected, flushing remainder.');

packages/socket-mode/test/integration.test.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,49 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () =>
405405
await reconnectedWaiter;
406406
await client.disconnect();
407407
});
408+
it('should reconnect if server becomes completely unresponsive and does not respond to close frames', async () => {
409+
wss.close();
410+
// Use noServer mode so we get access to the raw socket via the upgrade event.
411+
// After sending hello, we pause the socket to simulate a fully unresponsive server
412+
// that won't respond to pings OR close frames.
413+
wss = new WebSocketServer({ noServer: true, autoPong: false });
414+
const unresponsiveWsServer = createServer();
415+
let rawSocket = null;
416+
unresponsiveWsServer.on('upgrade', (req, socket, head) => {
417+
rawSocket = socket;
418+
wss.handleUpgrade(req, socket, head, (ws) => {
419+
ws.on('error', () => {});
420+
ws.send(JSON.stringify({ type: 'hello' }));
421+
exposed_ws_connection = ws;
422+
// Make the server completely unresponsive: it won't process any incoming
423+
// data, including ping frames and close frames.
424+
socket.pause();
425+
});
426+
});
427+
await new Promise((res) => unresponsiveWsServer.listen(WSS_PORT, res));
428+
await client.start();
429+
let closeCount = 0;
430+
client.on('close', () => {
431+
closeCount++;
432+
});
433+
// Swap in a working WSS for the reconnection attempt
434+
client.on('reconnecting', () => {
435+
if (rawSocket) rawSocket.destroy();
436+
unresponsiveWsServer.close();
437+
wss.close();
438+
wss = new WebSocketServer({ port: WSS_PORT });
439+
wss.on('connection', (ws) => {
440+
ws.on('error', () => {});
441+
ws.send(JSON.stringify({ type: 'hello' }));
442+
exposed_ws_connection = ws;
443+
});
444+
});
445+
const reconnectedWaiter = new Promise((res) => client.on('connected', res));
446+
await reconnectedWaiter;
447+
// The force-terminate should produce 1 close event per reconnection attempt
448+
assert.strictEqual(closeCount, 1);
449+
await client.disconnect();
450+
});
408451
it('should reconnect if server does not respond with `pong` message within specified client ping timeout after initially responding with `pong`', async () => {
409452
wss.close();
410453
// override the web socket server so that it DOESNT auto-respond to ping messages with a pong, except for the first time

0 commit comments

Comments
 (0)