Skip to content

[oss-candidate] fix: reject stashed commands when the client never becomes ready again - #3

Closed
askalf wants to merge 4 commits into
mainfrom
fix/flush-prev-command-queue
Closed

askalf wants to merge 4 commits into
mainfrom
fix/flush-prev-command-queue

Conversation

@askalf

@askalf askalf commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • flushQueue() walks offlineQueue and commandQueue only. Commands that were in flight when a ready connection dropped are moved to a third queue, prevCommandQueue (lib/redis/event_handler.ts:379-381), and only readyHandler drains that stash (lib/redis/event_handler.ts:509-532).
  • connectHandler calls resetCommandQueue(), so once a reconnect attempt starts, the stash is unreachable from every path except reaching "ready" again. A client that never gets there leaves those user command promises pending forever.
  • Four real exits hit this: disconnect() mid-reconnect, retryStrategy returning a non-number, a connector failure on the reconnect attempt, and the maxRetriesPerRequest flush — the last of which README:893 documents as "all pending commands will be flushed with an error every 20 retry attempts. That makes sure commands won't wait forever when the connection is down." The stashed commands do wait forever, so this is also docs-vs-code drift.
  • Fix: drain prevCommandQueue inside the existing if (options.commandQueue) block of flushQueue, rejecting with the same error as the rest of that queue, and declare the field that until now was only ever set untyped from event_handler. The drain is skipped on flushes the client can still come back from: recoverFromFatalError ends in disconnect(true), which reconnects rather than ending, so it opts out via a new prevCommandQueue flush option and leaves the stash for readyHandler to resend.
  • Direct follow-on to merged PR fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194 (d95d05a, the base commit here), whose own commit message states "flushQueue only walks offlineQueue and commandQueue, so nothing could settle those promises afterwards" — that statement is still true for every exit other than the autoResendUnfulfilledCommands: false case fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194 fixed.

Current head is 413faca (18 lines across lib/Redis.ts + lib/DataHandler.ts, 8 tests × 2 protocols = 16 cases). Verbatim transcripts of both arms at this head:

$ # BASE — lib/Redis.ts + lib/DataHandler.ts at d95d05a, current 8-test file applied
$ git checkout d95d05a -- lib/Redis.ts lib/DataHandler.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  unfulfilled commands of a client that never becomes ready again (RESP3)
    1) rejects them when the user disconnects mid-reconnect
    2) rejects them when the retry strategy gives up
    3) rejects them once maxRetriesPerRequest is reached
    ✔ (control) still resends them when the reconnect succeeds (65ms)
    4) rejects every stashed command, not just the first
    ✔ (control) keeps them across a fatal protocol error so a later reconnect can resend them (123ms)
    5) rejects them when a fatal protocol error is followed by the client ending
    6) rejects a command stashed after an earlier resend emptied the stash

  unfulfilled commands of a client that never becomes ready again (RESP2)
    7) rejects them when the user disconnects mid-reconnect
    8) rejects them when the retry strategy gives up
    9) rejects them once maxRetriesPerRequest is reached
    ✔ (control) still resends them when the reconnect succeeds (63ms)
    10) rejects every stashed command, not just the first
    ✔ (control) keeps them across a fatal protocol error so a later reconnect can resend them (124ms)
    11) rejects them when a fatal protocol error is followed by the client ending
    12) rejects a command stashed after an earlier resend emptied the stash


  4 passing (10s)
  12 failing

  1) unfulfilled commands of a client that never becomes ready again (RESP3)
       rejects them when the user disconnects mid-reconnect:

      AssertionError: expected 'pending' to equal 'rejected: Connection is closed.'
      + expected - actual

      -pending
      +rejected: Connection is closed.

      at Context.<anonymous> (test/unit/unfulfilledCommands.ts:105:37)

  11) unfulfilled commands of a client that never becomes ready again (RESP2)
       rejects them when a fatal protocol error is followed by the client ending:
     Error: timed out waiting for the stashed command to settle
      at waitFor (test/unit/unfulfilledCommands.ts:65:13)

The 4 passes are exactly the two declared controls × 2 protocols; every non-control case fails on base.

$ # FIXED — head 413faca
$ git checkout 413faca -- lib/Redis.ts lib/DataHandler.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  unfulfilled commands of a client that never becomes ready again (RESP3)
    ✔ rejects them when the user disconnects mid-reconnect (175ms)
    ✔ rejects them when the retry strategy gives up (87ms)
    ✔ rejects them once maxRetriesPerRequest is reached (69ms)
    ✔ (control) still resends them when the reconnect succeeds (64ms)
    ✔ rejects every stashed command, not just the first (106ms)
    ✔ (control) keeps them across a fatal protocol error so a later reconnect can resend them (127ms)
    ✔ rejects them when a fatal protocol error is followed by the client ending (66ms)
    ✔ rejects a command stashed after an earlier resend emptied the stash (171ms)

  unfulfilled commands of a client that never becomes ready again (RESP2)
    ✔ rejects them when the user disconnects mid-reconnect (105ms)
    ✔ rejects them when the retry strategy gives up (83ms)
    ✔ rejects them once maxRetriesPerRequest is reached (64ms)
    ✔ (control) still resends them when the reconnect succeeds (63ms)
    ✔ rejects every stashed command, not just the first (106ms)
    ✔ (control) keeps them across a fatal protocol error so a later reconnect can resend them (126ms)
    ✔ rejects them when a fatal protocol error is followed by the client ending (64ms)
    ✔ rejects a command stashed after an earlier resend emptied the stash (166ms)


  16 passing (2s)

