Skip to content

fix(redis): keep reconnecting when connection closes during client setup (#2099) - #2123

Merged
PavelPashov merged 2 commits into
redis:mainfrom
ranjeetcao:fix/reconnect-when-enableReadyCheck-false
Jun 8, 2026
Merged

PavelPashov merged 2 commits into
redis:mainfrom
ranjeetcao:fix/reconnect-when-enableReadyCheck-false

Conversation

@ranjeetcao

@ranjeetcao ranjeetcao commented May 31, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #2099 — when enableReadyCheck: false, the client can permanently stop reconnecting after the socket closes during the CLIENT SETNAME / CLIENT SETINFO handshake.

connectHandler() (lib/redis/event_handler.ts) calls readyHandler() from Promise.all(clientCommandPromises).finally() without validating that the connection is still live. If the socket closes during that window and closeHandler() reaches the maxRetriesPerRequest threshold (default 20), it flushes the in-flight handshake commands. The individual handshake promises swallow rejection via .catch(noop), so the flush settles Promise.all and runs the now-stale .finally(). By then closeHandler() has already moved status to "reconnecting" and scheduled a reconnect; the stale callback overwrites it with "ready" and resets retryAttempts. The pending connect() 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 the enableReadyCheck: false readiness emission asynchronous. Detailed root-cause analysis is in the issue comment.

Fix

Guard the .finally() callback the same way the enableReadyCheck: true ready-check callback (line 136) already is, plus a status check:

if (
  connectionEpoch !== self.connectionEpoch ||
  self.status !== "connect"
) {
  return;
}

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:

  • connectionEpoch guard mirrors the existing guards in the AUTH callback (event_handler.ts:29) and the _readyCheck callback (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 because connectionEpoch is only incremented inside _connect() (Redis.ts:210). Between closeHandler() 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.
  • No behavior change on the live path: when the connection is still healthy, both connectionEpoch === self.connectionEpoch and self.status === "connect" hold, so readyHandler() is called exactly as before.
  • No change to closeHandler(), _connect(), flushQueue(), or readyHandler(): the bug is purely that a stale callback runs; the surrounding state machine already does the right thing on its own.
  • Symmetry with the enableReadyCheck: true branch: that branch is naturally protected because _readyCheck() sends INFO and a dead socket cannot answer, and additionally re-checks connectionEpoch at line 136. The fix gives the enableReadyCheck: false branch equivalent protection (epoch + status) without introducing a synthetic round-trip.
  • fix: emit connect once all handshake commands have been resolved #2040 is not affected: it touched the same handshake-command flow but for a different (p)subscribe-during-connect race (info command 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 MockServer to deterministically reproduce the race:

  • First connection: server destroys the socket while CLIENT SETNAME is in flight (flags.hang = true; socket.destroy()).
  • Client config: enableReadyCheck: false, disableClientInfo: true, connectionName: "test-2099", maxRetriesPerRequest: 0, retryStrategy: () => 50.
  • maxRetriesPerRequest: 0 is what makes the failure 100% deterministic — it forces closeHandler() to call flushQueue() on every close (retryAttempts % (0 + 1) === 0 is 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.
  • Assertion: a "ready" event is only emitted on a real reconnection (second connect accepted by the mock server). Without the fix, the client wedges in "ready" on the dead first connection and the test times out.

Test plan

  • New regression test passes with the fix; fails without it (verified locally).
  • Full functional suite passes: npm run test:js — 713 passing.
  • Cluster suite passes: npm run test:cluster — 17 passing.
  • No changes outside lib/redis/event_handler.ts and test/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: false and the socket dies during the post-connect CLIENT handshake (SETNAME / SETINFO).

In connectHandler, the Promise.all(clientCommandPromises).finally() path could still call readyHandler() after closeHandler had already moved the client to reconnecting and scheduled a retry—especially when in-flight setup commands are flushed (e.g. maxRetriesPerRequest). That stale callback marked a dead connection ready and reset retry state, so the client stopped reconnecting (#2099).

The change bails out of that .finally() unless connectionEpoch still matches and status === "connect", mirroring guards already used for AUTH and the enableReadyCheck ready-check callback. A new functional test drops the first mock connection mid-CLIENT SETNAME and asserts a second connection reaches ready.

Reviewed by Cursor Bugbot for commit ac232d3. Bugbot is set up for automated code reviews on this repo. Configure here.

…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
@jit-ci

jit-ci Bot commented May 31, 2026

Copy link
Copy Markdown

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>
@PavelPashov

Copy link
Copy Markdown
Contributor

@ranjeetcao Thanks for the PR. I’ll take a look.

@PavelPashov
PavelPashov merged commit f9a66bc into redis:main Jun 8, 2026
19 checks passed
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.
@github-actions

Copy link
Copy Markdown

🎉 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.
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 6.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sometimes the connection is not reestablished when enableReadyCheck is false

2 participants