Repository navigation
Conversation
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.
sprayberry-redline
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
bd8edf9cand traced the bug mechanism from scratch:closeHandler(lib/redis/event_handler.ts:379-381) stashes in-flight commands intoprevCommandQueueonly when the previous status was"ready"; onlyreadyHandler(event_handler.ts:509-532) ever drains it, on either its resend branch or its abort branch.connectHandler(event_handler.ts:156) callsresetCommandQueue(), which replacescommandQueuebut never touchesprevCommandQueue. Before this diff,flushQueue(lib/Redis.ts:1015-1050, pre-fix) only walkedofflineQueueandcommandQueue, so a client that starts a reconnect and then ends (viadisconnect(),retryStrategygiving up, a connector failure,maxRetriesPerRequest, orrecoverFromFatalError) left the stashed commands' promises permanently pending. - Confirmed all four non-
readyHandlerreconnect exits reachflushQueuewithcommandQueuetruthy (the default):event_handler.ts:429(close(), used both by manualdisconnect()mid-reconnect and byretryStrategyreturning non-number),event_handler.ts:421(maxRetriesPerRequest),Redis.ts:281(connector failure), andRedis.ts:822/DataHandler.ts:108(recoverFromFatalError). - Ran the new suite myself: at head
bd8edf9c,test/unit/unfulfilledCommands.tsis 8/8 passing. Checked outlib/Redis.tsat based95d05a(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 --noEmitclean;eslint lib/Redis.ts test/unit/unfulfilledCommands.tsis 0 errors / 18 pre-existingmember-orderingwarnings unrelated to this diff.gh pr checks 3shows 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 smalllib/diff plus a dedicated new/extended test file,fix:commit-type titles, and reference an issue viaCloses #Nin 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.mdplaces unit tests needing only a mock server intest/unit/, which is where the new file landed — consistent withtest/unit/resubscribe.tsand 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
flushQueuehas (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
Rework — evidence-only, HEAD UNCHANGED at
|
sprayberry-redline
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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:130addsprivate 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 untypedself: any, sothis.prevCommandQueueinRedis.tswould not have type-checked without the declaration. -
lib/Redis.ts:1039-1048, inside the existingif (options.commandQueue)block offlushQueue: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:281connector failure,Redis.ts:822recoverFromFatalError,event_handler.ts:421maxRetriesPerRequest,event_handler.ts:429close()) and confirmed none of them previously touchedprevCommandQueue— onlyreadyHandler(event_handler.ts:509-532) drained it, and only on a successful reconnect. A client that never reaches"ready"again (manualdisconnect(),retryStrategygiving up,maxRetriesPerRequestexhausted, or a fatal error) left any in-flight command's promise permanently unsettled. This matches the direct predecessor commitd95d05a(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, runningtest/unit/unfulfilledCommands.ts:- At head: 8/8 passing (4 tests × RESP3/RESP2).
- With
lib/Redis.tschecked out to based95d05a(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 withAssertionError: 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 --noEmitclean at head.
-
The
(control)test ("still resends them when the reconnect succeeds") is not vacuous: it assertsresolved: 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-nullprevCommandQueueafterreadyHandler's resend branch, then a trailingdisconnect()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 isDataHandler.ts:108(recoverFromFatalError(err, err, { offlineQueue: false })), which never setscommandQueue: 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:1089has its own separateflushQueuewith noprevCommandQueueconcept, 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
VerificationVerified at current head I rebuilt the boundaries ledger from the diff, then added and committed three adversarial tests (
The production change in $ # 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 $ 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 coverageFacts sheet / PR body was rewritten for |
sprayberry-redline
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 themaxRetriesPerRequest/fatal-error cases). - Head-arm. Restored
lib/Redis.tsto HEAD: 14/14 passing. npx tsc --noEmit: rc=0.gh pr checks 3: 21/21 green atc542f30(full unit+functional matrix, Node 20/22/24/26 × five Redis targets).- Traced the actual defect in source:
lib/redis/event_handler.ts:379-381stashes in-flight commands intoself.prevCommandQueueon close-from-ready;readyHandler(event_handler.ts:509-532) is the only other reader.connectHandler/resetCommandQueue()(lib/Redis.ts:928-929) replacescommandQueueon every reconnect attempt, so once a reconnect starts,prevCommandQueueis 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-531vsRedis.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#2194and 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 artsearch results are consistent with what I'd expect from the issue tracker shape here (#2193closed, 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
|
Upstream: submitted (see ledger). |
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.
sprayberry-redline
left a comment
There was a problem hiding this comment.
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.
Upstream rework — round 1 (redis#2197)Head Verdict: the reviewer is right, and the defect is in this PR's own diff. Base Why it is real. Fix (+13/-2 across two files):
The four terminal exits ( Tests. The old test
Three-arm A/B, executed (Node v24.19.0, mocha 11.7.6):
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. Tooling at 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: The PR body above has been reconciled to this head: |
|
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. |
Summary
flushQueue()walksofflineQueueandcommandQueueonly. 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 onlyreadyHandlerdrains that stash (lib/redis/event_handler.ts:509-532).connectHandlercallsresetCommandQueue(), 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.disconnect()mid-reconnect,retryStrategyreturning a non-number, a connector failure on the reconnect attempt, and themaxRetriesPerRequestflush — 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.prevCommandQueueinside the existingif (options.commandQueue)block offlushQueue, rejecting with the same error as the rest of that queue, and declare the field that until now was only ever set untyped fromevent_handler. The drain is skipped on flushes the client can still come back from:recoverFromFatalErrorends indisconnect(true), which reconnects rather than ending, so it opts out via a newprevCommandQueueflush option and leaves the stash forreadyHandlerto resend.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 theautoResendUnfulfilledCommands: falsecase fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194 fixed.Current head is
413faca(18 lines acrosslib/Redis.ts+lib/DataHandler.ts, 8 tests × 2 protocols = 16 cases). Verbatim transcripts of both arms at this head:The 4 passes are exactly the two declared controls × 2 protocols; every non-control case fails on base.
Upstream
redis/ioredismaind95d05a964be3687b01381224753f82c23427177(fix: reject unfulfilled commands dropped on reconnect (#2194))413facae537a95b8182cd98411608e6a2fe0d3delib/Redis.ts—flushQueue()(:1015-1053),recoverFromFatalError()(:817-828), and the newprevCommandQueuefield declaration (:130);lib/DataHandler.ts— theFlushQueueOptionstype (:37-44)test/unit/unfulfilledCommands.ts(new, 406 lines, 8 tests × 2 protocols = 16 cases)lib/redis/event_handler.ts—closeHandler(:375-382) stashes,readyHandler(:509-532) drainsBug
Trigger. A client reaches
"ready", a command is sent and is still awaiting its reply, and the socket drops.closeHandlerseesprevStatus === "ready"and moves the livecommandQueueintoself.prevCommandQueue(event_handler.ts:379-381). A reconnect is scheduled;connectHandlerrunsself.resetCommandQueue(), replacingcommandQueuewith a fresh emptyDeque. From this moment the stash is referenced only byprevCommandQueue, and the only code that reads it isreadyHandler.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'sawait 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:
redis.disconnect()while reconnectingevent_handler.ts:429(close())retryStrategyreturns a non-number (give up)event_handler.ts:429(close())maxRetriesPerRequestreached, still retryingevent_handler.ts:421Redis.ts:281setStatus("end")) — stash drainedRedis.ts:822(recoverFromFatalError)Graceful shutdown is the common one: a service that drains by calling
disconnect()after a network blip leaves a promise thatPromise.allwill wait on until the process is killed. It is invisible in logs — no error is emitted, because no error is delivered anywhere.The
maxRetriesPerRequestrow 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.tsis the repro; it needs no Redis server (MockServeronly). Against the unmodified base, with the current 8-test file in place:The 4 passes are exactly the two declared controls × 2 protocols; the full per-test listing and failure text is in the
## Summaryblock above. Eight of the twelve failures readexpected 'pending' to equal 'rejected: Connection is closed.'(or the three-elementeqlform) — the promise is observably still pending after the client has reached status"end". ThemaxRetriesPerRequestand fatal-then-ending cases time out inwaitForbecause the promise never settles at all.The shape in user terms:
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:
options.commandQueue, rejected with the sameerrorand with the samewhile ((item = q.shift()))loop the two queues above it use. No new error type, no signature change.recoverFromFatalErrorends indisconnect(true), which leavesmanuallyClosingunset socloseHandlerschedules 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 totrue, so every existing caller — none of which sets it — is unchanged.readyHandleris the only other consumer, and it runs on"ready".flushQueuewith the drain enabled runs only when the client is ending or has hit its retry contract. Pinned by both controls.readyHandler's abort branch setsprevCommandQueue = nullafter rejecting (event_handler.ts:530); its resend branch shifts every item out. Either way the dequeflushQueuecan later see is empty or null.prevCommandQueuewas assigned fromevent_handler.ts(which typesselfasany) and never declared on the class, sothis.prevCommandQueuewould not type-check inRedis.tsat all. Declaring it is the minimum required to read it, and it matches the neighbouringprivate offlineQueue: Deque;.Alternatives rejected:
connectHandler/resetCommandQueue(). That would reject on every reconnect attempt, destroyingautoResendUnfulfilledCommands— the whole point of the stash is that it survives the attempt.this.status === "end"insideflushQueueinstead of an option.flushQueueis called beforesetStatus("end")atRedis.ts:281and by themaxRetriesPerRequestpath 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.commandQueueon close. The two queues have different semantics (one is resent on ready, one is not) andreadyHandlerdistinguishes them; merging would change resend behaviour for everyone.maxRetriesPerRequestcall site, the one with the doc contradiction. The same leak exists at the other terminal call sites; putting it influshQueuefixes all of them with less code.prevConditionhas the same single-consumer shape and is also only cleared inreadyHandler. 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 head413faca. Placement follows.github/CONTRIBUTING.md:108("Place unit tests intest/unit/... Follow nearby tests and reuse helpers fromtest/helpers/") and thePROTOCOLS = [3, 2]loop convention used bytest/unit/resubscribe.ts.MockServeronly — no Redis server needed.The shared fixture brings a client to
"ready", leaves aGETin flight (the mock server accepts it and never replies), then destroys the socket socloseHandlerstashes 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 (commitc542f30). Tests 6 and 7 are from the upstream-review round (commit413faca) 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.d95d05ac542f30413facaclose()viadisconnect(),Connection is closed.'pending')close()viaretryStrategy→null'pending')event_handler.ts:421flush,MaxRetriesPerRequestErrortextreadyHandler's resend; assertsresolved: barwhileloop drains N>1 items, not only the head — threeGETs in flight, asserts all three settle['pending','pending','pending'])recoverFromFatalErroris non-terminal: its flush must leave the stash. Assertsresolved: bar, and only the third connection ever answersGETrejected: Unknown RESP type 64 "@". Please report this.)retryStrategythen gives up — the close flush must settle the stashrejected: Unknown RESP type 64 "@"…)prevCommandQueuenon-null-but-empty, then a second drop re-stashes onto it and the client never recovers'pending')Two controls, both declared in their names, and they control for different things.
resolved: bar— a value only the resent command can produce, since the first connection never replies toGET. 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 dequereadyHandler's resend branch leaves behind. Green on base and on both heads by construction: that is what it controls for.c542f30, which is what makes it worth keeping rather than redundant with test 4. It is the test that discriminates theprevCommandQueue: falseopt-out specifically — the regression this round fixes. Like test 4 it assertsresolved: bar, and the fixture guarantees only the third connection answersGET(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 withgit checkout 413faca -- …;git status --porcelainconfirmed clean):Previous-head arm — the same current test file against
c542f30's source, isolating this round's change: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
eqlcompares 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("Runnpm run lint,npm run build, and the tests for your change"):test/unit/DataHandler.tsis run because this round also toucheslib/DataHandler.ts; it is green.npm run format-checkoverlib/is not clean, and is not clean on base either. Both touchedlib/files are prettier-dirty on the unmodified base, at code far from this diff:The only block prettier objects to in
lib/DataHandler.tsis at:146, more than a hundred lines from the:37hunk this PR adds. Sonpx prettier --writeon 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, mocha11.7.6. Unit lane only —test/unit/mocks the network. Functional and cluster tests neednpm 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
## Boundariesledger 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 onlib/Redis.ts:1047) that identified the non-terminalrecoverFromFatalErrorflush; that finding was confirmed by execution — not merely by reading — via the previous-head arm above, where tests 6 and 7 fail onc542f30and pass at413faca.Scope note on that review. The defect Codex raised is in this PR's own diff, not pre-existing on base:
d95d05anever drainedprevCommandQueueat 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/ioredishas Actions enabled, so the upstreamtest:jsworkflow — unit and functional against live Redis servers — runs on the fork branch. At the current head413faca, run 34903573093 completed 21/21 green: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 theflushQueuechange and the newprevCommandQueue: falseopt-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:
bd8edf921/21 green (run 34877010651),c542f3021/21 green (run 34892232492). Note thatc542f30was 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
Findings:
readyHandlerreject the stash whenautoResendUnfulfilledCommandsisfalse. Its commit message names the gap this PR closes — "flushQueue only walks offlineQueue and commandQueue, so nothing could settle those promises afterwards." fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194 fixed the case where the client does become ready; this fixes the case where it never does. Not overlapping: the two branches areevent_handler.ts:524-531andRedis.ts:1043-1048.flushQueueand is the closest neighbour — same connection-lifecycle area, also about commands stranded on disconnect. It is a different defect: it clears armedcommandTimeouttimers for commands stranded in the offline queue and adds a staleness guard to the connect callback.gh pr diff 2169 --repo redis/ioredis | grep prevCommandQueuereturns zero hits; its files arelib/Redis.ts(connect callback +disconnect()),lib/redis/event_handler.ts(connectHandler),test/functional/commandTimeout.ts. No conflict withflushQueue's body. Worth cross-referencing for a maintainer, not competing.accepted) is the issue fix: reject unfulfilled commands dropped on reconnect redis/ioredis#2194 resolved; this is its surviving half.#422015,#8002019,#9652020,#17182023).prevCommandQueuefromflushQueue. Clear.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 inAGENTS.md,.github/CONTRIBUTING.md, orCODE_OF_CONDUCT.md. No CLA, no DCO sign-off requirement, no changelog/changeset requirement. No.github/PULL_REQUEST_TEMPLATE*exists..github/CONTRIBUTING.md:26-32requires maintainer agreement on scope before most work, with listed exceptions that may be submitted directly: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): "Runnpm 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 intest/unit/... Follow nearby tests and reuse helpers fromtest/helpers/." — the test is intest/unit/and usestest/helpers/mock_server.Commands per
AGENTS.md: single-file run isTS_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:
d95d05a), took the gap its own commit message described ("flushQueueonly walksofflineQueueandcommandQueue, 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.lib/Redis.tsandlib/DataHandler.ts(net +21/-1 across the branch) and the whole oftest/unit/unfulfilledCommands.ts(+406/-0). No human wrote or edited a line of either file.0e9bad5991,bd8edf9c47,c542f306bd,413facae53) and this entire facts sheet, which is also the PR body.tsc --noEmit,eslint,prettier --check. Everyconsoleblock here is copy-pasted from a real terminal, not reconstructed or paraphrased. Verification method for this candidate isexecuted, notstatic.askalf <263217947+askalf@users.noreply.github.com>. There are no AI attribution trailers, noCo-Authored-Bylines, 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:
bd8edf9c(a gating review and an independent second-opinion review), and a third, independent adversarial verification run re-executed everything at headc542f30and 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.chatgpt-codex-connectorat headc542f30(aCOMMENTEDverdict with one P2 inline onlib/Redis.ts:1047). That finding was valid, was reproduced by execution, and is fixed at head413faca; 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.Facts relevant to the upstream project's own stance:
redis/iorediscarries anAGENTS.mdwhose 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.AGENTS.md,.github/CONTRIBUTING.md, orCODE_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.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:
infocommand issued after client enters subscriber mode redis/ioredis#2037, subscribe/psubscribe racing handshake commands). The agent determined that issue is dead — maintainer PavelPashov commented 2026-09-10 that it no longer reproduces on 6.0.0, closed by the RESP3 handshake gate in Add RESP3 redis/ioredis#2127 — and pivoted to this bug instead.infocommand issued after client enters subscriber mode redis/ioredis#2037 is not what this PR fixes; do not reference it in the upstream description. No upstream issue exists for this specific gap.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
413facaround.private prevCommandQueue … = null"ready", or reached it but had an emptycommandQueue—event_handler.ts:379only stashesif (self.commandQueue.length))null; row 3 short-circuits;flushQueuebehaves exactly as on baseflushQueueat least once before any stash exists (thelazyConnectconnect + first drop), so thenullpath is exercised in every testif (options.commandQueue)options.commandQueue === falseFlushQueueOptionsisDataHandler.ts:108, which passes{ offlineQueue: false }and leavescommandQueuetodefaults()→true;grep -rn "commandQueue: false" lib/returns zero hitsoptions.prevCommandQueue— newdefaults()keyevent_handler.ts:421,:429,Redis.ts:281)defaults()fillstrue; stash drained, as before this roundflushQueuethrough the no-options callersoptions.prevCommandQueue— explicitfalserecoverFromFatalErrorpasses{ ...options, prevCommandQueue: false }(the only in-treefalse)disconnect(true)reconnects andreadyHandlerresends it. Spread order matters:prevCommandQueuecomes last, so a caller could not accidentally re-enable itc542f30, passes at413faca. Test 7 pins the complementary case: same fatal flush, then a terminal close that does drainoptions.prevCommandQueue—undefinedfrom a partial options objectDataHandler.ts:108passes{ offlineQueue: false }, which reachesrecoverFromFatalErrorand is spreadundefinedis overwritten by the explicitfalsein row 2b beforedefaults()sees it, so the fatal path never falls back totrueif (options.prevCommandQueue && this.prevCommandQueue)— truthiness of the fieldnull(initial, or already drained byreadyHandler's abort branch atevent_handler.ts:530, or by a previousflushQueue)maxRetriesPerRequesttests callflushQueuerepeatedly as retries continue — the 2nd and later calls take thenullpath after the 1st drained itDeque(the falsy-looking-but-valid case):readyHandler's resend branch (event_handler.ts:512-523) shifts every item out but does not null the fieldDequeinstance is always truthy, so the guard is entered;shift()returnsundefinedimmediately, the loop body never runs, and the field is set tonull. No command is rejected — correct, they were already resentredis.disconnect()runsflushQueueagainst exactly this empty-deque state, andresolved: barwould break if anything were rejected herewhile ((item = this.prevCommandQueue.shift()))— loop entryshift()returnsundefinedGETs in flight, asserts all three settle. Base: all three still'pending'reject(error), thenshift()→undefined, loop exitsGETin flight)readyHandlerresend empties the deque without nulling it, then a later drop pushes a new item into that same fieldflushQueuesees a non-null, non-empty deque and rejects the new item'pending'CommandItemthat is itself falsyDequehere holdsCommandItemobjects pushed bysendCommand; objects are always truthy. Identical idiom to the two pre-existing loops at:1023and:1034, so no new failure modeitem.command.reject(error)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,rejecton a settled promise is a no-op by promise semanticsthis.prevCommandQueue = null— idempotencyflushQueuecalled twice with the same stashnulland skips; no double-rejectmaxRetriesPerRequestcases (test 3), which flush on everymaxRetriesPerRequest + 1-th retry while the client keeps retryingerrorreaches the stashclose()→Connection is closed.commandQueuegetserrorreaches the stashmaxRetriesPerRequest→MaxRetriesPerRequestErrorerrorreaches the stashRedis.ts:822 recoverFromFatalError(err, err, { offlineQueue: false })— the other code path the fix claims to coverConnection is closed.), not the fatal oneConnection is closed.errorreaches the stashRedis.ts:281 flushQueue(err)setStatus("end")follows); stash drained with the connector's errorprotocol: 3)protocol: 2)Clusterlib/cluster/index.tshas its own separateflushQueue(:1089) and noprevCommandQueue; the stash is a standalone/Sentinel concept owned bylib/redis/event_handler.tsSuggested upstream PR title
fix: settle commands stashed on reconnect when the client never becomes ready