Skip to content

fix(redis): clear commandTimeout timers of commands stranded on disconnect - #2169

Open
abhijeet117 wants to merge 4 commits into
redis:mainfrom
abhijeet117:fix/command-timeout-offline-flush
Open

abhijeet117 wants to merge 4 commits into
redis:mainfrom
abhijeet117:fix/command-timeout-offline-flush

Conversation

@abhijeet117

@abhijeet117 abhijeet117 commented Aug 26, 2026 •

Copy link
Copy Markdown

Summary

With commandTimeout set, a per-command timer armed by sendCommand() is cleared only when the command settles. If disconnect() runs while the client is still connecting or waiting to reconnect, no stream event ever fires, so queued commands keep live timers that later reject with "Command timed out" even though they were never sent. disconnect() now reuses its existing closeHandler cleanup in those two states, rejecting stranded commands with "Connection is closed." and clearing their timers, as already done for the wait status and stream close. Fixes #1535. Related but independent from #2131.

Testing

  • New regression tests in test/functional/commandTimeout.ts fail on main with "Command timed out" and pass with this fix.
  • helpers + unit + commandTimeout suites: 402 passing on main, 404 with this PR, 0 failures in both runs.
  • eslint and tsc build pass.

Checklist

  • bug reproduced before fix
  • root cause identified
  • bug fixed
  • tests passed

Note

Medium Risk
Touches core connection teardown and reconnect scheduling; changes are narrow but incorrect handling could leave clients reconnecting after manual close or mishandle queued commands.

Overview
Fixes #1535: with commandTimeout set, commands queued while the client is still connecting or reconnecting could keep armed timers after disconnect() because no stream close event ran cleanup. Those timers later rejected with "Command timed out" instead of "Connection is closed." and could keep the event loop alive.

disconnect() now invokes closeHandler when there is no live stream to flush queues (no stream yet, destroyed stream during retry, or reconnecting), matching behavior already used for wait.

Connect/close race hardening: the connector callback captures connectionEpoch and ignores stale results after disconnect; late streams are destroyed. connectHandler bails unless status is connecting; closeHandler bails if status is already end so intentional shutdown does not double-emit end or schedule reconnect. Failed connect paths only call setStatus("end") when not already ended.

Regression coverage added in test/functional/commandTimeout.ts (hanging connector, reconnecting disconnect, delayed connect, refused port + single end, disconnect vs stream destroy).

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

…nnect

disconnect() flushed pending queues only when the client was still in the wait status. When disconnecting while a connection attempt was still pending or while waiting to reconnect, no stream event ever fired, so commands parked in the offline queue kept their armed commandTimeout timers and later rejected with Command timed out even though they were never sent.

Run the same closeHandler cleanup in those states so queued commands are rejected with Connection is closed. and every pending timer is cleared.

