Repository navigation
fix(socket-mode): open the replacement connection before closing the old one on refresh - #2821
Conversation
…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 <svc-devxp-claude@slack-corp.com>
🦋 Changeset detectedLatest commit: 49629b1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## fix-issue-2656 #2821 +/- ##
==================================================
+ Coverage 94.05% 94.28% +0.23%
==================================================
Files 67 67
Lines 10625 10670 +45
Branches 654 672 +18
==================================================
+ Hits 9993 10060 +67
+ Misses 606 582 -24
- Partials 26 28 +2
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
Keep the base branch's abortable reconnect delay (reconnectDelay) and SMConnectAbortedError, with this branch's close-listener socket check and retiring-connection cleanup in disconnect(). Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
… 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 <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
mwbrooks
left a comment
There was a problem hiding this comment.
Thanks @WilliamBergamin, the overlap approach reads really well and the tests cover most of the races. One hello race left before this lands, details inline. Requesting changes only so it doesn't merge by accident while we sort it out.
|
|
||
| // Slack has finalized the handshake with a hello message; we are good to go. | ||
| if (event.type === 'hello') { | ||
| this.closeRetiringWebsocket(); |
There was a problem hiding this comment.
issue: Any hello closes the retiring connection, whichever socket it came from. If a second disconnect lands after the first replacement opens but before its hello, retire() closes the old serving connection, then the first replacement's hello closes that replacement too. Nothing serves until the second replacement says hello, and if it fails we're back in the backoff gap this PR fixes. Passing the socket through ws_message (like close now does) and only retiring on a hello from this.websocket should cover it.
There was a problem hiding this comment.
Good catch, reproduced it with a test (second disconnect after the first replacement opens, before its hello). ws_message now carries the socket like close, and a hello from a replaced connection is ignored, so the first replacement keeps serving until the second one says hello.
| "@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. |
There was a problem hiding this comment.
suggestion (non-blocking): "keeps serving until it closes, then the usual reconnect takes over" reads like the backoff waits for the old connection to close, but it starts as soon as the replacement fails (the "should back off and keep the old connection serving" test expects that). Maybe "keeps serving while the usual reconnect retries"?
There was a problem hiding this comment.
Took your wording thank you 🙏
| assert.strictEqual(old.disconnectCalls, 1); | ||
| }); | ||
|
|
||
| it('should close both connections and emit disconnected once when disconnect() runs mid-refresh', async () => { |
There was a problem hiding this comment.
praise: Thanks for covering disconnect() mid-refresh and the back to back disconnect messages. Those are the races I'd have worried about most.
Downgrading to a comment, the hello race isn't a critical blocker. Suggestions inline still stand.
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 <svc-devxp-claude@slack-corp.com>
Brings in the session signal and the in-flight count. The count already stops the old connection's close from scheduling a backoff while the replacement's URL is fetched, so that test now expects two connections and one connected/reconnecting each. The test harness now lives in the shared scope. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
…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 <svc-devxp-claude@slack-corp.com>
…nect 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 <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
mwbrooks
left a comment
There was a problem hiding this comment.
✅ Thanks @WilliamBergamin! Ignoring a hello from a replaced connection closes the race nicely, and the new test pins it down.
Picks up the pending-connects set; the replacement's busy check uses it too. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Picks up the try/catch around the reconnect wait. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Picks up the connectOperations rename; the replacement's busy check uses it. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
…isconnect 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 <svc-devxp-claude@slack-corp.com>
Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
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 <svc-devxp-claude@slack-corp.com>
- 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 <svc-devxp-claude@slack-corp.com>
The retiringWebsocket field, send()'s socket parameter, and the close emitter read clearly without them. Co-Authored-By: Claude <svc-devxp-claude@slack-corp.com>
Summary
Fixes #2767: on a Slack
disconnectmessage, the client closed its only connection, then reconnected through the failure backoff, so apps were offline for 5 to 12s every few hours. Supersedes #2785 (thanks @hippi345, credited as co-author for the overlap and the ack-on-old-socket idea). Stacked on #2820; review that one first.Now the client opens the next connection before closing the old one (Slack allows up to 10):
disconnectmessage,start()runs right away, without the backoff. Alink_disabledmessage still closes the connection first, since a replacement can't connect while Socket Mode is off.disconnect(refresh_requested, about 60s after thewarning). Then the client closes it. Slack keeps routing payloads to both connections for several seconds after the switch, so closing the old one at the newhellodropped them.closenow carries theSlackWebSocketthat closed, so a replaced connection's close doesn't trigger a reconnect.disconnectmessages, or a late one on the old connection) open one replacement.disconnect()also closes the old connection, andstart()closes a previous connection that never opened.While the replacement's URL fetch retries, the old connection keeps serving. A failure for good rejects with its cause, as on
main. A refresh emitsreconnecting,authenticated,connecting,connected, and nodisconnected(README updated). bolt-js only listens toslack_event.Testing
SlackWebSocket. An integration test with the real undici client sends an envelope on the old connection after the newhelloand checks it is received and acked there.refresh-gap.mjsfrom socket-mode: Slack'sdisconnect(refresh_requested) leaves the app without a connection for 9–12 s #2767 against a build: under 0.1s without a connection, with and without--rfc(10.7s and 5.0s on 3.1.1).&debug_reconnects=true, bursts of messages across each refresh:hello): 2 of about 270 events needed a Slack retry.num_connectionsnever went above 2.Requirements
🤖 Generated with Claude Code