Skip to content

fix(socket-mode): open the replacement connection before closing the old one on refresh - #2821

Merged
WilliamBergamin merged 32 commits into
fix-issue-2656from
fix-issue-2767
Nov 9, 2026
Merged

WilliamBergamin merged 32 commits into
fix-issue-2656from
fix-issue-2767

Conversation

@WilliamBergamin

@WilliamBergamin WilliamBergamin commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #2767: on a Slack disconnect message, 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):

  • 🟢 On a disconnect message, start() runs right away, without the backoff. A link_disabled message still closes the connection first, since a replacement can't connect while Socket Mode is off.
  • 🟢 The old connection stays open, still receiving and acking, until Slack sends it its own disconnect (refresh_requested, about 60s after the warning). 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 new hello dropped them.
  • 🟢 Each payload is acked on the connection it arrived on, or on the current one if that connection has closed.
  • 🟢 close now carries the SlackWebSocket that closed, so a replaced connection's close doesn't trigger a reconnect.
  • 🟢 Overlapping triggers (the old connection closing during the replacement's URL fetch, back-to-back disconnect messages, or a late one on the old connection) open one replacement.
  • 🟢 disconnect() also closes the old connection, and start() 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 emits reconnecting, authenticated, connecting, connected, and no disconnected (README updated). bolt-js only listens to slack_event.

⚠️ Trade-off: each refresh now holds two connections for about 60s. Both count toward Slack's limit of 10, so an app running many instances needs room for one extra connection per instance while it refreshes. The changeset calls this out.

Testing

  • Each scenario has a unit test with a fake SlackWebSocket. An integration test with the real undici client sends an envelope on the old connection after the new hello and checks it is received and acked there.
  • refresh-gap.mjs from socket-mode: Slack's disconnect (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).
  • Soak with a real Socket Mode app and &debug_reconnects=true, bursts of messages across each refresh:
    • Before this change (closing the old connection at hello): 2 of about 270 events needed a Slack retry.
    • Now: 6 refreshes, 288 events, 0 retries. 44 payloads arrived on the old connection after the switch and were acked there. num_connections never went above 2.
  • On Windows with undici 8 the replacement in the overlap integration test connects after about 1.3s (0.3s elsewhere), so that test waits up to 5s instead of a fixed sleep.

Requirements

🤖 Generated with Claude Code

…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-bot

changeset-bot Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 49629b1

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@slack/socket-mode Patch

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

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.49123% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.28%. Comparing base (74c7594) to head (49629b1).
✅ All tests successful. No failed tests found.

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     
Flag Coverage Δ
cli-hooks 94.16% <96.49%> (+0.29%) ⬆️
cli-test 94.16% <96.49%> (+0.29%) ⬆️
logger 94.16% <96.49%> (+0.29%) ⬆️
oauth 94.16% <96.49%> (+0.29%) ⬆️
socket-mode 94.16% <96.49%> (+0.29%) ⬆️
web-api 94.16% <96.49%> (+0.29%) ⬆️
webhook 94.16% <96.49%> (+0.29%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

WilliamBergamin and others added 11 commits November 6, 2026 11:16
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>
@WilliamBergamin WilliamBergamin self-assigned this Nov 6, 2026
@WilliamBergamin WilliamBergamin added bug M-T: A confirmed bug report. Issues are confirmed when the reproduction steps are documented semver:patch pkg:socket-mode applies to `@slack/socket-mode` labels Nov 6, 2026
@WilliamBergamin WilliamBergamin added this to the socket-mode@next milestone Nov 6, 2026
@WilliamBergamin
WilliamBergamin marked this pull request as ready for review November 6, 2026 21:25
@WilliamBergamin
WilliamBergamin requested a review from a team as a code owner November 6, 2026 21:25
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 mwbrooks left a comment

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.

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

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

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.

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"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 () => {

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.

@mwbrooks
mwbrooks dismissed their stale review November 6, 2026 23:01

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>
WilliamBergamin and others added 3 commits November 9, 2026 11:00
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>
WilliamBergamin and others added 2 commits November 9, 2026 11:03
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 mwbrooks left a comment

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.

✅ Thanks @WilliamBergamin! Ignoring a hello from a replaced connection closes the race nicely, and the new test pins it down.

WilliamBergamin and others added 13 commits November 9, 2026 11:44
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>
@WilliamBergamin
WilliamBergamin merged commit dea9b45 into fix-issue-2656 Nov 9, 2026
22 checks passed
@WilliamBergamin
WilliamBergamin deleted the fix-issue-2767 branch November 9, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug M-T: A confirmed bug report. Issues are confirmed when the reproduction steps are documented pkg:socket-mode applies to `@slack/socket-mode` semver:patch

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants