From 57dfdcf6a72398561cd10d6b9d2d558d6a4d1e31 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 29 Apr 2026 11:39:38 -0700 Subject: [PATCH 1/4] fix(socket-mode): terminate closing connections earlier if normal close response fails --- .changeset/full-spiders-enter.md | 5 +++ packages/socket-mode/src/SlackWebSocket.ts | 7 +++- packages/socket-mode/test/integration.test.js | 37 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .changeset/full-spiders-enter.md diff --git a/.changeset/full-spiders-enter.md b/.changeset/full-spiders-enter.md new file mode 100644 index 000000000..784166ea8 --- /dev/null +++ b/.changeset/full-spiders-enter.md @@ -0,0 +1,5 @@ +--- +"@slack/socket-mode": patch +--- + +fix: terminate closing connections earlier if normal close responses fails diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts index 31fdff9eb..85262f42f 100644 --- a/packages/socket-mode/src/SlackWebSocket.ts +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -159,11 +159,16 @@ export class SlackWebSocket { if (this.closeFrameReceived) { this.logger.debug('Terminating WebSocket (close frame received).'); this.terminate(); + } else if (this.websocket.readyState === WebSocket.CLOSING) { + // A close frame was already sent but the peer hasn't responded. Force-terminate rather than + // waiting for the ws library's closeTimeout (~30s) while the ping monitor logs repeated warnings. + this.logger.debug('Terminating WebSocket (close frame sent but no response, force-terminating).'); + this.terminate(); } else { // If we haven't received a close frame yet, then we send one to the peer, expecting to receive a close frame // in response. this.logger.debug('Sending close frame (status=1000).'); - this.websocket.close(1000); // send a close frame, 1000=Normal Closure + this.websocket.close(1000); // 1000 = Normal Closure } } else { this.logger.debug('WebSocket already disconnected, flushing remainder.'); diff --git a/packages/socket-mode/test/integration.test.js b/packages/socket-mode/test/integration.test.js index 1d4fce906..99d819f62 100644 --- a/packages/socket-mode/test/integration.test.js +++ b/packages/socket-mode/test/integration.test.js @@ -405,6 +405,43 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => await reconnectedWaiter; await client.disconnect(); }); + it('should reconnect if server becomes completely unresponsive and does not respond to close frames', async () => { + wss.close(); + // Use noServer mode so we get access to the raw socket via the upgrade event. + // After sending hello, we pause the socket to simulate a fully unresponsive server + // that won't respond to pings OR close frames. + wss = new WebSocketServer({ noServer: true, autoPong: false }); + const unresponsiveWsServer = createServer(); + let rawSocket = null; + unresponsiveWsServer.on('upgrade', (req, socket, head) => { + rawSocket = socket; + wss.handleUpgrade(req, socket, head, (ws) => { + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + exposed_ws_connection = ws; + // Make the server completely unresponsive: it won't process any incoming + // data, including ping frames and close frames. + socket.pause(); + }); + }); + await new Promise((res) => unresponsiveWsServer.listen(WSS_PORT, res)); + await client.start(); + // Swap in a working WSS for the reconnection attempt + client.on('reconnecting', () => { + if (rawSocket) rawSocket.destroy(); + unresponsiveWsServer.close(); + wss.close(); + wss = new WebSocketServer({ port: WSS_PORT }); + wss.on('connection', (ws) => { + ws.on('error', () => {}); + ws.send(JSON.stringify({ type: 'hello' })); + exposed_ws_connection = ws; + }); + }); + const reconnectedWaiter = new Promise((res) => client.on('connected', res)); + await reconnectedWaiter; + await client.disconnect(); + }); it('should reconnect if server does not respond with `pong` message within specified client ping timeout after initially responding with `pong`', async () => { wss.close(); // override the web socket server so that it DOESNT auto-respond to ping messages with a pong, except for the first time From 9e0bdfbf1f1637ceeece170b4d3325a1a276772b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 29 Apr 2026 11:40:47 -0700 Subject: [PATCH 2/4] chore: changeset --- .changeset/full-spiders-enter.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/full-spiders-enter.md b/.changeset/full-spiders-enter.md index 784166ea8..7cdd6499b 100644 --- a/.changeset/full-spiders-enter.md +++ b/.changeset/full-spiders-enter.md @@ -2,4 +2,4 @@ "@slack/socket-mode": patch --- -fix: terminate closing connections earlier if normal close responses fails +fix: terminate closing connections earlier if normal close responses fail From e1916f279381045cdb1c7af5103158694461d14e Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 29 Apr 2026 16:02:06 -0700 Subject: [PATCH 3/4] test: assert close attempts equal one before force terminate --- packages/socket-mode/test/integration.test.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/socket-mode/test/integration.test.js b/packages/socket-mode/test/integration.test.js index 99d819f62..03eccbcef 100644 --- a/packages/socket-mode/test/integration.test.js +++ b/packages/socket-mode/test/integration.test.js @@ -426,6 +426,10 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => }); await new Promise((res) => unresponsiveWsServer.listen(WSS_PORT, res)); await client.start(); + let closeCount = 0; + client.on('close', () => { + closeCount++; + }); // Swap in a working WSS for the reconnection attempt client.on('reconnecting', () => { if (rawSocket) rawSocket.destroy(); @@ -440,6 +444,8 @@ describe('Integration tests with a WebSocket server', { timeout: 30000 }, () => }); const reconnectedWaiter = new Promise((res) => client.on('connected', res)); await reconnectedWaiter; + // The force-terminate should produce 1 close event per reconnection attempt + assert.strictEqual(closeCount, 1); await client.disconnect(); }); it('should reconnect if server does not respond with `pong` message within specified client ping timeout after initially responding with `pong`', async () => { From f1b9280bcf1014c0d2f82a582edc791efe88e42d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 29 Apr 2026 16:24:41 -0700 Subject: [PATCH 4/4] docs: changeset description --- .changeset/full-spiders-enter.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/full-spiders-enter.md b/.changeset/full-spiders-enter.md index 7cdd6499b..0ced61cd4 100644 --- a/.changeset/full-spiders-enter.md +++ b/.changeset/full-spiders-enter.md @@ -3,3 +3,5 @@ --- fix: terminate closing connections earlier if normal close responses fail + +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.