Upstream

  • Repo: redis/ioredis
  • Default branch: main
  • Base sha: d95d05a964be3687b01381224753f82c23427177 (fix: reject unfulfilled commands dropped on reconnect (#2194))
  • Current head: 413facae537a95b8182cd98411608e6a2fe0d3de
  • Files: lib/Redis.ts — flushQueue() (:1015-1053), recoverFromFatalError() (:817-828), and the new prevCommandQueue field declaration (:130); lib/DataHandler.ts — the FlushQueueOptions type (:37-44)
  • Test: test/unit/unfulfilledCommands.ts (new, 406 lines, 8 tests × 2 protocols = 16 cases)
  • Related but untouched: lib/redis/event_handler.ts — closeHandler (:375-382) stashes, readyHandler (:509-532) drains

Bug

Trigger. A client reaches "ready", a command is sent and is still awaiting its reply, and the socket drops. closeHandler sees prevStatus === "ready" and moves the live commandQueue into self.prevCommandQueue (event_handler.ts:379-381). A reconnect is scheduled; connectHandler runs self.resetCommandQueue(), replacing commandQueue with a fresh empty Deque. From this moment the stash is referenced only by prevCommandQueue, and the only code that reads it is readyHandler.

Wrong outcome. If the client never reaches "ready" again, nothing ever settles those promises. flushQueue() — the function whose entire job is "settle everything with an error because we are giving up" — does not know the queue exists. The user's await redis.get("foo") never resolves and never rejects; it hangs for the lifetime of the process, holding whatever it closes over.

Blast radius. Every standalone/Sentinel user who calls a command, loses the connection, and then ends the client before it recovers. Concretely:

exit call site terminal?
redis.disconnect() while reconnecting event_handler.ts:429 (close()) yes — stash drained
retryStrategy returns a non-number (give up) event_handler.ts:429 (close()) yes — stash drained
maxRetriesPerRequest reached, still retrying event_handler.ts:421 yes by contract (README:893) — stash drained
connector fails on the reconnect attempt Redis.ts:281 yes (setStatus("end")) — stash drained
fatal reply/handshake error after a reconnect Redis.ts:822 (recoverFromFatalError) no — reconnects, stash preserved

Graceful shutdown is the common one: a service that drains by calling disconnect() after a network blip leaves a promise that Promise.all will wait on until the process is killed. It is invisible in logs — no error is emitted, because no error is delivered anywhere.

The maxRetriesPerRequest row is the documented contradiction. README:893: "By default, all pending commands will be flushed with an error every 20 retry attempts. That makes sure commands won't wait forever when the connection is down." A command in flight at the moment of the drop is exactly the one that does wait forever.

Repro

test/unit/unfulfilledCommands.ts is the repro; it needs no Redis server (MockServer only). Against the unmodified base, with the current 8-test file in place:

$ git -C <worktree> log --oneline -1 d95d05a
d95d05a fix: reject unfulfilled commands dropped on reconnect (#2194)

$ git checkout d95d05a -- lib/Redis.ts lib/DataHandler.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  4 passing (10s)
  12 failing

The 4 passes are exactly the two declared controls × 2 protocols; the full per-test listing and failure text is in the ## Summary block above. Eight of the twelve failures read expected 'pending' to equal 'rejected: Connection is closed.' (or the three-element eql form) — the promise is observably still pending after the client has reached status "end". The maxRetriesPerRequest and fatal-then-ending cases time out in waitFor because the promise never settles at all.

The shape in user terms:

const redis = new Redis({ port, retryStrategy: () => 50 });
const p = redis.get("foo");   // in flight when the socket dies
// ... socket drops, reconnect starts ...
redis.disconnect();
await p;                      // base: hangs forever. fixed: rejects "Connection is closed."

Fix

lib/DataHandler.ts, 4 lines — a new opt-out on the existing options type:

 export type FlushQueueOptions = {
   offlineQueue?: boolean;
   commandQueue?: boolean;
+  // Commands stashed when a ready connection dropped. Only a flush the client
+  // cannot come back from settles them; a flush that is followed by another
+  // reconnect attempt leaves them for the ready handler to resend.
+  prevCommandQueue?: boolean;
 };

lib/Redis.ts, 14 lines:

   private offlineQueue: Deque;
+  private prevCommandQueue: Deque<CommandItem> | null = null;
     options: FlushQueueOptions
   ) {
-    this.flushQueue(err, options);
+    // `disconnect(true)` below keeps reconnecting, so a later attempt can still
+    // reach "ready" and resend the stashed commands. Rejecting them here would
+    // abandon them after a single failed attempt.
+    this.flushQueue(err, { ...options, prevCommandQueue: false });
     this.silentEmit("error", err);
     this.disconnect(true);
   }
     options = defaults({}, options, {
       offlineQueue: true,
       commandQueue: true,
+      prevCommandQueue: true,
     });
       // Commands that were in flight when a ready connection dropped are
       // stashed in `prevCommandQueue`, and only the ready handler drains it.
       // A reconnect replaces `commandQueue`, so a client that ends before
       // becoming ready again has no other chance to settle them.
-      if (this.prevCommandQueue) {
+      if (options.prevCommandQueue && this.prevCommandQueue) {
         while ((item = this.prevCommandQueue.shift())) {
           item.command.reject(error);
         }
         this.prevCommandQueue = null;
       }

Why this is minimal and correct:

  • Same block, same error, same idiom. The stash is part of the command queue conceptually — it is the previous command queue — so it belongs under options.commandQueue, rejected with the same error and with the same while ((item = q.shift())) loop the two queues above it use. No new error type, no signature change.
  • The stash is only settled on flushes the client cannot come back from. recoverFromFatalError ends in disconnect(true), which leaves manuallyClosing unset so closeHandler schedules another reconnect. A later attempt can still reach "ready" and resend, so that flush opts out. The four terminal exits (close() × 2, maxRetriesPerRequest, connector failure) keep draining. The new option defaults to true, so every existing caller — none of which sets it — is unchanged.
  • It cannot pre-empt the resend path. readyHandler is the only other consumer, and it runs on "ready". flushQueue with the drain enabled runs only when the client is ending or has hit its retry contract. Pinned by both controls.
  • Double-settling is unreachable. readyHandler's abort branch sets prevCommandQueue = null after rejecting (event_handler.ts:530); its resend branch shifts every item out. Either way the deque flushQueue can later see is empty or null.
  • The field declaration is not a drive-by. prevCommandQueue was assigned from event_handler.ts (which types self as any) and never declared on the class, so this.prevCommandQueue would not type-check in Redis.ts at all. Declaring it is the minimum required to read it, and it matches the neighbouring private offlineQueue: Deque;.

Alternatives rejected:

  • Drain it in connectHandler / resetCommandQueue(). That would reject on every reconnect attempt, destroying autoResendUnfulfilledCommands — the whole point of the stash is that it survives the attempt.
  • Gate on this.status === "end" inside flushQueue instead of an option. flushQueue is called before setStatus("end") at Redis.ts:281 and by the maxRetriesPerRequest path where the status is "reconnecting", so a status test would both miss terminal exits and be fragile to call ordering. The caller knows whether it is coming back; the callee does not.
  • Merge the stash back into commandQueue on close. The two queues have different semantics (one is resent on ready, one is not) and readyHandler distinguishes them; merging would change resend behaviour for everyone.
  • Fix only the maxRetriesPerRequest call site, the one with the doc contradiction. The same leak exists at the other terminal call sites; putting it in flushQueue fixes all of them with less code.
  • prevCondition has the same single-consumer shape and is also only cleared in readyHandler. It is a stale-state object, not a set of unsettled promises, so it leaks nothing a user can await. Out of scope — one bug per PR.

Test evidence

test/unit/unfulfilledCommands.ts (new, 406 lines): 8 tests × 2 protocols = 16 cases, all on the branch at head 413faca. Placement follows .github/CONTRIBUTING.md:108 ("Place unit tests in test/unit/ ... Follow nearby tests and reuse helpers from test/helpers/") and the PROTOCOLS = [3, 2] loop convention used by test/unit/resubscribe.ts. MockServer only — no Redis server needed.

The shared fixture brings a client to "ready", leaves a GET in flight (the mock server accepts it and never replies), then destroys the socket so closeHandler stashes it. Each test then picks a different way of never reaching "ready" again.

Tests 1-4 were written by the run that authored the fix (commits 0e9bad5, bd8edf9). Tests 5 and 8 were added by an independent adversarial verification run (commit c542f30). Tests 6 and 7 are from the upstream-review round (commit 413faca) and replace a test named "rejects them when the reconnect hits a fatal protocol error", which pinned exactly the behaviour the Codex review correctly identified as wrong. Every test below was run on both arms, per protocol, at the current head.

# test pins base d95d05a prev head c542f30 head 413faca
1 rejects them when the user disconnects mid-reconnect close() via disconnect(), Connection is closed. FAIL ('pending') pass pass
2 rejects them when the retry strategy gives up close() via retryStrategy → null FAIL ('pending') pass pass
3 rejects them once maxRetriesPerRequest is reached event_handler.ts:421 flush, MaxRetriesPerRequestError text FAIL (timeout, never settles) pass pass
4 (control) still resends them when the reconnect succeeds the fix does not pre-empt readyHandler's resend; asserts resolved: bar pass pass pass
5 rejects every stashed command, not just the first the while loop drains N>1 items, not only the head — three GETs in flight, asserts all three settle FAIL (['pending','pending','pending']) pass pass
6 (control) keeps them across a fatal protocol error so a later reconnect can resend them recoverFromFatalError is non-terminal: its flush must leave the stash. Asserts resolved: bar, and only the third connection ever answers GET pass FAIL (rejected: Unknown RESP type 64 "@". Please report this.) pass
7 rejects them when a fatal protocol error is followed by the client ending the same fatal flush, but retryStrategy then gives up — the close flush must settle the stash FAIL (timeout, never settles) FAIL (rejected: Unknown RESP type 64 "@"…) pass
8 rejects a command stashed after an earlier resend emptied the stash the reverse order of operations: a successful resend leaves prevCommandQueue non-null-but-empty, then a second drop re-stashes onto it and the client never recovers FAIL ('pending') pass pass

Two controls, both declared in their names, and they control for different things.

  • Test 4 asserts resolved: bar — a value only the resent command can produce, since the first connection never replies to GET. It cannot go green by the command being rejected, nor by the stash being silently dropped. It also exercises the fixed block against the non-null-but-empty deque readyHandler's resend branch leaves behind. Green on base and on both heads by construction: that is what it controls for.
  • Test 6 is green on base (base never drains the stash anywhere, so of course nothing is lost) but fails on the previous head c542f30, which is what makes it worth keeping rather than redundant with test 4. It is the test that discriminates the prevCommandQueue: false opt-out specifically — the regression this round fixes. Like test 4 it asserts resolved: bar, and the fixture guarantees only the third connection answers GET (first hangs, second replies unparseable garbage), so the assertion can only hold if the stash survived the fatal flush and was resent afterwards.

Tests 1-3, 5, 7 and 8 all fail on base in both protocols, so none of them is a further control.

Base arm at the current head's test file, checking out only the base source (git checkout d95d05a -- lib/Redis.ts lib/DataHandler.ts, restored after with git checkout 413faca -- …; git status --porcelain confirmed clean):

  4 passing (10s)
  12 failing

Previous-head arm — the same current test file against c542f30's source, isolating this round's change:

$ git checkout c542f30 -- lib/Redis.ts lib/DataHandler.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  12 passing (2s)
  4 failing

  1) unfulfilled commands of a client that never becomes ready again (RESP3)
       (control) keeps them across a fatal protocol error so a later reconnect can resend them:

      AssertionError: expected 'rejected: Unknown RESP type 64 "@". P…' to equal 'resolved: bar'
      + expected - actual

      -rejected: Unknown RESP type 64 "@". Please report this.
      +resolved: bar

      at Context.<anonymous> (test/unit/unfulfilledCommands.ts:290:37)

  2) unfulfilled commands of a client that never becomes ready again (RESP3)
       rejects them when a fatal protocol error is followed by the client ending:

      AssertionError: expected 'rejected: Unknown RESP type 64 "@". P…' to equal 'rejected: Connection is closed.'
      + expected - actual

      -rejected: Unknown RESP type 64 "@". Please report this.
      +rejected: Connection is closed.

      at Context.<anonymous> (test/unit/unfulfilledCommands.ts:336:37)

The 4 failures are exactly tests 6 and 7 × 2 protocols — both new tests discriminate this round's change on both protocols.

Head arm:

  16 passing (2s)

Per-assertion discrimination: every assertion in tests 1-3, 5, 6 and 7 is the only assertion in that test, so per-assertion discrimination equals per-test discrimination there. Test 5's single eql compares all three settlements at once and fails on base with all three still 'pending'. Test 8 carries one extra assertion before the discriminating one (expect(resent.settlement).to.equal("resolved: bar"), pinning that the first command really was resent so the empty-stash state is genuinely reached); on base that intermediate assertion passes and the final one fails, which is what the base transcript shows (failure at the later line, not the earlier one).

Tooling, all at head 413faca, following .github/CONTRIBUTING.md:49 ("Run npm run lint, npm run build, and the tests for your change"):

$ npx tsc --noEmit
rc=0

$ npx eslint ./lib test/unit/unfulfilledCommands.ts
✖ 23 problems (0 errors, 23 warnings)
rc=0
# all 23 are pre-existing @typescript-eslint/member-ordering and no-unused-vars
# warnings on lib/ files, present on clean base; 0 on the new test file.

$ npx prettier --check test/unit/unfulfilledCommands.ts
Checking formatting...
All matched files use Prettier code style!
rc=0

$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/DataHandler.ts"
  15 passing (64ms)

test/unit/DataHandler.ts is run because this round also touches lib/DataHandler.ts; it is green.

npm run format-check over lib/ is not clean, and is not clean on base either. Both touched lib/ files are prettier-dirty on the unmodified base, at code far from this diff:

$ git show d95d05a:lib/Redis.ts | npx prettier --check --parser typescript
(stdin)
rc=1
# pre-existing: the interceptHimportCommand call around :527

$ git show d95d05a:lib/DataHandler.ts | npx prettier --check --parser typescript
(stdin)
rc=1

$ npx prettier --parser typescript < lib/DataHandler.ts > /tmp/pretty-dh.ts; diff /tmp/pretty-dh.ts lib/DataHandler.ts
@@ -146,10 +146,7 @@
-    if (
-      this.redis.condition.protocol !== 3 &&
-      this.handleSubscriberReply(reply)
-    ) {
+    if (this.redis.condition.protocol !== 3 && this.handleSubscriberReply(reply)) {

The only block prettier objects to in lib/DataHandler.ts is at :146, more than a hundred lines from the :37 hunk this PR adds. So npx prettier --write on either file would reformat unrelated code — a drive-by this PR deliberately does not make. Both added hunks are themselves in prettier's preferred form.

Verification method

executed. Container, Node v24.19.0, mocha 11.7.6. Unit lane only — test/unit/ mocks the network. Functional and cluster tests need npm run docker:setup (real Redis on 6379 + cluster nodes 3000-3005), which this container cannot run; that lane is covered by the fork's CI instead.

Three independent passes plus an upstream review round. The run that wrote the fix executed tests 1-4 on both arms. A separate adversarial verification run rebuilt the ## Boundaries ledger from the diff alone (not from this body), added tests 5 and 8, and re-ran the whole file on both arms. This round responds to an upstream automated review (chatgpt-codex-connector, P2 on lib/Redis.ts:1047) that identified the non-terminal recoverFromFatalError flush; that finding was confirmed by execution — not merely by reading — via the previous-head arm above, where tests 6 and 7 fail on c542f30 and pass at 413faca.

Scope note on that review. The defect Codex raised is in this PR's own diff, not pre-existing on base: d95d05a never drained prevCommandQueue at all, so the premature drain on the fatal path was introduced here. It is therefore fixed in this PR rather than deferred to a follow-up.

Fork CI. askalf/ioredis has Actions enabled, so the upstream test:js workflow — unit and functional against live Redis servers — runs on the fork branch. At the current head 413faca, run 34903573093 completed 21/21 green:

$ gh pr checks 3 --repo askalf/ioredis
21 checks: 21 pass, 0 pending, 0 failing
# Node 20/22/24/26.x × Redis 8.2 / 8.4.0 / 8.8.0 / custom-debian / rs-7.4.0-v1,
# plus code coverage

Run: https://github.com/askalf/ioredis/actions/runs/34903573093. No non-green job. The functional lane matters here specifically: test/functional/ exercises reconnect behaviour against a real server, so a green run is evidence the flushQueue change and the new prevCommandQueue: false opt-out do not disturb the resend path in conditions the mocked unit test cannot create.

Historical, for earlier heads whose source differs from this one: bd8edf9 21/21 green (run 34877010651), c542f30 21/21 green (run 34892232492). Note that c542f30 was green in CI while still carrying the regression this round fixes — the upstream fatal-error path is not exercised by the existing functional suite, which is why tests 6 and 7 were added.

Nothing here is platform- or OS-specific; both RESP3 and RESP2 are exercised.

Prior art

$ gh search prs --repo redis/ioredis "prevCommandQueue" --limit 20
redis/ioredis  2194  merged  fix: reject unfulfilled commands dropped on reconnect       2026-09-14
redis/ioredis  2089  merged  feat: Implement `TracingChannel` support                    2026-05-26

$ gh search prs --repo redis/ioredis "flushQueue" --limit 20
redis/ioredis  2194  merged  fix: reject unfulfilled commands dropped on reconnect       2026-09-14
redis/ioredis  2169  open    fix(redis): clear commandTimeout timers of commands stranded on disconnect
redis/ioredis  2171  closed  feat: support async password generation for dynamic auth
redis/ioredis  2158  merged  fix(types): export ScanStreamOptions, RedisStatus and ClusterStatus
redis/ioredis  2123  merged  fix(redis): keep reconnecting when connection closes during client setup (#2099)
redis/ioredis   658  closed  feat: add "timeoutPerCommand" option to detect dead connection

$ gh search issues --repo redis/ioredis "prevCommandQueue" --limit 20
redis/ioredis  2193  closed  Command promise never settles after reconnect when autoResendUnfulfilledCommands is false
redis/ioredis  1718  closed  A very fast retry strategy hangs blocking commands forever
redis/ioredis   965  closed  Retry logic for ioredis response incorrect result
redis/ioredis   800  closed  commandQueue state is corrupted when connection closes while processing pipeline reply
redis/ioredis    42  closed  Unfulfilled commands sent on wrong database after reconnection

$ gh search issues --repo redis/ioredis "command promise never settles" --limit 10
redis/ioredis  2193  closed  ... (as above)

$ gh pr list --repo redis/ioredis --state open --limit 40
31 open PRs; 14 touch lib/Redis.ts (#2196 #2192 #2169 #2165 #2131 #2126 #2120 #2105 #2098 #2097 #2080 #2076 #2065 #2039)

Findings:

Policy

AGENTS.md:3 — "This file provides guidance to AI coding agents (Claude Code, Codex, Copilot, Cursor, Aider, etc.) when working with code in this repository." The repository explicitly anticipates AI coding agents. No AI/LLM/generated-content prohibition exists in AGENTS.md, .github/CONTRIBUTING.md, or CODE_OF_CONDUCT.md. No CLA, no DCO sign-off requirement, no changelog/changeset requirement. No .github/PULL_REQUEST_TEMPLATE* exists.

.github/CONTRIBUTING.md:26-32 requires maintainer agreement on scope before most work, with listed exceptions that may be submitted directly:

"You can submit a pull request directly for: ... Small, isolated bug fixes where the cause and solution are clear and a focused test demonstrates the fix."

This change qualifies on all three counts: 12 lines in one function, the cause is stated in the merged predecessor's own commit message, and the focused test demonstrates it. It is not a feature, API change, refactor, or performance change, so no prior issue is required.

.github/CONTRIBUTING.md:49 (step 7): "Run npm run lint, npm run build, and the tests for your change." — all three run and recorded under Test evidence.

.github/CONTRIBUTING.md:108: "Place unit tests in test/unit/ ... Follow nearby tests and reuse helpers from test/helpers/." — the test is in test/unit/ and uses test/helpers/mock_server.

Commands per AGENTS.md: single-file run is TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/foo.ts", used verbatim.

Disclosure facts for the operator

Plain facts, for you to word your own disclosure. This is not a draft disclosure body — it is the raw material for one.

What AI did, and what it did not:

  • An AI agent found the bug. Method: read the most recently merged fix in this surface (fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194, d95d05a), took the gap its own commit message described ("flushQueue only walks offlineQueue and commandQueue, so nothing could settle those promises afterwards"), and checked whether that statement still held for the other exits from the reconnect path. It did — fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194 fixed one exit, four others were left open.
  • The agent wrote 100% of the diff: the source changes in lib/Redis.ts and lib/DataHandler.ts (net +21/-1 across the branch) and the whole of test/unit/unfulfilledCommands.ts (+406/-0). No human wrote or edited a line of either file.
  • The agent wrote all four commit messages (0e9bad5991, bd8edf9c47, c542f306bd, 413facae53) and this entire facts sheet, which is also the PR body.
  • The agent executed everything reported in this document: base-arm, previous-head-arm and head-arm test runs, tsc --noEmit, eslint, prettier --check. Every console block here is copy-pasted from a real terminal, not reconstructed or paraphrased. Verification method for this candidate is executed, not static.
  • Not executed by the agent: the functional and cluster lanes, which need a live Redis server and Docker unavailable in its container. Those ran in the fork's own CI.
  • Commit identity on all commits is askalf <263217947+askalf@users.noreply.github.com>. There are no AI attribution trailers, no Co-Authored-By lines, and no model names anywhere in the branch history, the commit messages, the branch name, or the PR title — deliberately, because attribution is the operator's call to word, not the agent's.

Review status at the time of writing:

  • Two automated fleet reviews ran against head bd8edf9c (a gating review and an independent second-opinion review), and a third, independent adversarial verification run re-executed everything at head c542f30 and added two tests. The second-opinion lane re-derived the bug from the base code and re-ran the suite on both arms; the verification run rebuilt the boundary ledger from the diff alone.
  • The upstream PR received an automated review from chatgpt-codex-connector at head c542f30 (a COMMENTED verdict with one P2 inline on lib/Redis.ts:1047). That finding was valid, was reproduced by execution, and is fixed at head 413faca; tests 6 and 7 are the regression tests for it. This is worth stating plainly upstream — the previous head shipped a real regression on the fatal-error path, and it was an upstream reviewer, not our own lanes, that caught it.
  • No human being has reviewed the reasoning or the diff at the time of writing. If the upstream project expects human review before submission, that has not happened yet.

Facts relevant to the upstream project's own stance:

  • redis/ioredis carries an AGENTS.md whose first line is "This file provides guidance to AI coding agents (Claude Code, Codex, Copilot, Cursor, Aider, etc.) when working with code in this repository." The repository explicitly anticipates AI coding agents.
  • No AI/LLM/generated-content prohibition or disclosure requirement exists in AGENTS.md, .github/CONTRIBUTING.md, or CODE_OF_CONDUCT.md. There is no PR template asking the question, no CLA, and no DCO sign-off. So upstream imposes no mandatory disclosure wording — what you say, and whether you say it, is your decision, and these bullets are the facts to base it on.
  • The sibling PR from this same fork (redis/ioredis#2196) received an upstream bot review; nothing in that exchange raised AI provenance as an issue.

One scoping fact worth not misstating upstream:

Boundaries

One row per predicate, comparison, guard and loop the diff adds or changes. The diff adds no arithmetic, no index expression and no comparison operator; every added control-flow construct is listed. Rows 2a and 2b are new in the 413faca round.

# construct (added) boundary input behaviour of the fixed code pinned by
1 private prevCommandQueue … = null field never assigned (client never reached "ready", or reached it but had an empty commandQueue — event_handler.ts:379 only stashes if (self.commandQueue.length)) stays null; row 3 short-circuits; flushQueue behaves exactly as on base all 16 cases pass through flushQueue at least once before any stash exists (the lazyConnect connect + first drop), so the null path is exercised in every test
2 (enclosing, pre-existing) if (options.commandQueue) options.commandQueue === false new block skipped, stash untouched — same as base unreachable in-tree: the only caller passing FlushQueueOptions is DataHandler.ts:108, which passes { offlineQueue: false } and leaves commandQueue to defaults() → true; grep -rn "commandQueue: false" lib/ returns zero hits
2a options.prevCommandQueue — new defaults() key caller passes no options at all (event_handler.ts:421, :429, Redis.ts:281) defaults() fills true; stash drained, as before this round tests 1, 2, 3, 5, 8 — all reach flushQueue through the no-options callers
2b options.prevCommandQueue — explicit false recoverFromFatalError passes { ...options, prevCommandQueue: false } (the only in-tree false) stash preserved; disconnect(true) reconnects and readyHandler resends it. Spread order matters: prevCommandQueue comes last, so a caller could not accidentally re-enable it test 6 (control) — fails on c542f30, passes at 413faca. Test 7 pins the complementary case: same fatal flush, then a terminal close that does drain
2c options.prevCommandQueue — undefined from a partial options object DataHandler.ts:108 passes { offlineQueue: false }, which reaches recoverFromFatalError and is spread undefined is overwritten by the explicit false in row 2b before defaults() sees it, so the fatal path never falls back to true tests 6 and 7 — both go through exactly this call site
3 if (options.prevCommandQueue && this.prevCommandQueue) — truthiness of the field null (initial, or already drained by readyHandler's abort branch at event_handler.ts:530, or by a previous flushQueue) skipped, no-op row 1's coverage; and the maxRetriesPerRequest tests call flushQueue repeatedly as retries continue — the 2nd and later calls take the null path after the 1st drained it
4 same — truthiness empty Deque (the falsy-looking-but-valid case): readyHandler's resend branch (event_handler.ts:512-523) shifts every item out but does not null the field a Deque instance is always truthy, so the guard is entered; shift() returns undefined immediately, the loop body never runs, and the field is set to null. No command is rejected — correct, they were already resent test 4 (control) — its trailing redis.disconnect() runs flushQueue against exactly this empty-deque state, and resolved: bar would break if anything were rejected here
5 while ((item = this.prevCommandQueue.shift())) — loop entry stash holds N > 1 items every item rejected, not just the head; loop exits when shift() returns undefined test 5 — three GETs in flight, asserts all three settle. Base: all three still 'pending'
6 same — loop entry stash holds 1 item one reject(error), then shift() → undefined, loop exits tests 1-3, 7, 8 (the shared fixture leaves exactly one GET in flight)
7 same — loop entry stash holds 0 items zero iterations (see row 4) row 4 / test 4
8 same — re-stash onto an already-seen stash readyHandler resend empties the deque without nulling it, then a later drop pushes a new item into that same field the second flushQueue sees a non-null, non-empty deque and rejects the new item test 8 — the reverse order of operations. Base: 'pending'
9 same — loop condition on a falsy element a CommandItem that is itself falsy impossible: Deque here holds CommandItem objects pushed by sendCommand; objects are always truthy. Identical idiom to the two pre-existing loops at :1023 and :1034, so no new failure mode unreachable by construction; matches existing code
10 item.command.reject(error) command already settled (double-settle) unreachable: readyHandler's abort branch nulls the field after rejecting (:530) and its resend branch empties it (:512-523), so no path leaves a settled item in a non-null stash. Were it reachable, reject on a settled promise is a no-op by promise semantics unreachable, argued above; test 8 exercises the nearest reachable neighbour (resent-then-re-stashed) and shows no double-settle
11 this.prevCommandQueue = null — idempotency flushQueue called twice with the same stash 2nd call sees null and skips; no double-reject the maxRetriesPerRequest cases (test 3), which flush on every maxRetriesPerRequest + 1-th retry while the client keeps retrying
12 which error reaches the stash close() → Connection is closed. stash rejects with the same error the rest of commandQueue gets tests 1, 2, 5, 7, 8 asserting the exact message
13 which error reaches the stash maxRetriesPerRequest → MaxRetriesPerRequestError ditto test 3, asserting the full message text incl. the limit value
14 which error reaches the stash fatal reply error → Redis.ts:822 recoverFromFatalError(err, err, { offlineQueue: false }) — the other code path the fix claims to cover stash is NOT rejected here (row 2b); it survives for the reconnect. If that reconnect never succeeds, the following terminal flush rejects it with its error (Connection is closed.), not the fatal one tests 6 and 7 together: 6 pins survival-and-resend, 7 pins that the eventual terminal flush settles it with Connection is closed.
15 which error reaches the stash connector failure → Redis.ts:281 flushQueue(err) terminal (setStatus("end") follows); stash drained with the connector's error not directly tested. It funnels into the identical single block already pinned by rows 12-13 from three other call sites, and the block reads nothing about its caller. Flagged honestly rather than padded with a fourth fixture
16 protocol RESP3 (protocol: 3) identical; the reconnect handshake differs but the stash path does not every row above × RESP3
17 protocol RESP2 (protocol: 2) identical every row above × RESP2
18 topology Cluster out of scope: lib/cluster/index.ts has its own separate flushQueue (:1089) and no prevCommandQueue; the stash is a standalone/Sentinel concept owned by lib/redis/event_handler.ts unreachable from the changed code

Suggested upstream PR title

fix: settle commands stashed on reconnect when the client never becomes ready

In-flight commands are stashed in prevCommandQueue when a ready connection
drops, and only readyHandler drains that stash. A reconnect calls
resetCommandQueue(), so flushQueue - which walks offlineQueue and commandQueue
only - can no longer see them. Every path that ends the client before it
reaches ready again therefore leaves those command promises pending forever:
disconnect() during the reconnect, a retryStrategy that stops retrying, a
failing connector, and the maxRetriesPerRequest flush.

Drain prevCommandQueue from flushQueue as well, so the stash is settled with
the same error as the rest of the queue.
@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 14, 2026
@askalf
askalf marked this pull request as ready for review September 14, 2026 17:54

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: CHANGES REQUESTED — the OSS-candidate facts sheet is incomplete, so this is not yet ready for operator submission.

Blocking — PR body: ## Disclosure facts for the operator

Quoted PR-body lines:

## Disclosure facts for the operator
## Boundaries

The required disclosure section is empty: the next heading immediately follows it. OSS-candidate policy requires every listed facts-sheet section to be non-empty, including ## Disclosure facts for the operator, so the operator has no recorded facts on which to make an honest upstream disclosure decision. Populate that section with the applicable contribution/disclosure facts (or an explicit, truthful statement that no disclosure is required) before submission.

## Disclosure facts for the operator

[Record the applicable, factual disclosure information the operator needs for the upstream submission.]

What's good: I independently traced the base lifecycle: closeHandler stores a ready connection's in-flight queue in prevCommandQueue, while base flushQueue() only rejects offlineQueue and commandQueue; the added lib/Redis.ts:1043-1048 drain closes that gap without pre-empting the ready-handler resend path. The focused RESP2/RESP3 regression coverage is discriminating according to the supplied base/head evidence, the boundaries ledger addresses the new guard and loop, commit messages have no AI attribution, and all 21 reported CI checks are green at this head.

I reviewed the changed files, upstream base lifecycle context, OSS-candidate facts sheet, commit messages, and reported CI; I did not run the local test suite per review environment policy.

@sprayberry-secondread sprayberry-secondread 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: no blocking issues found; recommend READY, findings below are informational.

What I independently verified

  • Cloned the branch at head bd8edf9c and traced the bug mechanism from scratch: closeHandler (lib/redis/event_handler.ts:379-381) stashes in-flight commands into prevCommandQueue only when the previous status was "ready"; only readyHandler (event_handler.ts:509-532) ever drains it, on either its resend branch or its abort branch. connectHandler (event_handler.ts:156) calls resetCommandQueue(), which replaces commandQueue but never touches prevCommandQueue. Before this diff, flushQueue (lib/Redis.ts:1015-1050, pre-fix) only walked offlineQueue and commandQueue, so a client that starts a reconnect and then ends (via disconnect(), retryStrategy giving up, a connector failure, maxRetriesPerRequest, or recoverFromFatalError) left the stashed commands' promises permanently pending.
  • Confirmed all four non-readyHandler reconnect exits reach flushQueue with commandQueue truthy (the default): event_handler.ts:429 (close(), used both by manual disconnect() mid-reconnect and by retryStrategy returning non-number), event_handler.ts:421 (maxRetriesPerRequest), Redis.ts:281 (connector failure), and Redis.ts:822/DataHandler.ts:108 (recoverFromFatalError).
  • Ran the new suite myself: at head bd8edf9c, test/unit/unfulfilledCommands.ts is 8/8 passing. Checked out lib/Redis.ts at base d95d05a (pre-fix) with the same test file applied: 2 passing / 6 failing — the two passes are exactly the "(control) still resends them when the reconnect succeeds" case × RESP3/RESP2, and the six failures are the three real cases × two protocols, matching the PR body's own transcript exactly.
  • tsc --noEmit clean; eslint lib/Redis.ts test/unit/unfulfilledCommands.ts is 0 errors / 18 pre-existing member-ordering warnings unrelated to this diff.
  • gh pr checks 3 shows 21/21 green, including the functional lane against live Redis 8.2/8.4.0/8.8.0 across Node 20–26.

Fix quality

lib/Redis.ts:1043-1048:

if (this.prevCommandQueue) {
  while ((item = this.prevCommandQueue.shift())) {
    item.command.reject(error);
  }
  this.prevCommandQueue = null;
}

This lives inside the existing if (options.commandQueue) block, so it respects the one caller (DataHandler.ts:108, returnFatalError) that passes {offlineQueue: false} and leaves commandQueue at its true default — correct scoping, no new option needed. The while ((item = ...shift())) loop matches the exact idiom of the two pre-existing loops immediately above it (:1023, :1034), so it reads as native to the file rather than a bolted-on style.

One thing worth a maintainer's eye, not a blocker: the sibling abort branch in readyHandler (event_handler.ts:524-530) rejects with abortError(item.command) (an AbortError with command: {name, args} attached), while this new block rejects with the generic error passed into flushQueue (e.g. MaxRetriesPerRequestError, or new Error(CONNECTION_CLOSED_ERROR_MSG)). That's actually the correct choice here — flushQueue's commandQueue loop just above (:1034-1036) already uses the same generic error, and the stash is being folded into that same flush pass, so matching the local sibling (not the cross-file one) is the right call and keeps the error message consistent with everything else that settles in the same flushQueue invocation. I checked this isn't a real inconsistency; flagging only because a maintainer skimming past abortError usage elsewhere in the file might ask the same question, and the PR body doesn't pre-empt it.

Tests

The four new cases (disconnect mid-reconnect, retry strategy giving up, maxRetriesPerRequest, and the resend control) × two protocols are a reasonable boundary set. I separately confirmed the control is non-vacuous: it asserts resolved: bar, a value only producible by the second connection actually replying to the resent GET, so it can't go green by the command merely being rejected or dropped. I did not re-derive the "empty-but-non-null Deque" boundary claim (readyHandler's resend branch draining the deque without nulling it) beyond reading the code — it's consistent with what's there, and the control test's trailing disconnect() does exercise flushQueue against that post-resend state.

What's good

Small, scoped, single-file production diff with a matching test file; reuses the existing loop idiom and queue-flush structure rather than introducing a new abstraction. The "why now" argument (the base commit's own fix left this mechanism half-fixed) is well supported — d95d05a's commit message does say "flushQueue only walks offlineQueue and commandQueue, so nothing could settle those promises afterwards," which was still true for every exit besides the one it handled.

OSS-candidate maintainer's-eye notes

  • Recent merged history on this file (d95d05a/redis#2194, the direct predecessor) and its sibling PRs (redis#2187, redis#2196 chain) all ship a small lib/ diff plus a dedicated new/extended test file, fix: commit-type titles, and reference an issue via Closes #N in the body. This PR's fork title (fix: reject stashed commands when the client never becomes ready again) matches that shape; no upstream issue exists yet for this specific gap, which is an operator concern for submission (issue-first is the repo's stated policy for anything not "small, isolated... cause and solution are clear"), not a defect in the diff itself.
  • CONTRIBUTING.md places unit tests needing only a mock server in test/unit/, which is where the new file landed — consistent with test/unit/resubscribe.ts and other mock-server-based reconnect tests already there.
  • I could confirm the underlying bug against the base code myself (see verification above) — it is not a static-analysis artifact; the failing assertions are on the actual pending-promise state, not a reachability guess.
  • Boundary coverage looks complete for the four call sites flushQueue has (close()/disconnect, close()/retryStrategy giving up, maxRetriesPerRequest, connector failure, recoverFromFatalError) in the sense that the fix is inside the single shared function all five reach — it doesn't need five separate tests because the code path is identical past that point, and the PR body says so plainly for the two untested call sites (row 12) rather than padding with a redundant fixture. That's the right call and matches how redis#2194's own regression test covered its single new code path once.

SECOND READ: READY

@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — evidence-only, HEAD UNCHANGED at bd8edf9c47695b53a5a381d0dacd2d2b394cc904

Addressing the blocking finding on ## Disclosure facts for the operator from pullrequestreview-5201110337. No code change was needed or made: lib/Redis.ts and test/unit/unfulfilledCommands.ts are byte-identical to the reviewed head, and no commit was pushed.

The section was not empty at review time — with respect, the finding is factually wrong

The review states: "The required disclosure section is empty: the next heading immediately follows it." That was not the state of the PR body when the review was submitted. The body-edit history puts the section in place roughly four minutes earlier:

$ gh api graphql -f query='{repository(owner:"askalf",name:"ioredis"){pullRequest(number:3){userContentEdits(first:10){nodes{editedAt diff}}}}}' \
    --jq '.data.repository.pullRequest.userContentEdits.nodes[] | {editedAt, diffLen:(.diff|length)}'
{"diffLen":28997,"editedAt":"2026-09-14T17:56:35Z"}
{"diffLen":27072,"editedAt":"2026-09-14T17:54:46Z"}
{"diffLen":56,"editedAt":"2026-09-14T17:22:02Z"}

$ gh api repos/askalf/ioredis/pulls/3/reviews/5201110337 --jq .submitted_at
2026-09-14T17:58:59Z

The 17:54:46Z revision — two edits and ~4 minutes before the review — already carried the populated section:

$ gh api graphql -f query='...userContentEdits(first:10){nodes{editedAt diff}}...' \
    --jq '...nodes[1].diff' | grep -n -A6 'Disclosure facts'
297:## Disclosure facts for the operator
298-
299-Plain facts, for you to word your own disclosure:
300-
301-- An AI agent found the bug. Method: read the most recently merged fix in this surface (#2194, `d95d05a`), took the gap its own commit message described, and checked whether it held for the other exits from the reconnect path. It does.
302-- The agent wrote the 12-line source change in `lib/Redis.ts` and the whole of `test/unit/unfulfilledCommands.ts`.
303-- The agent executed everything reported here: base-arm and head-arm test runs, `tsc --noEmit`, `npm run build`, `eslint`, `prettier --check`. All console output in this document is copy-pasted from those runs, not reconstructed.

The live body at the moment the rework ticket was opened still had six bullets under that heading, and the on-disk facts sheet at /agent-output/oss/ioredis/fix-flush-prev-command-queue/facts.md was byte-identical to it (sole difference: one trailing newline GitHub appends). Both quoted "PR-body lines" in the finding — ## Disclosure facts for the operator immediately followed by ## Boundaries — were 11 lines apart in the actual body, so the quotation is not a contiguous extract of this PR at any point in its history.

The likeliest cause is that the review read a body snapshot taken before the 17:54:46Z edit. Flagging it because a review lane that can silently read a stale snapshot will produce the same false blocker on future candidates, and that is worth knowing independently of this PR.

Done anyway — the section is materially stronger now

Being right about the mechanics is not a reason to leave the section as it was. It had a real weakness the finding did not name: its last bullet said "No human has reviewed the reasoning at the time of writing beyond the team's own gating review", which had gone stale the moment two review lanes reported. The section has been rewritten and roughly tripled, now organised under four explicit headings, and the PR body and the on-disk facts sheet are updated together:

  • What AI did, and what it did not — now states the exact split (100% of the +12/-0 source diff and the +199/-0 test file, both commit messages, this entire facts sheet), names the two commit SHAs, records that verification is executed rather than static, names what was not executed locally (functional/cluster lanes, no Docker) and which CI run covered it instead, and records the commit identity with an explicit statement that no attribution trailers or model names appear anywhere in the branch — and why that is deliberate.
  • Review status at the time of writing — corrected: two automated lanes have now run at this head, the second-opinion lane independently re-derived the bug and reproduced the 2-pass/6-fail base split, and no human has reviewed it yet. That last point is the one an operator actually needs before submitting.
  • Facts relevant to the upstream project's own stance — upstream ships an AGENTS.md that explicitly anticipates AI coding agents; there is no AI prohibition, no mandatory disclosure wording, no PR template, no CLA and no DCO in AGENTS.md, .github/CONTRIBUTING.md or CODE_OF_CONDUCT.md. So the disclosure decision is genuinely the operator's, and these are the facts it rests on.
  • One scoping fact worth not misstating upstream — retained and promoted: the originating ticket hypothesised a different bug (info command issued after client enters subscriber mode redis/ioredis#2037, now dead per maintainer PavelPashov on 2026-09-10), and info command issued after client enters subscriber mode redis/ioredis#2037 must not be referenced in the upstream description.

The section remains plain facts, not a drafted disclosure body — wording the disclosure is the operator's line to write, not this lane's.

$ gh api repos/askalf/ioredis/pulls/3 --jq .head.sha
bd8edf9c47695b53a5a381d0dacd2d2b394cc904

Head is unchanged, so the CI evidence in ## Verification method (run 34877010651, 21/21 green) and every test transcript in ## Test evidence still describe this exact head. Nothing needs re-running.

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: APPROVED — ready for the operator to submit.

No blocking issues found in this evidence-only re-review. The current ## Disclosure facts for the operator is populated with specific provenance, execution, CI, review-status, and upstream-policy facts; it is not empty. The prior quoted headings were not contiguous in the live body, and the body-edit history shows the section existed before the earlier review was submitted.

What's good: the changed lib/Redis.ts:1035-1047 code drains the otherwise unreachable prevCommandQueue using the same error and queue-drain idiom as commandQueue, then clears the reference. I traced the base flushQueue() at lib/Redis.ts:1027-1037, which only handled commandQueue; this closes the stated pending-promise path without affecting the successful-reconnect resend path. The focused RESP2/RESP3 tests distinguish the fix on the supplied base/head evidence, the boundary ledger covers the new null/truthiness/empty-deque paths, commit messages contain no prohibited attribution, and all 21 CI checks are green at this exact head.

I reviewed the changed files, base lifecycle context, current OSS-candidate facts sheet and evidence-only rework comment, commit messages, upstream policy/prior-art evidence, and CI. I did not run the local test suite, per review environment policy.

@sprayberry-secondread sprayberry-secondread 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: no blocking issues in the diff itself; this is an evidence-only rework of the PR body at an unchanged head, and I independently re-verified the underlying bug and fix.

What I checked

This ticket flagged that only the PR body changed since the last review (head bd8edf9c is unchanged: 0e9bad5 fix + bd8edf9 test-only follow-up, both untouched). I re-derived the finding from the diff myself rather than trusting the body:

  • lib/Redis.ts:130 adds private prevCommandQueue: Deque<CommandItem> | null = null; — this is a genuine gap fix, not a cosmetic type annotation. Before this PR, event_handler.ts:380 (self.prevCommandQueue = self.commandQueue;) assigned to this field through an untyped self: any, so this.prevCommandQueue in Redis.ts would not have type-checked without the declaration.

  • lib/Redis.ts:1039-1048, inside the existing if (options.commandQueue) block of flushQueue:

    if (this.prevCommandQueue) {
      while ((item = this.prevCommandQueue.shift())) {
        item.command.reject(error);
      }
      this.prevCommandQueue = null;
    }
    

    I traced the four call sites of flushQueue (Redis.ts:281 connector failure, Redis.ts:822 recoverFromFatalError, event_handler.ts:421 maxRetriesPerRequest, event_handler.ts:429 close()) and confirmed none of them previously touched prevCommandQueue — only readyHandler (event_handler.ts:509-532) drained it, and only on a successful reconnect. A client that never reaches "ready" again (manual disconnect(), retryStrategy giving up, maxRetriesPerRequest exhausted, or a fatal error) left any in-flight command's promise permanently unsettled. This matches the direct predecessor commit d95d05a (redis#2194, merged same day), whose own message says "flushQueue only walks offlineQueue and commandQueue, so nothing could settle those promises afterwards" — that statement was still true for the four exits this PR now covers.

  • I reproduced the bug and fix myself, not from the PR's pasted console output. In the cloned fork worktree at bd8edf9c, running test/unit/unfulfilledCommands.ts:

    • At head: 8/8 passing (4 tests × RESP3/RESP2).
    • With lib/Redis.ts checked out to base d95d05a (fix reverted, test file unchanged): 2 passing / 6 failing — the 2 passes are exactly the (control) test × 2 protocols, and the 6 failures are exactly the three discriminating scenarios × 2 protocols, either with AssertionError: expected 'pending' to equal 'rejected: Connection is closed.' or a timeout waiting for the stash to settle. This is a real, reproducible regression fix, not a static-analysis shape.
    • npx tsc --noEmit clean at head.
  • The (control) test ("still resends them when the reconnect succeeds") is not vacuous: it asserts resolved: bar, a value only obtainable if the stashed command was actually resent and answered by the second connection, not merely left alone. It also exercises the new code path (an empty, non-null prevCommandQueue after readyHandler's resend branch, then a trailing disconnect() that runs the new block against that empty deque) — verified this is genuinely both-arms-green by construction, since nothing in the fix could affect an already-drained queue.

  • FlushQueueOptions.commandQueue — the only caller in-tree that ever overrides it is DataHandler.ts:108 (recoverFromFatalError(err, err, { offlineQueue: false })), which never sets commandQueue: false. So the new block is reachable on every call site; there's no dead branch introduced.

  • Cluster mode is untouched: lib/cluster/index.ts:1089 has its own separate flushQueue with no prevCommandQueue concept, confirming the fix is standalone/Sentinel-scoped as the PR states.

Reuse and idiom

The fix reuses the exact same while ((item = queue.shift())) { item.command.reject(error); } loop already used twice above it in the same function for offlineQueue and commandQueue, and reuses the same error value rather than inventing a new error type — consistent with the idiom #2194 established one function over in readyHandler's abort branch. No missing abstraction here; a helper would be overkill for three structurally-identical loops in one function.

Boundaries (rebuilt independently from the diff)

input to the new guard fixed-code behaviour pinned by
prevCommandQueue never assigned (client never reached "ready") stays null, guard skips all 8 cases pass through flushQueue at least once (the initial connect) with the field still null
prevCommandQueue is an empty Deque (already drained by readyHandler's resend branch) truthy but loop body never runs, field set to null (control) test, verified above
prevCommandQueue holds exactly 1 item one reject, loop exits the three discriminating tests
flushQueue called twice on the same stash (maxRetriesPerRequest retries) 2nd call sees null, no double-reject maxRetriesPerRequest test, which flushes repeatedly across retries
error source: close() vs MaxRetriesPerRequestError both funnel into the identical block, reject with whatever error the caller passed tests 1/2 assert Connection is closed., test 3 asserts the max-retries message
error source: connector failure (Redis.ts:281) / fatal reply error (Redis.ts:822) same block, not caller-dependent not directly tested — reasonably so, since the block reads nothing about its caller and is already pinned twice; flagged honestly by the PR body rather than padded with a redundant fixture
RESP3 vs RESP2 identical — the stash path doesn't touch the handshake every test runs both protocols

I did not find a reachable boundary row that the PR's own ## Boundaries table missed.

On the "evidence-only rework" context

I did not read the gating review. Per the ticket, the prior gating CHANGES_REQUESTED objected to the PR body's disclosure section allegedly being empty; that is a body-formatting/documentation concern, not a defect in lib/Redis.ts or the test file, both of which are byte-identical to the head I already have evidence for. I have nothing to add on the disclosure-section dispute itself since I was not shown that review and it does not touch the diff I'm scoped to assess.

What's good

Minimal, well-scoped fix (12 source lines) that reuses an established idiom in the same function, a test file that reproduces the bug on base and passes at head with a discriminating control, and CI is 21/21 green (fork run 34877010651) including the functional lane against live Redis 8.2/8.4.0/8.8.0.

SECOND READ: READY

@askalf askalf added the verified Adversarially verified by a fresh run label Sep 14, 2026
@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

Verified at current head c542f306bddeabb94d0395155c975e67f85474d9.

I rebuilt the boundaries ledger from the diff, then added and committed three adversarial tests (c542f30) that the candidate did not have:

  1. while ((item = prevCommandQueue.shift())) drains all N>1 entries, not merely the first;
  2. recoverFromFatalError(..., { offlineQueue: false }) reaches the new stash drain after a fatal reconnect reply (the other flushQueue call path); and
  3. a reverse-order sequence — resend leaves an empty non-null stash, then a second drop re-stashes a command which must settle when the client ends.

The production change in lib/Redis.ts is byte-identical to the reviewed candidate head bd8edf9; the verification commit changes only test/unit/unfulfilledCommands.ts.

$ # base source d95d05a with the complete current 7-test file
$ git checkout d95d05a -- lib/Redis.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  unfulfilled commands of a client that never becomes ready again (RESP3)
    1) rejects them when the user disconnects mid-reconnect
    2) rejects them when the retry strategy gives up
    3) rejects them once maxRetriesPerRequest is reached
    ✔ (control) still resends them when the reconnect succeeds (66ms)
    4) rejects every stashed command, not just the first
    5) rejects them when the reconnect hits a fatal protocol error
    6) rejects a command stashed after an earlier resend emptied the stash

  unfulfilled commands of a client that never becomes ready again (RESP2)
    7) rejects them when the user disconnects mid-reconnect
    8) rejects them when the retry strategy gives up
    9) rejects them once maxRetriesPerRequest is reached
    ✔ (control) still resends them when the reconnect succeeds (62ms)
    10) rejects every stashed command, not just the first
    11) rejects them when the reconnect hits a fatal protocol error
    12) rejects a command stashed after an earlier resend emptied the stash

  2 passing (9s)
  12 failing

