Repository navigation
fix(redis): keep reconnecting when connection closes during client setup (#2099) - #2123
Merged
PavelPashov merged 2 commits intoJun 8, 2026
Conversation
…t setup When `enableReadyCheck` is false, `connectHandler` calls `readyHandler` from the `Promise.all(clientCommandPromises).finally()` callback without checking whether the connection is still the current one. If the connection is closed while the CLIENT SETNAME/SETINFO setup commands are still in flight, this stale callback still runs and marks the dead connection as "ready". That makes the next `connect()` reject with "Redis is already connecting/connected" and permanently stops reconnection. Guard the callback with the same `connectionEpoch` check the `enableReadyCheck: true` branch already uses, plus a status check, so a stale callback becomes a no-op. Fixes redis#2099
|
Hi, I’m Jit, a friendly security platform designed to help developers build secure applications from day zero with an MVS (Minimal viable security) mindset. In case there are security findings, they will be communicated to you as a comment inside the PR. Hope you’ll enjoy using Jit. Questions? Comments? Want to learn more? Get in touch with us. |
Switch the `it` callback to arrow form and rename the `MockServer` variable to `node`, matching every other test in this file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
|
@ranjeetcao Thanks for the PR. I’ll take a look. |
PavelPashov
approved these changes
Jun 8, 2026
github-actions Bot
pushed a commit
that referenced
this pull request
Jul 29, 2026
# [6.0.0-beta.1](v5.11.1...v6.0.0-beta.1) (2026-07-29) * Add RESP3 ([#2127](#2127)) ([5d0862e](5d0862e)) ### Bug Fixes * clear stale socket timeout on reconnect ([#2148](#2148)) ([6455dbe](6455dbe)) * **cluster:** recreate stale connection on circular MOVED ([#2135](#2135)) ([08c8967](08c8967)) * **cluster:** validate MOVED slot to prevent Array.prototype pollution ([#2151](#2151)) ([9618206](9618206)), closes [#1267](#1267) * **command:** serialize large integer arguments in decimal notation ([#2136](#2136)) ([09b8d04](09b8d04)) * **redis:** keep reconnecting when connection closes during client setup ([#2099](#2099)) ([#2123](#2123)) ([f9a66bc](f9a66bc)) * **sentinel:** preserve zero preferred slave priority ([#2129](#2129)) ([a3f9f2d](a3f9f2d)) * **tracing:** redact values for GETSET and PSETEX ([#2134](#2134)) ([832765d](832765d)) ### Features * add LMOVEM and BLMOVEM command support ([#2144](#2144)) ([c26af46](c26af46)) * add Redis 8.10 set cardinality commands ([#2143](#2143)) ([301099b](301099b)) * support MAXCOUNT and MAXSIZE for stream reads ([#2142](#2142)) ([ae5e41b](ae5e41b)) ### BREAKING CHANGES * ioredis now requires Node.js 20 or newer and uses RESP3 by default. Set `protocol: 2` to retain the v5 wire protocol.
|
🎉 This PR is included in version 6.0.0-beta.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
github-actions Bot
pushed a commit
that referenced
this pull request
Jul 31, 2026
# [6.0.0](v5.11.1...v6.0.0) (2026-07-31) * Add RESP3 ([#2127](#2127)) ([5d0862e](5d0862e)) ### Bug Fixes * clear stale socket timeout on reconnect ([#2148](#2148)) ([6455dbe](6455dbe)) * **cluster:** recreate stale connection on circular MOVED ([#2135](#2135)) ([08c8967](08c8967)) * **cluster:** validate MOVED slot to prevent Array.prototype pollution ([#2151](#2151)) ([9618206](9618206)), closes [#1267](#1267) * **command:** serialize large integer arguments in decimal notation ([#2136](#2136)) ([09b8d04](09b8d04)) * **redis:** keep reconnecting when connection closes during client setup ([#2099](#2099)) ([#2123](#2123)) ([f9a66bc](f9a66bc)) * **sentinel:** preserve zero preferred slave priority ([#2129](#2129)) ([a3f9f2d](a3f9f2d)) * **tracing:** redact values for GETSET and PSETEX ([#2134](#2134)) ([832765d](832765d)) * **types:** export ScanStreamOptions, RedisStatus and ClusterStatus ([#2158](#2158)) ([cf3bf71](cf3bf71)) ### Features * add LMOVEM and BLMOVEM command support ([#2144](#2144)) ([c26af46](c26af46)) * add Redis 8.10 set cardinality commands ([#2143](#2143)) ([301099b](301099b)) * himport managed fieldsets ([#2159](#2159)) ([729f174](729f174)) * improve default connection resilience ([#2160](#2160)) ([6d0716e](6d0716e)) * support MAXCOUNT and MAXSIZE for stream reads ([#2142](#2142)) ([ae5e41b](ae5e41b)) ### BREAKING CHANGES * ioredis now requires Node.js 20 or newer and uses RESP3 by default. Set `protocol: 2` to retain the v5 wire protocol.
|
🎉 This PR is included in version 6.0.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #2099 — when
enableReadyCheck: false, the client can permanently stop reconnecting after the socket closes during theCLIENT SETNAME/CLIENT SETINFOhandshake.connectHandler()(lib/redis/event_handler.ts) callsreadyHandler()fromPromise.all(clientCommandPromises).finally()without validating that the connection is still live. If the socket closes during that window andcloseHandler()reaches themaxRetriesPerRequestthreshold (default 20), it flushes the in-flight handshake commands. The individual handshake promises swallow rejection via.catch(noop), so the flush settlesPromise.alland runs the now-stale.finally(). By thencloseHandler()has already moved status to"reconnecting"and scheduled a reconnect; the stale callback overwrites it with"ready"and resetsretryAttempts. The pendingconnect()then rejects with "Redis is already connecting/connected" — swallowed by.catch(noop)— and no further reconnect is ever scheduled.This regressed in #2033, which moved the handshake commands out of
readyHandler()and made theenableReadyCheck: falsereadiness emission asynchronous. Detailed root-cause analysis is in the issue comment.Fix
Guard the
.finally()callback the same way theenableReadyCheck: trueready-check callback (line 136) already is, plus a status check:A stale callback becomes a no-op.
Alignment with existing handshake / reconnect behavior
The fix deliberately reuses patterns already established in this file rather than introducing new mechanisms:
connectionEpochguard mirrors the existing guards in the AUTH callback (event_handler.ts:29) and the_readyCheckcallback (event_handler.ts:136). Both are captured the same way (const { connectionEpoch } = self;at line 26) and compared identically.status !== "connect"guard is needed in addition to the epoch check becauseconnectionEpochis only incremented inside_connect()(Redis.ts:210). BetweencloseHandler()setting status to"reconnecting"and the reconnect timer firing_connect(), the epoch still matches the captured one — only the status reflects that the connection is no longer the one we're setting up. The status check covers exactly that window.connectionEpoch === self.connectionEpochandself.status === "connect"hold, soreadyHandler()is called exactly as before.closeHandler(),_connect(),flushQueue(), orreadyHandler(): the bug is purely that a stale callback runs; the surrounding state machine already does the right thing on its own.enableReadyCheck: truebranch: that branch is naturally protected because_readyCheck()sendsINFOand a dead socket cannot answer, and additionally re-checksconnectionEpochat line 136. The fix gives theenableReadyCheck: falsebranch equivalent protection (epoch + status) without introducing a synthetic round-trip.(p)subscribe-during-connectrace (infocommand issued after client enters subscriber mode #2037) and did not add a guard for the connection closing mid-handshake.Regression test
test/functional/connection.ts—enableReadyCheck: false → keeps reconnecting after the connection is closed during client setup (#2099).The test uses
MockServerto deterministically reproduce the race:CLIENT SETNAMEis in flight (flags.hang = true; socket.destroy()).enableReadyCheck: false,disableClientInfo: true,connectionName: "test-2099",maxRetriesPerRequest: 0,retryStrategy: () => 50.maxRetriesPerRequest: 0is what makes the failure 100% deterministic — it forcescloseHandler()to callflushQueue()on every close (retryAttempts % (0 + 1) === 0is always true), which is what surfaces the stale.finally()against the dead connection. With the default of 20, the same scenario only triggers every 21st failed attempt, which is why the issue reporter saw it intermittently when starting/stopping Redis."ready"event is only emitted on a real reconnection (secondconnectaccepted by the mock server). Without the fix, the client wedges in"ready"on the dead first connection and the test times out.Test plan
npm run test:js— 713 passing.npm run test:cluster— 17 passing.lib/redis/event_handler.tsandtest/functional/connection.ts.Note
Medium Risk
Touches core connect/ready/reconnect timing in event_handler.ts; behavior change is narrowly scoped to stale callbacks, with a targeted regression test.
Overview
Fixes a reconnect wedge when
enableReadyCheck: falseand the socket dies during the post-connectCLIENThandshake (SETNAME/SETINFO).In
connectHandler, thePromise.all(clientCommandPromises).finally()path could still callreadyHandler()aftercloseHandlerhad already moved the client toreconnectingand scheduled a retry—especially when in-flight setup commands are flushed (e.g.maxRetriesPerRequest). That stale callback marked a dead connectionreadyand reset retry state, so the client stopped reconnecting (#2099).The change bails out of that
.finally()unlessconnectionEpochstill matches andstatus === "connect", mirroring guards already used for AUTH and theenableReadyCheckready-check callback. A new functional test drops the first mock connection mid-CLIENT SETNAMEand asserts a second connection reachesready.Reviewed by Cursor Bugbot for commit ac232d3. Bugbot is set up for automated code reviews on this repo. Configure here.