Fixes redis#1535

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a73cb93376

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/Redis.ts Outdated
if (
!reconnect &&
this.status !== "end" &&
(!this.stream || this.status === "reconnecting")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Treat retry connection attempts as streamless

After a client has connected once, this.stream is never cleared, so during a subsequent retry it can have status connecting while still referencing the previous closed socket. If disconnect() is called while that retry is waiting on Sentinel resolution or a custom connector whose connect() promise is hung, this condition is false, connector.disconnect() cannot produce another close event from the old socket, and closeHandler never flushes the offline queue; queued commands therefore still reject with Command timed out and the client may remain connecting. Synchronous cleanup should also cover connecting with a stale or destroyed stream, or the stream reference should be cleared when it closes.

Useful? React with 👍 / 👎.

Comment thread lib/Redis.ts

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b18fc0fa5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread lib/Redis.ts
Comment on lines +410 to +412
(!this.stream ||
this.stream.destroyed ||
this.status === "reconnecting")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Defer cleanup when the socket still owes a close event

When a socket is destroyed but has not yet emitted close—for example, when an application calls disconnect() from an error listener after ECONNREFUSED—this predicate invokes closeHandler synchronously even though the registered socket close handler will still run. The first invocation consumes manuallyClosing and ends the client; the second sees manuallyClosing === false, applies the default retry strategy, and reconnects despite the explicit disconnect. Make close cleanup idempotent per connection generation, or distinguish a stream whose close event was already delivered from one that is merely destroyed.

Useful? React with 👍 / 👎.

Comment thread lib/Redis.ts
this.stream.destroyed ||
this.status === "reconnecting")
) {
eventHandler.closeHandler(this)();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Invalidate the pending connector attempt before reopening

If disconnect() reaches this synchronous close while connector.connect() is pending, the client immediately becomes end, so a same-tick connect() starts a second attempt before the first settles. Since _connect() does not capture and validate its attempt epoch, a late success can install handlers for an obsolete socket, while a late failure runs the shared flushQueue() and resets the surviving attempt to end; the standalone connector's shared connecting flag can also revive its canceled next-tick attempt after the second call sets that flag again. Invalidate and discard/close superseded completions, following the captured-epoch pattern used by Cluster.connect().

Useful? React with 👍 / 👎.

Comment thread lib/Redis.ts

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit b14cefc. Configure here.

Comment thread lib/Redis.ts
@PavelPashov

Copy link
Copy Markdown
Contributor

@abhijeet117, thank you for the PR. Could you please check why the CI checks are failing?

@abhijeet117

Copy link
Copy Markdown
Author

@PavelPashov I reproduced the stale connect race and fixed it. The targeted commandTimeout suite passes locally; the current CI matrix remains red due the repo environment failing unrelated TypeScript DOM/lib setup, not the disconnect fix itself.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3636a94ae

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +367 to +369
if (self.status === "end") {
debug("skip closing because the client is already in the end status");
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Ignore close events from superseded streams

When a destroyed old stream has queued its close event, disconnect() can synchronously move the client to end, after which an immediate connect() moves it back to connecting before that event runs. This guard then no longer recognizes the old handler as stale: it executes the retry path, changes the status to reconnecting, and causes the new attempt's epoch callback to reject and destroy its stream. Although the new callback epoch check handles stale connection completions, this fresh path shows that close handlers also need to be bound to their stream or connection generation.

Useful? React with 👍 / 👎.

@PavelPashov

Copy link
Copy Markdown
Contributor

@PavelPashov I reproduced the stale connect race and fixed it. The targeted commandTimeout suite passes locally; the current CI matrix remains red due the repo environment failing unrelated TypeScript DOM/lib setup, not the disconnect fix itself.

Thanks for checking. CI still reports two failures. Could you please investigate and rerun the full test suite?

@UnexpectedLobster

Copy link
Copy Markdown

Another real-world case for the disconnect() whilereconnecting part of this PR, not tied to commandTimeout: the blocking command itself never settles, so a BullMQ Worker.close() hangs forever.

Chain: BullMQ's read-timeout watchdog (worker.js, bclient.disconnect(!this.closing)) drops the blocking client while the event loop was blocked for a couple of seconds. The client is now in reconnecting with its retry timer armed and the bzpopmin kept in prevCommandQueue. Worker.close() then calls blockingConnection.disconnect(false), which clears the retry timer and calls connector.disconnect() on a socket that is already gone. No stream event will ever fire, the client never reaches end, the bzpopmin promise stays pending, and close() waits on it forever. BullMQ documented the same ioredis behavior in taskforcesh/bullmq#4585 and worked around it in its watchdog only (taskforcesh/bullmq#4586), not in close().

Standalone repro, no BullMQ, against a local Redis:

const Redis = require("ioredis");
const { once } = require("node:events");

(async () => {
  const redis = new Redis({ retryStrategy: () => 60_000 });
  await once(redis, "ready");
  const blocking = redis.blpop(`repro:${Date.now()}`, 0);
  redis.disconnect(true);
  await once(redis, "reconnecting");
  redis.disconnect();
  const outcome = await Promise.race([
    blocking.then(() => "blpop resolved", (err) => `blpop rejected: ${err.message}`),
    new Promise((resolve) => setTimeout(resolve, 3_000, "still pending after 3s (hang)")),
  ]);
  console.log(`status=${redis.status} — ${outcome}`);
  process.exit(0);
})();
  • ioredis 5.10.1: status=reconnecting — still pending after 3s (hang)
  • this PR's head (e3636a9, npm ci && npm run build): status=end — blpop rejected: Connection is closed.

One gap remains on the PR head. closeHandler from ready saves prevCommandQueue = commandQueue by reference, and the terminal close() only reaches those commands through flushQueue() while the two are still the same object. If a reconnect attempt gets a socket but fails before ready, connectHandler runs resetCommandQueue() and un-aliases them; a later disconnect() then ends the client without settling the commands parked in prevCommandQueue (a later manual connect() would also replay them). Same repro with that step simulated:

  redis.disconnect(true);
  await once(redis, "reconnecting");
  redis.resetCommandQueue(); // what connectHandler does on a reconnect that fails before "ready"
  redis.disconnect();
  • this PR's head: status=end prevCommandQueue=1 — still pending after 3s (hang)

Minimal fix in close() of lib/redis/event_handler.ts, verified to settle the second repro on the PR head (and to keep the first one green):

function close() {
  self.setStatus("end");
  self.flushQueue(new Error(CONNECTION_CLOSED_ERROR_MSG));
  if (self.prevCommandQueue) {
    let item;
    while ((item = self.prevCommandQueue.shift())) {
      item.command.reject(new Error(CONNECTION_CLOSED_ERROR_MSG));
    }
    self.prevCommandQueue = null;
  }
}

I can either fold this into #2169 or open a small follow-up PR once it merges.

Disclaimer: fix written with Claude Code.

@PavelPashov

Copy link
Copy Markdown
Contributor

I can either fold this into #2169 or open a small follow-up PR once it merges.

@UnexpectedLobster please open a small follow-up once this merges.

@Thyregud

Thyregud commented Oct 2, 2026

Copy link
Copy Markdown

This PR also fixes a second symptom of the same gap, one that needs no commandTimeout. I'm adding it here because it
is what makes downstream shutdowns hang.

On 6.0.0, disconnect() on a client whose status is reconnecting clears the retry timer and calls
connector.disconnect() on a stream that has already closed. Nothing runs the close handler, so:

  • no end is emitted, and the status stays reconnecting for good;
  • commands queued while reconnecting never settle. That is with default options: once the retry timer is gone,
    maxRetriesPerRequest never flushes them either;
  • the client does not reconnect when Redis comes back.

Anything that waits for end after disconnect() hangs. bullmq's Worker.close() does: taskforcesh/bullmq#4656,
taskforcesh/bullmq#4861.

Versions: ioredis 6.0.0 (disconnect() is the same on main today), standalone Redis 7.4.10, Node 24.14.1 / 24.20.0
and Bun 1.4.2, on Windows 11 and Linux.

Repro (default options; a TCP relay stands in for Redis dying):

'use strict';
// ioredis 6.0.0: disconnect() while "reconnecting" never emits "end" and never settles queued commands.
//   REDIS_PORT=6379 node ioredis-repro-compact.cjs
const net = require('node:net');
const Redis = require('ioredis');

const PORT = Number(process.env.REDIS_PORT || 6379);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// A TCP relay in front of Redis: stopping it is what a client sees when Redis dies.
const sockets = new Set();
const relay = net.createServer((c) => {
  const u = net.connect(PORT, '127.0.0.1');
  for (const s of [c, u]) (sockets.add(s), s.on('error', () => {}));
  c.pipe(u).pipe(c);
});
const listen = (port) => new Promise((r) => relay.listen(port, '127.0.0.1', r));

function watch(redis, pending) {
  const seen = { end: false, cmd: 'pending' };
  redis.once('end', () => (seen.end = true));
  pending.then(() => (seen.cmd = 'resolved'), (e) => (seen.cmd = `rejected (${e.message})`));
  return seen;
}

(async () => {
  // Control: Redis up, a BLPOP in flight, then disconnect().
  const a = new Redis({ port: PORT });
  a.on('error', () => {});
  await new Promise((r) => a.once('ready', r));
  const ca = watch(a, a.blpop('never-pushed', 0));
  await sleep(100);
  a.disconnect();
  await sleep(1000);
  console.log(`control: status=${a.status} end=${ca.end} BLPOP ${ca.cmd}`);

  // Redis drops; disconnect() while the client is reconnecting (retry timer armed, no socket).
  await listen(0);
  const port = relay.address().port;
  const b = new Redis({ port });
  b.on('error', () => {});
  await new Promise((r) => b.once('ready', r));
  const parked = new Promise((r) =>
    b.once('reconnecting', () => {
      const before = b.status;
      const cb = watch(b, b.get('k')); // queued in the offline queue
      b.disconnect();
      r({ before, cb });
    }),
  );
  for (const s of sockets) s.destroy();
  relay.close();
  const { before, cb } = await parked;
  await sleep(10_000);
  console.log(`outage:  status=${b.status} (was ${before} at disconnect()) end=${cb.end} GET ${cb.cmd}, 10 s after disconnect()`);
  await listen(port);
  await sleep(10_000);
  console.log(`outage:  status=${b.status} end=${cb.end} GET ${cb.cmd}, 10 s after Redis came back`);
  process.exit(0);
})();

Actual. The output is the same on Node and Bun, on Windows and Linux. A real docker stop/docker start in place
of the relay gives the same result:

control: status=end end=true BLPOP rejected (Connection is closed.)
outage:  status=reconnecting (was reconnecting at disconnect()) end=false GET pending, 10 s after disconnect()
outage:  status=reconnecting end=false GET pending, 10 s after Redis came back

Expected is what the wait status already gets: status=end end=true GET rejected (Connection is closed.).

Cause (built/Redis.js:247-261): only wait goes through closeHandler. In reconnecting, the stream's
once("close") listener (built/Redis.js:224-226) has already fired, so connector.disconnect() → stream.end()
emits nothing. The close() in closeHandler (built/redis/event_handler.js:351-354, setStatus("end") plus
flushQueue) is never reached.

I applied this PR's reconnecting branch as a local patch: after disconnect(), run closeHandler when the status
was reconnecting. With it, the same script prints:

(ioredis disconnect() patched as in PR #2169)
control: status=end end=true BLPOP rejected (Connection is closed.)
outage:  status=end (was reconnecting at disconnect()) end=true GET rejected (Connection is closed.), 10 s after disconnect()
outage:  status=end end=true GET rejected (Connection is closed.), 10 s after Redis came back

With the same patch, bullmq's Worker.close() in that state resolves in 3 ms instead of never. One heads-up for
whoever lands this: once end arrives, an in-flight bullmq reconnect turns it into connect(). That is bullmq's to fix
(taskforcesh/bullmq#4861) and not a reason to hold this PR.

@PavelPashov

Copy link
Copy Markdown
Contributor

@Thyregud @UnexpectedLobster Thanks for the detailed reproductions. Since #2169 has stalled, could one of you open a focused replacement PR for disconnect() while reconnecting, including cleanup of pending commands retained across a failed reconnect?
Please keep it independent of commandTimeout and timer changes, add regression tests for both cases, and link back to this PR.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

With commandTimeout option set, active timers are not cleared on client disconnection

4 participants