$ # restore head c542f30
$ git checkout HEAD -- lib/Redis.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  14 passing (1s)

The two base passes are exactly the declared still resends ... (control) case for RESP3 and RESP2; it is a genuine reachability control because it asserts resolved: bar, which only a re-sent command can produce. All six discriminating tests fail on base in both protocol variants.

$ npx tsc --noEmit
rc=0

$ npx eslint ./lib test/unit/unfulfilledCommands.ts
✖ 23 problems (0 errors, 23 pre-existing warnings)
rc=0

$ npx prettier --check test/unit/unfulfilledCommands.ts
Checking formatting...
All matched files use Prettier code style!

$ gh pr checks 3 --repo askalf/ioredis
21 checks: 21 pass, 0 pending, 0 failing
# run 34892232492: Node 20/22/24/26 × Redis 8.2/8.4.0/8.8.0/custom-debian/rs-7.4.0-v1, plus coverage

Facts sheet / PR body was rewritten for c542f30: 7 tests × 2 protocols = 14 cases, 2-pass/12-fail base arm, 14-pass fixed arm, and current-head CI evidence. Verified.

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: APPROVED — ready for the operator to submit; no blocking issues found.

I reviewed the live head c542f306bddeabb94d0395155c975e67f85474d9, including both changed files and the reconnect/ready-handler context. The new prevCommandQueue drain in lib/Redis.ts:1038-1047 is reached by the existing flush paths and rejects every stashed in-flight command with the same error used for the active command queue. It does not interfere with the successful-reconnect path: readyHandler remains the consumer on readiness, and an already-drained/null or empty queue is harmless. I also traced the base implementation at d95d05a964be3687b01381224753f82c23427177, where the stash is created on a ready-connection close but flushQueue did not drain it.

The regression suite covers disconnect, retry give-up, max-retries flushing, fatal protocol error, multiple stashed commands, re-stashing after a successful resend, and both RESP2 and RESP3. Fork CI is green (21 checks). I did not run the test suite locally, per review environment policy.

OSS-candidate checks: the facts sheet contains the required evidence and boundaries ledger; base failure/head success output is included; the regression tests distinguish the fixed and unfixed paths; upstream prior-art searches showed no duplicate open proposal; the focused bug-fix exception in upstream contribution guidance applies; and the commits/branch/title contain no AI attribution.

What's good: the fix is minimal, uses the existing queue-draining idiom, explicitly clears the stash after rejection, and the added adversarial cases close the important queue-size, repeated-flush, error-source, and protocol boundaries.

@sprayberry-secondread sprayberry-secondread 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.

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the Claude second-opinion lane (second opinion, non-gating; the gating review is posted separately).

Verdict: no blocking issues. This is a well-verified, minimally-scoped fix.

What I independently checked

I did not read the gating review. I cloned the fork fresh, checked out head c542f30, and reproduced the claims myself rather than trusting the PR body:

  • Base-arm repro. git checkout d95d05a -- lib/Redis.ts (the immediate predecessor commit, #2194) with the current test file in place: 2 passing / 12 failing, matching the PR's own transcript exactly ('pending' vs 'rejected: Connection is closed.', and two timeouts on the maxRetriesPerRequest/fatal-error cases).
  • Head-arm. Restored lib/Redis.ts to HEAD: 14/14 passing.
  • npx tsc --noEmit: rc=0.
  • gh pr checks 3: 21/21 green at c542f30 (full unit+functional matrix, Node 20/22/24/26 × five Redis targets).
  • Traced the actual defect in source: lib/redis/event_handler.ts:379-381 stashes in-flight commands into self.prevCommandQueue on close-from-ready; readyHandler (event_handler.ts:509-532) is the only other reader. connectHandler/resetCommandQueue() (lib/Redis.ts:928-929) replaces commandQueue on every reconnect attempt, so once a reconnect starts, prevCommandQueue is reachable only via reaching "ready" again. Before this diff, flushQueue() (lib/Redis.ts:1015-1050) — the "give up and settle everything" path — never looked at that field, so a client that ends without reaching "ready" again leaves those command promises pending forever.

The fix

lib/Redis.ts:1039-1048:

      // Commands that were in flight when a ready connection dropped are
      // stashed in `prevCommandQueue`, and only the ready handler drains it.
      // A reconnect replaces `commandQueue`, so a client that ends before
      // becoming ready again has no other chance to settle them.
      if (this.prevCommandQueue) {
        while ((item = this.prevCommandQueue.shift())) {
          item.command.reject(error);
        }
        this.prevCommandQueue = null;
      }

This is the same block, same error, same while ((item = q.shift())) idiom as the pre-existing commandQueue drain immediately above it (lib/Redis.ts:1028-1037). It sits inside the existing if (options.commandQueue) guard, so it inherits that option's semantics rather than adding a new one.

Boundaries ledger — rebuilt independently from the diff

The diff adds exactly one field declaration, one truthiness guard, one loop. I did not start from the PR body's table; my own pass over the diff and the two touched files landed on the same predicates:

input fixed-code behaviour test that pins it
prevCommandQueue never assigned (never reached ready, or reached it with empty commandQueue — event_handler.ts:379 only stashes if (self.commandQueue.length)) null, guard short-circuits, behaves as base every case passes through this path at least once (initial connect)
prevCommandQueue is an empty but non-null Deque (readyHandler's resend branch drains without nulling, event_handler.ts:512-523) guard is truthy-entered, shift() immediately undefined, zero rejects, field nulled "(control) still resends them when the reconnect succeeds" — its trailing disconnect() hits exactly this state and would break resolved: bar if anything were wrongly rejected
stash holds N>1 items loop drains all, not just the head "rejects every stashed command, not just the first" — base: all three still 'pending', confirmed on my own base-arm run
stash re-populated after a prior resend emptied it (order: resend, then a second drop) second flushQueue sees a fresh non-null/non-empty deque and rejects it "rejects a command stashed after an earlier resend emptied the stash" — base: 'pending', confirmed
different call sites feeding different error values (close() → Connection is closed., maxRetriesPerRequest flush → MaxRetriesPerRequestError, recoverFromFatalError fatal-protocol path with { offlineQueue: false }) stash always rejects with whatever error flushQueue was called with — call-site independent one test per variant, all failing/timing out on base
Cluster topology untouched — lib/cluster/index.ts:1089 has its own separate flushQueue with no prevCommandQueue concept out of scope by construction, correctly not tested

I did not find a reachable row the PR's own ledger misses. The one honestly-flagged gap — the connector-failure call site (Redis.ts:281, flushQueue(err) with no options) — funnels into the identical three-line block already exercised by the other four tests and reads nothing caller-specific, so I agree a dedicated fixture for it would be redundant rather than missing coverage.

Test quality check (both-arms-green audit)

Only one test is designed to pass on both arms — "(control) still resends them when the reconnect succeeds" — and it is declared as such in its own name, not hidden in prose. I checked it isn't a second, undeclared vacuous test: its assertion (resolved: bar) can only be produced by the resend path actually completing and being answered by the second connection; a silently-dropped or wrongly-rejected stash would break it. All six other tests fail on base in both protocols (I reproduced this directly, not from the PR's transcript), so each is a real, non-vacuous discriminator.

Prior art / idiom fit (maintainer's-eye read)

  • Direct predecessor redis/ioredis#2194 (merged same day, this PR's base commit) fixed the resend-succeeds half of this same stash; this PR fixes the never-becomes-ready-again half. The two branches (event_handler.ts:524-531 vs Redis.ts:1043-1048) don't overlap — confirmed by reading both.
  • Scope, diff size (12 lines + tests), single-file source change, and title style (fix: ...) match #2194 and the other recent merged fixes I sampled (#2187, #2180) closely enough that I don't see anything a maintainer would push back on structurally.
  • The PR's ## Prior art search results are consistent with what I'd expect from the issue tracker shape here (#2193 closed, matches).

What's good

Small, well-isolated diff reusing the existing loop/reject idiom exactly; base-arm repro and head-arm fix both independently reproduced by me from a fresh clone rather than taken on faith; CI green at the exact head under review; the one gap in test coverage is disclosed rather than glossed over.

SECOND READ: READY

@askalf askalf added the ready-for-operator Gated; operator submits upstream label Sep 14, 2026
@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Upstream: submitted (see ledger).

@askalf askalf closed this Sep 14, 2026
recoverFromFatalError() flushes and then calls disconnect(true), which
schedules another reconnect instead of ending the client. Draining the
stashed commands there gave up on them after a single failed attempt,
even though a later attempt could still reach ready and resend them.

Settle the stash only on flushes the client cannot come back from, via
a prevCommandQueue flush option that recoverFromFatalError opts out of.
@askalf askalf reopened this Sep 14, 2026
@askalf askalf removed verified Adversarially verified by a fresh run ready-for-operator Gated; operator submits upstream labels Sep 14, 2026

@sprayberry-redline sprayberry-redline 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.

Automated review from the Sprayberry Labs fleet code reviewer.
Reviewed by the GPT gating lane (gating review).

Verdict: request changes — the new fatal-reconnect behavior is not covered by current-head evidence in the OSS-candidate facts sheet.

Blocking — verification evidence is stale for the changed behavior (lib/Redis.ts:822-825)

// \disconnect(true)` below keeps reconnecting, so a later attempt can still`

// reach "ready" and resend the stashed commands. Rejecting them here would

// abandon them after a single failed attempt.

this.flushQueue(err, { ...options, prevCommandQueue: false });

This head adds the prevCommandQueue: false override, changing recoverFromFatalError() from rejecting the stashed promise to retaining it across a fatal decoder error and a later reconnect. The existing facts sheet still identifies c542f30 as the current head and its executed A/B output, test count, and complete CI result all cover that prior head, before this behavior was added. Current CI is also still pending (one matrix job was in progress when reviewed).

The new control test is a sensible shape for this case, but the candidate body must provide current-head evidence that it was executed and that the changed path discriminates: show the test on this head and the corresponding failing result when the prevCommandQueue: false override is removed (or otherwise restore the old behavior). Update the facts sheet's head SHA, test count, and CI status once the matrix completes. Without that, the mandatory evidence that this new branch is correct is not established.

// Re-run the focused test on 413faca with the override present, then rerun it
// with the override removed and record both verbatim results in the PR body.
this.flushQueue(err, { ...options, prevCommandQueue: false });

What's good: the changed guard is narrowly scoped to the fatal-error path; retaining the stash before disconnect(true) matches the documented reconnect/resend lifecycle, and the added control directly targets the regression that a blanket flush would introduce. I also traced the base stash/ready-handler paths, reviewed the full diff and current body, checked upstream prior-art searches, and inspected CI; I did not run the repository suite locally per review policy.

@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Upstream rework — round 1 (redis#2197)

Head c542f30 → 413faca. Responds to the chatgpt-codex-connector review at c542f30: one P2 inline on lib/Redis.ts:1047, "Preserve stashed commands across recoverable reconnect failures".

Verdict: the reviewer is right, and the defect is in this PR's own diff. Base d95d05a never drained prevCommandQueue at all, so the premature drain on the fatal-error path was introduced by this PR — it is not pre-existing, and it is fixed here rather than deferred.

Why it is real. recoverFromFatalError (lib/Redis.ts:817-828) ends in disconnect(true). That leaves manuallyClosing unset, so closeHandler schedules another reconnect instead of ending the client. The flush that precedes it is therefore non-terminal, and draining the stash there abandoned in-flight commands after a single failed attempt — even under default autoResendUnfulfilledCommands or maxRetriesPerRequest: null, exactly as the review says.

Fix (+13/-2 across two files):

  • lib/DataHandler.ts:37-44 — new optional prevCommandQueue?: boolean on FlushQueueOptions.
  • lib/Redis.ts:1019-1023 — defaulted to true in flushQueue's defaults(), so every existing caller is unchanged.
  • lib/Redis.ts:1047 — guard becomes if (options.prevCommandQueue && this.prevCommandQueue).
  • lib/Redis.ts:822 — recoverFromFatalError passes { ...options, prevCommandQueue: false }, the only in-tree opt-out.

The four terminal exits (close() via disconnect(), close() via retryStrategy giving up, maxRetriesPerRequest, connector failure) keep draining as before.

Tests. The old test rejects them when the reconnect hits a fatal protocol error pinned exactly the behaviour the review identified as wrong, so it is replaced by two:

  • (control) keeps them across a fatal protocol error so a later reconnect can resend them — first connection hangs on GET, second replies unparseable garbage (@bogus\r\n), third answers bar. resolved: bar can therefore only hold if the stash survived the fatal flush and was resent.
  • rejects them when a fatal protocol error is followed by the client ending — same fatal flush, but retryStrategy then gives up, so the terminal close flush must settle it with Connection is closed.

test/unit/unfulfilledCommands.ts is now 8 tests × RESP3/RESP2 = 16 cases.

Three-arm A/B, executed (Node v24.19.0, mocha 11.7.6):

arm result
head 413faca 16/16 pass
previous head c542f30, current tests 12 pass / 4 FAIL — exactly the two new tests × 2 protocols
base d95d05a, current tests 4 pass / 12 FAIL — the 4 are the two declared controls × 2 protocols

The middle arm is the one that matters: it shows both new tests discriminate this round's change on both protocols.

$ git checkout c542f30 -- lib/Redis.ts lib/DataHandler.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/unfulfilledCommands.ts"

  12 passing (2s)
  4 failing

  1) unfulfilled commands of a client that never becomes ready again (RESP3)
       (control) keeps them across a fatal protocol error so a later reconnect can resend them:

      AssertionError: expected 'rejected: Unknown RESP type 64 "@". P…' to equal 'resolved: bar'
      + expected - actual

      -rejected: Unknown RESP type 64 "@". Please report this.
      +resolved: bar

  2) unfulfilled commands of a client that never becomes ready again (RESP3)
       rejects them when a fatal protocol error is followed by the client ending:

      AssertionError: expected 'rejected: Unknown RESP type 64 "@". P…' to equal 'rejected: Connection is closed.'

Note on the second control. (control) keeps them across a fatal protocol error… passes on base (base never drains the stash anywhere) but fails on the previous head, which is what earns its place alongside the existing resend control — it is the test that discriminates the prevCommandQueue: false opt-out specifically. Both controls are declared in their names.

Tooling at 413faca: tsc --noEmit rc=0; eslint ./lib + test file rc=0 (23 pre-existing warnings, 0 errors); prettier --check clean on the test file; test/unit/DataHandler.ts 15/15 (run because this round also touches lib/DataHandler.ts). Both touched lib/ files are prettier-dirty on clean base at code far from the diff (lib/DataHandler.ts objects at :146, the hunk is at :37), so neither was reformatted.

Fork CI at this head: run 34903573093 — 21/21 green, unit + functional against live Redis 8.2/8.4.0/8.8.0 + custom-debian + rs-7.4.0-v1 on Node 20/22/24/26.x. Worth noting: c542f30 was also 21/21 green while carrying this regression — the existing functional suite does not exercise the fatal-error reconnect path, which is why the two new unit tests were needed.

The PR body above has been reconciled to this head: ## Summary, ## Repro, ## Test evidence, ## Verification method and ## Boundaries (new rows 2a/2b/2c for the option) all state 8 tests / 16 cases and the 413faca transcripts.

@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Re-closing: this candidate is already submitted upstream as redis#2197. It was reopened only to run the fork's pull_request CI lane at the new head 413faca (21/21 green, run 34903573093); the branch remains the head of the upstream PR.

@askalf askalf closed this Sep 14, 2026
@askalf askalf added the ready-for-operator Gated; operator submits upstream label Sep 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oss-candidate Sprayberry Code candidate for upstream ready-for-operator Gated; operator submits upstream submitted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants