Repository navigation
Conversation
`handleReconnection` re-issues SELECT to restore the command's db before
resending it when `reconnectOnError` returns 2, but that call is
fire-and-forget. If it fails - the ACL changed, the db index is out of
range, or the socket is not writeable and the offline queue is disabled -
it becomes an unhandled rejection, which terminates the process on Node
15+ even when an error listener is registered on the client.
Route it through silentEmit("error", err), matching the seven sibling
call sites in readyHandler and the "select" handshake command in
getHandshakeCommands.
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: ready for the operator — approve. No blocking issues found in this focused OSS-candidate fix.
I reviewed the two changed files, the base implementation at d95d05a964be3687b01381224753f82c23427177, the candidate facts sheet, upstream contribution guidance, commit message, and independent upstream handleReconnection PR/issue searches. The new handler is correctly attached to the formerly fire-and-forget promise:
lib/Redis.ts:856-858—this.select(item.select).catch((err) => this.silentEmit("error", err));handles the restoringSELECTrejection without changing the subsequent resend path.test/unit/reconnectOnError.ts:105-112and:125-132cover both asynchronous server rejection and the inline offline-queue-disabled rejection under RESP3 and RESP2, asserting both no unhandled rejection and delivery to the client error listener. The facts sheet includes verbatim fail-before (4 failing) and pass-after (4 passing) evidence, including a per-assertion discrimination check.
The base source confirms that the changed guard reaches a bare this.select(item.select); in handleReconnection case 2; a rejected restore therefore had no consumer. The candidate's silentEmit route matches the established error-handling intent described in the change and preserves the command resend. The diff is minimal, scoped to one bug and its regression test; its fix: commit subject follows the upstream convention and has no prohibited attribution.
The facts sheet contains all required sections, including concrete base/fixed test output, verification method, prior-art results, policy evidence, disclosure facts, and a boundaries ledger. Independent searches found no open upstream PR for handleReconnection; returned issues were closed and unrelated. The upstream policy permits a small, isolated bug fix with a focused test without prior issue discussion. No GitHub Actions checks are reported for this fork branch, so I did not treat unavailable CI as passing; the supplied executed test evidence and static checks were assessed on their merits.
What's good: this is a narrow, behavior-preserving follow-up to the established rejection-handling pattern, and the test deliberately covers the two distinct promise-rejection paths.
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: I independently confirmed the bug and the fix; this is ready for upstream submission as-is.
What I checked
- Cloned the fork at head
e4aaa237d6372b87c95962f7f9f96e975d043be4,npm ci --ignore-scripts, rantest/unit/reconnectOnError.ts: 4 passing with the fix. - Reverted just
lib/Redis.tsto based95d05a(test file kept): 4 failing, both protocol arms, same messages the PR body reports (ERR DB index is out of rangeunhandled,Stream isn't writeable...unhandled). - Reproduced the per-assertion discrimination check myself (neutered the
unhandledassertion into a tautology in a scratch copy, reran against base): the second assertion (errorsincludes the message) independently fails on base in all 4 cases. Neither assertion in either variant is vacuous. npx tsc --noEmitclean.- Confirmed
lib/redis/event_handler.ts:53,480,487,494,502,519,545,554all route through.catch((err) => self.silentEmit("error", err))via merged commitde2fbdf(PR redis#2187) in this fork's history — matches the "eighth site" claim in the PR body. Verifiedde2fbdfalso exists in upstreamredis/ioredishistory, so this is a real precedent, not a fabricated one. - Ran the prior-art searches myself against
redis/ioredis:gh search prs "handleReconnection"→ 0 results,gh search issues "handleReconnection"→ redis#1819/redis#1759/redis#1149, all closed and unrelated. Matches the PR body's table.
Fix at lib/Redis.ts:856
this.select(item.select).catch((err) =>
this.silentEmit("error", err)
);
This is a correct, minimal fix for the stated bug: select() returns a promise, base had it fire-and-forget, an unhandled rejection there is fatal on Node ≥15 even with an error listener registered, and silentEmit is the existing repo idiom used at all seven sibling sites for exactly this class. No blocking issues.
Answers to the three points raised in the ticket
(a) silentEmit vs. item.command.reject(err). Agree silentEmit is the correct choice here, not the rejection alternative. case 2 in handleReconnection (lib/Redis.ts:848-863) is a "reconnect and resend" contract — the in-flight command is meant to complete via this.sendCommand(item.command) regardless of whether the db-restoring SELECT itself succeeds (a failed SELECT means the command runs against whatever db the connection ends up on, not that the command should be abandoned). Routing the restore failure to item.command.reject(err) would reject the original command over a failure in a side-effecting bookkeeping call, which is a behavior change beyond the stated bug and, as the PR body notes, diverges from all seven siblings. silentEmit reports the operational error without touching the command's own resolution path — the correct minimal choice.
(b) Boundaries row 16 (blanket () => 2 re-entrancy). Confirmed unchanged. The .catch attaches a handler to the same promise this.select() already returned; it does not alter what happens if the restoring SELECT itself triggers reconnectOnError again. Nothing in the diff touches the reconnectOnError invocation path (lib/Redis.ts:836-838) or adds any guard against re-entry. This is genuinely pre-existing behavior, not introduced or fixed by this diff, and the PR body's row 16 is accurate in scoping it as "noted, not fixed." I'd add: the risk is real for a blanket () => 2 hook (unbounded reconnect loop on a persistently failing SELECT), but it is a distinct bug outside this diff's blast radius, and the new tests correctly avoid exercising it by keying reconnectOnError on the READONLY message content.
(c) Test's unhandledRejection-listener swap. Safe. test/helpers/global.ts:51-53 installs a listener that throws on any unhandled rejection during the suite, and the new test's beforeEach/afterEach (test/unit/reconnectOnError.ts) saves the existing listeners via process.listeners("unhandledRejection"), removes them, installs a recorder, and restores the saved listeners in afterEach — this is the identical pattern already used by test/unit/resubscribe.ts (added by merged PR redis#2187), so it's a proven-safe idiom at this call site, not a novel risk. afterEach runs even on assertion failure (mocha semantics), so a failing test in the new file won't leave the global throwing listener uninstalled for subsequent tests.
Nothing else blocking
Diff is one production line plus one well-targeted new test file. No reuse/simplification opportunity beyond what's already in the PR — the fix intentionally matches the seven sibling call sites verbatim rather than introducing a shared helper, which is reasonable for a 2-line change and avoids widening the diff's blast radius. No efficiency concerns (this is an error path, not a hot path). Missing-test surface is covered: both rejection sources (async server reply, sync inline sendCommand rejection) × both protocols.
Boundaries ledger (rebuilt independently from the diff)
The diff adds exactly one new expression, .catch((err) => this.silentEmit("error", err)); no new predicate, comparison, or index expression. Re-deriving the reachable rows from the diff itself, not from the PR body's own table:
| Input / case | Fixed code behavior | Test pin |
|---|---|---|
select() resolves |
.catch not invoked, resend proceeds unchanged |
Implicit on the first connection in both tests before the induced failure |
select() rejects async (server error reply) |
.catch fires → silentEmit("error", err) |
Test 1, both protocols — confirmed fails on base, passes on fix |
select() rejects sync (enableOfflineQueue:false, sendCommand rejects inline) |
Same — .catch is attached to the already-returned (possibly already-rejected) promise, handled identically |
Test 2, both protocols — confirmed fails on base, passes on fix |
No error listener registered |
Falls through to console.error in silentEmit (lib/Redis.ts:804-809), unmodified by this diff |
Not tested (pre-existing silentEmit behavior); reasonable to leave uncovered here since it's shared with the 7 untouched siblings |
Restoring SELECT itself triggers reconnectOnError again (blanket hook) |
Unchanged/pre-existing; not addressed by this diff | Deliberately not exercised; PR body row 16 correctly scopes this as out-of-scope, and I agree with that scoping |
I could not find a reachable boundary case that the PR body's own Boundaries table (rows 1-16) misses. Row 16 in particular is the one case a reviewer could plausibly want "fixed" instead of "noted," and I agree with the PR's judgment that fixing it here would be scope creep.
SECOND READ: READY
Adds two cases to the reconnectOnError db restoration suite: the restoring SELECT under enableAutoPipelining, where the autopipeline must flush the failing command before the db is switched for the guard to be reached at all, and a control for the item.command.name !== "select" branch, which keeps the restoring call from firing when the failed command is itself a SELECT.
Adversarial verification — askalf/ioredis #2Fork PR: #2 Verification method: executed. Node v24.19.0, mocha 11.7.6, in Nothing in this report is taken from the PR body. The Boundaries ledger was VerdictEverything in the candidate holds. The fix is correct, minimal, and the One genuine hole was found in the ledger and closed: the diff's guarded call
The diff under test
if (
this.condition?.select !== item.select &&
item.command.name !== "select"
) {
- this.select(item.select);
+ this.select(item.select).catch((err) =>
+ this.silentEmit("error", err)
+ );
}One logical line. The fire-and-forget promise became an unhandled rejection, 1. Test file, both armsHead
|
| # | Predicate / input | Value | Behaviour at head | Pinned by |
|---|---|---|---|---|
| 1 | condition?.select !== item.select |
differs (0 vs 2) | restoring SELECT fires, rejection caught | tests 1–5, both protocols |
| 2 | condition?.select !== item.select |
equal | restoring call skipped entirely | "resends the command after a successful db restoration" (control) + the autopipelining probe, which showed condsel=2 itemsel=2 skipping the call |
| 3 | condition?.select |
undefined (?. short-circuit) |
undefined !== item.select is true for any numeric db, so the call fires and is guarded |
unreachable in a unit fixture: condition is set in connect() before any command can be in flight; the ?. is defensive. No test. |
| 4 | item.command.name !== "select" |
name is select |
restoring call skipped; resent SELECT settles normally | "does not restore the db when the failed command is itself a SELECT" (control, green both arms) |
| 5 | item.command.name !== "select" |
name is get |
call fires | tests 1–5 |
| 6 | item.select |
0 (falsy but valid db) |
SELECT 0 issued and guarded — a truthiness check here would have skipped it | "surfaces a failing db-restoring SELECT as an error event", which restores db 0. The guard is !==, not truthiness, so 0 is handled. |
| 7 | item.select |
non-zero (5) |
guarded identically | "surfaces a failing db-restoring SELECT for a non-zero db" |
| 8 | rejection source | server ReplyError | .catch -> silentEmit("error") |
tests 1, 4 |
| 9 | rejection source | inline reject from sendCommand (enableOfflineQueue: false) |
same | "surfaces an unwritable db-restoring SELECT ... offline queue is disabled" |
| 10 | .catch returns a promise? |
enableAutoPipelining: true |
select bypasses autopipelining, still a Command promise |
"surfaces a failing db-restoring SELECT when auto pipelining is enabled" (new) |
| 11 | error listener registered | none | silentEmit falls back to console.error, rejection still consumed |
"surfaces a failing db-restoring SELECT when no error listener is registered" |
| 12 | error listener registered | present | error delivered to the listener | tests 1, 2, 4 |
| 13 | SELECT succeeds | no rejection | .catch never runs, no spurious error event, resent command settles |
"resends the command after a successful db restoration" (control) |
| 14 | protocol | RESP3 / RESP2 | identical on both | every test runs under both |
Row 3 is the only one without a test, and it is unreachable from a unit fixture
— stated rather than papered over.
silentEmit itself (lib/Redis.ts:781) returns early when status === "end"
or when manually closing, so a rejection arriving after disconnect() is
swallowed deliberately. That is pre-existing behaviour shared with the seven
sibling call sites from redis#2187, not something this diff changes.
6. Package suite
test/unit/**/*.ts — the unit lane. Not the full test:js lane: that also
pulls test/functional/**, which needs a live Redis, and it dies early on a
pre-existing autoPipelining afterEach timeout on both arms.
| Arm | Result |
|---|---|
Head 96cf3e5 |
425 passing / 1 failing |
Base d95d05a (fix reverted, new tests kept) |
423 passing / 9 failing |
The one failure common to both arms:
1) StandaloneConnector
connect()
ignore path when port is set and path is null:
TypeError: Attempted to wrap createConnection which is already wrapped
at checkWrappedMethod (node_modules/sinon/lib/sinon/util/core/wrap-method.js:64:21)Pre-existing sinon double-wrap, unrelated to this change, fails on clean base.
The other 8 base failures are this PR's own tests. No regressions.
7. Formatter / linter / typecheck
$ npx prettier --check test/unit/reconnectOnError.ts
Checking formatting...
All matched files use Prettier code style!
$ npx eslint --ext .js,.ts ./lib
✖ 23 problems (0 errors, 23 warnings)
(rc=0; all 23 are pre-existing member-ordering warnings on clean base)
$ npx tsc --noEmit
(rc=0, no output)Note on prettier: my initial write of the test file introduced a stray leading
space on line 1. I normalised the file through prettier and confirmed with
git diff that the only deletion in the diff is the client() signature line I
deliberately extended:
$ git diff -- test/unit/reconnectOnError.ts | grep "^-" | grep -v "^---"
- function client(port: number, enableOfflineQueue: boolean) {Everything else is additive. The committed test file at e4aaa23 was already
prettier-clean (git show HEAD:test/unit/reconnectOnError.ts | npx prettier --check → rc=0),
so unlike lib/Redis.ts this file is not subject to the standing ioredis
prettier-dirty trap.
8. Behaviour outside the stated bug
Read the diff for anything beyond the fix. There is nothing: one .catch
appended to one call, no predicate altered, no new import, no signature change,
no dependency. silentEmit was already the error sink used by the seven
sibling call sites that PR redis#2187 (de2fbdf) fixed, so the error-reporting
contract is unchanged — this is the eighth site of the same class, in a
different file, which is exactly why it was missed.
The only non-test file touched is lib/Redis.ts, 1 insertion / 3 deletions
(reflow of a single statement).
9. What I committed
96cf3e5 — test: cover autopipelining and the select-command guard on reconnect
(+194 / -1, test/unit/reconnectOnError.ts only). Two tests:
surfaces a failing db-restoring SELECT when auto pipelining is enabled—
fails on base, ledger row 10. Required thesetImmediateflush to be
non-vacuous; see section 3.does not restore the db when the failed command is itself a SELECT—
control, green on both arms, ledger row 4. Pins that the
item.command.name !== "select"guard keeps the restoring call from firing,
and that the resent SELECT still settles and switches the db exactly once.
The fix commit e4aaa23 was not touched.
Verification method
executed, in-container, Node v24.19.0 / mocha 11.7.6, worktree
/agent-workspace/oss/ioredis-wt-1789396101, both arms produced by
git checkout d95d05a -- lib/Redis.ts and restoring from a saved copy.
Fork CI: askalf/ioredis has never had Actions enabled, so gh pr checks
reports no checks on every PR in this fork. Not a signal either way.
Verification addendum — fork CI is live and greenCorrecting a standing assumption in our notes: Actions are enabled on Run: https://github.com/askalf/ioredis/actions/runs/34860870976 at One failure, and why it was not this changeThe first attempt of 2026-09-14T15:14:51.4776481Z Container test-single-1 Healthy
2026-09-14T15:14:56.9776385Z container test-cluster-1 is unhealthy
2026-09-14T15:14:56.9910651Z ##[error]Process completed with exit code 1.That is I re-ran the failed job with The |
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 newly added OSS regression-test controls contain assertions that pass without the fix, so they do not discriminate the regression in their variants.
Blocking — test/unit/reconnectOnError.ts:236-265
it("does not restore the db when the failed command is itself a SELECT", async () => {
// Control: green with and without the fix.
expect(unhandled, "must not surface as an unhandled rejection").to.eql([]);
expect(errors, "a plain SELECT must not emit an error").to.eql([]);
This RESP3/RESP2 control deliberately avoids the changed this.select(item.select).catch(...) path (item.command.name === "select" makes the pre-existing guard false). Consequently all of its assertions pass after reverting the production fix as well. The candidate boundary rule requires every new-test assertion to fail without the fix for its variant; these unchanged-path assertions cannot demonstrate this regression.
// Remove this unchanged-path control from the new regression file.
// Keep the cases that force an unequal selected DB and observe the
// db-restoring SELECT rejection, because those fail before the fix.
Blocking — test/unit/reconnectOnError.ts:274-301
it("resends the command after a successful db restoration", async () => {
// Control: green with and without the fix.
expect(errors, "a successful SELECT must not emit an error").to.eql([]);
expect(result, "the resent command must settle").to.eql("OK");
This variant makes the restoring SELECT resolve, so the added .catch is never invoked. Its assertions likewise pass on base and do not distinguish the proposed change. Please remove this non-discriminating control from the added regression suite (or replace it with assertions in a variant that actually causes the restoring SELECT to reject and therefore fails on base).
// Retain only regression variants that cause the db-restoring SELECT to
// reject, then assert both the error event and absence of unhandled rejection.
What's good: I independently traced the bare base call at lib/Redis.ts:853-860; an unequal selected DB in case 2 can indeed issue a rejecting promise with no handler. The production .catch(...silentEmit...) is minimal and matches the established error sink. The facts sheet is complete, the stated base repro is readable, no duplicate upstream PR was found in the re-run searches, commit messages meet the upstream convention, and all current fork CI checks are green. I did not run local tests.
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 since the prior review at e4aaa23 — this is an evidence-strengthening rework, not a code change. Rebuilt the boundaries ledger and re-ran the fix against the new tests from scratch (not from the PR body).
What changed since the last head
Only test/unit/reconnectOnError.ts changed (e4aaa23..96cf3e5, +194/-1). lib/Redis.ts:856 — the one-line fix (this.select(item.select).catch((err) => this.silentEmit("error", err));) — is byte-identical to the previously reviewed version. The new commit adds five tests: a no-error-listener case, a non-zero restoring-db case, an auto-pipelining case, and two "control" tests (plain SELECT settles; a successful restore doesn't disturb the resend).
Independent verification
- Cloned the fork at
96cf3e5, rannpm ci --ignore-scripts(734 packages, clean). - All 14 cases in
test/unit/reconnectOnError.tspass at head (RESP3 + RESP2 × 7). - Reverted only
lib/Redis.tsto based95d05a(test file untouched): 10 of the 14 fail, and the 4 that keep passing are exactly the two "control" tests × 2 protocols (does not restore the db when the failed command is itself a SELECT,resends the command after a successful db restoration) — as expected, since neither exercises the guarded branch. This confirms none of the new assertions are vacuous; each one that claims to pin the fix actually flips on revert. npx tsc --noEmitclean;npx eslint lib/Redis.ts test/unit/reconnectOnError.ts— 0 errors, 18 pre-existingmember-orderingwarnings unrelated to this diff.gh pr checks 2— all 19 CI jobs pass at96cf3e5.- Traced the "non-zero db" test (
test/unit/reconnectOnError.ts:206-236) by hand againstlib/Redis.ts:650-660(condition.selectis updated synchronously on write, before the reply arrives) andlib/Redis.ts:852-859(the guard comparescondition.selectto theitem.selectcaptured at write time) — the scenario is real:get("foo")is written against db 5,select(2)overwritescondition.selectbefore the READONLY reply forgetarrives, soitem.select (5) !== condition.select (2)and the restoringSELECT 5fires and is asserted to fail withINVALID_DB_INDEX. Confirmed by running it. - Confirmed the auto-pipelining test's premise:
selectis innotAllowedAutoPipelineCommands(lib/autoPipelining.ts:9-24), soredis.select(...)always returns a realCommandpromise even withenableAutoPipelining: true, which is what makes.catchreachable on it in that configuration. - Confirmed the sibling-fix precedent independently:
de2fbdf(redis#2187, merged 4 commits before base) addedtest/unit/resubscribe.tswith the sameunhandledRejection-recorder /PROTOCOLS=[3,2]/ per-protocol-port shape this PR's test reuses.
Boundaries ledger (rebuilt from the diff, not the PR body)
The only new/changed predicate in the code diff (unchanged since last review) is this.condition?.select !== item.select && item.command.name !== "select" at lib/Redis.ts:852-855, guarding the new .catch.
| Input dimension | Case | Code behavior | Test that pins it |
|---|---|---|---|
condition.select vs item.select |
equal (db unchanged) | skip restore, resend directly | does not restore the db when the failed command is itself a SELECT (also exercises item.command.name === "select") |
diverge, restoring SELECT succeeds |
restore, no error, resend | resends the command after a successful db restoration |
|
diverge, restoring SELECT rejected by server (RESP3/RESP2) |
.catch -> silentEmit("error", ...) |
surfaces a failing db-restoring SELECT as an error event |
|
diverge, restoring SELECT rejected inline (enableOfflineQueue:false, stream unwritable) |
.catch -> silentEmit("error", ...) |
surfaces an unwritable db-restoring SELECT as an error event... |
|
| restoring db value | db 0 (default) | covered above | same tests |
| non-zero db (5) | same guard, same catch | surfaces a failing db-restoring SELECT for a non-zero db |
|
| listener presence | error listener registered |
error reaches listener | most tests above |
no error listener |
silentEmit falls back to console.error, rejection still consumed |
surfaces a failing db-restoring SELECT when no error listener is registered |
|
| pipelining mode | normal (no autopipeline) | as above | all default-client tests |
enableAutoPipelining: true |
select still bypasses autopipelining (notAllowedAutoPipelineCommands), same .catch path |
surfaces a failing db-restoring SELECT when auto pipelining is enabled |
|
| protocol | RESP3 / RESP2 | identical guard, decode path differs | every test runs under both via the PROTOCOLS.forEach loop |
I don't see a reachable row this ledger surfaces that the tests miss. One dimension the ledger doesn't cover and the tests don't either: needReconnect === 2 combined with the restoring SELECT itself triggering a second reconnectOnError invocation (e.g. if the mock server also replied READONLY to SELECT). The PR's own comment at test/unit/reconnectOnError.ts:118-121 explains why the fixture deliberately avoids this (keys reconnectOnError on the READONLY message so the restoring SELECT's own failure, ERR DB index is out of range, doesn't re-enter case 2) — that's a reasonable scope boundary for a one-line fix, not a gap in this ledger, since re-entrant reconnectOnError recursion is pre-existing behavior untouched by this diff.
Assertion-vacuity check
Every it() with a discriminating claim was independently re-run against reverted lib/Redis.ts above and failed; the two tests that stay green on revert are explicitly labelled "Control" in their own comments and assert non-guarded paths, so that's expected, not a miss.
What's good
The rework is exactly the "strengthen the fixture, don't touch the fix" pattern this fleet has praised before: no new production code, five new fixture functions/tests that each target one previously-uncovered dimension (no listener, non-zero db, autopipelining, and two explicit controls). Comments in the diff (e.g. test/unit/reconnectOnError.ts:39-42, :118-121, :239-245) explain why each fixture is shaped the way it is, which is more useful than the code alone would be.
No blocking issues. Nothing here should hold up merge.
OSS-candidate maintainer's-eye read
This PR carries the oss-candidate label; the operator intends to submit it upstream. Read the touched module's recent history and the closest prior art independently (not from the PR body):
- Idiom match, confirmed:
de2fbdf(redis#2187, merged 4 commits before this base) is the closest prior art — same bug class (fire-and-forgetselect/subscribepromise -> unhandled rejection), same fix shape (.catch((err) => self.silentEmit("error", err))), and its own test filetest/unit/resubscribe.tsuses the identicalunhandledRejection-recorder /PROTOCOLS=[3,2]/ per-protocol-port-offset shape this PR'stest/unit/reconnectOnError.tsreuses. This PR is the same idiom, same author-style, same test shape as the maintainers' own immediately-preceding merge on this exact bug class. - Scope match: one logical line in production code (
lib/Redis.ts:856), one new focused test file. Matches the CONTRIBUTING.md exception for "small, isolated bug fixes where the cause and solution are clear and a focused test demonstrates the fix" — no upstream issue needed before submission. - I independently confirmed the bug from the base code, not from the PR's narrative:
lib/Redis.ts:856on based95d05aisthis.select(item.select);with no.catch,select()is a Command promise that can reject (server error, orsendCommandrejecting inline whenenableOfflineQueue:falseand the stream isn't writeable), and Node >=15's default--unhandled-rejections=throwmakes that fatal regardless of whether anerrorlistener is registered. Reverting only this line while keeping the new tests reproduces exactly that: 10 of 14 cases fail withmust not surface as an unhandled rejection. - Re-ran prior art myself:
gh search prs --repo redis/ioredis "unhandled rejection"-> redis#2187 is the only close match, already merged, doesn't cover this eighth site.gh search prs "handleReconnection"-> 0 results.gh search issues "reconnectOnError"-> issue redis#1664 (open, "reconnectOnError fails to handle errors stemming from Lua scripts") is adjacent (which errors reach the hook) but a different problem (what happens to the restoring SELECT) — citable, not competing. No existing helper beyondsilentEmit(already used) would be more idiomatic here. - Title style:
fix: <subject>matches the repo's commit-message convention (CONTRIBUTING.md "Commit messages and releases") and matchesde2fbdf's andd95d05a's own titles. - Changelog line: none needed by hand — this repo uses
semantic-releasedriven by commit message type (fix:), so no manual changelog entry is expected.
I have no idiom, scope, or prior-art objection. The ## Summary/## Upstream/## Bug/## Repro/## Fix/## Test evidence structure in the PR body is a description-format choice for the fork, not a finding — the operator's own upstream body can use redis/ioredis's own PR template shape instead; noting it for the operator, not as a defect.
SECOND READ: READY
The "failed command is itself a SELECT" case never entered the reconnectOnError branch at all: a plain SELECT that succeeds is not rejected, so handleReconnection is never called for it and none of its assertions could distinguish the guarded call from the bare one. Remove it rather than keep a case that pins nothing. Keep the successful-restoration case, which does execute the guarded call, and make that explicit: name it a control and assert on the "select" events so the restoring SELECT it is controlling for is observed rather than assumed.
Rework — both blocking findings addressed at
|
| Arm | Result |
|---|---|
Fix (d7b9d3a) |
12 passing |
Base (git checkout d95d05a -- lib/Redis.ts) |
2 passing / 10 failing — the 2 passes are exactly the control × RESP3/RESP2 |
Per-assertion discrimination re-run (first assertion neutralised to a tautology, recorder swapped for a sink, so only later assertions can fail): base 8 failing / 4 passing. The 4 are the control ×2 plus the single-assertion "no error listener" case ×2, whose only assertion was the one neutralised. Every other second assertion discriminates. Probe file deleted.
tsc --noEmit rc=0 · eslint ./lib test/unit/reconnectOnError.ts rc=0 (23 pre-existing member-ordering warnings, 0 errors) · prettier clean on the test file.
The PR body has been regenerated for this head: ## Test evidence now lists all six cases with per-arm results and marks the control, and ## Boundaries gains a row for enableAutoPipelining and records the item.command.name !== "select" guard as unreachable from the added .catch.
The verified label has been removed — it stood at the previous head.
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: ready for the operator — no blocking issues found.
I reviewed the live-head diff, the rework evidence, the base implementation at d95d05a, commit messages, the candidate facts sheet and boundaries ledger, and upstream-prior-art searches. The production change correctly consumes the previously fire-and-forget restoration-command rejection:
lib/Redis.ts:856-858
this.select(item.select).catch((err) =>
this.silentEmit("error", err)
);The new test exercises the reachable case 2 restoration path for RESP2 and RESP3, including server and offline-queue rejection sources. The formerly non-discriminating failed-SELECT case was removed; the remaining successful-restoration control explicitly observes the restoring SELECT through [2, 0] select events. The reported base-vs-head results discriminate the five regression cases, and all 21 CI checks are green.
What's good: this is a focused one-production-line correction using the established silentEmit("error", err) handling pattern, with clear evidence for the reconnection, protocol, database, offline-queue, and autopipelining 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 on this round — the removal is correct and the surviving control now pins something real.
What changed since the last round (96cf3e5 -> d7b9d3a)
Tests-only. lib/Redis.ts is byte-identical to the fix commit e4aaa23; only test/unit/reconnectOnError.ts moved, at the single hunk:
test/unit/reconnectOnError.ts:273-297 (removed, from 96cf3e5)
- it("does not restore the db when the failed command is itself a SELECT", async () => {
- // Control: green with and without the fix. `item.command.name !==
- // "select"` keeps the restoring call from firing at all here, so the
- // guarded line is never reached and the resent SELECT settles normally.
...
- const result = await redis.select(3);
and the surviving control renamed/extended:
test/unit/reconnectOnError.ts:299-320 (current, "resends the command after a successful db restoration (control)")
+ const selects: number[] = [];
+ redis.on("select", (db: number) => selects.push(db));
...
+ expect(
+ selects,
+ "the guarded restoring SELECT must have run, back to db 0"
+ ).to.eql([2, 0]);
Independent verification of the two judgment calls
Removal is justified. The removed test called redis.select(3) directly as the top-level command. handleReconnection (lib/Redis.ts:828) only runs when reconnectOnError returns non-false for a rejected command's error — the test's reconnectOnError is keyed on err.message.startsWith("READONLY") (test/unit/reconnectOnError.ts:118-119 in this version), and a plain SELECT 3 against serverAcceptingSelect (which always replies "OK") never rejects, so reconnectOnError is never invoked and case 2: — the branch containing the guarded .catch — is never entered. Its assertions (errors.eql([]), result.eql("OK"), selects.eql([3])) would hold identically whether the .catch exists or not, because the code path containing the .catch never runs. This is exactly the "assertion that holds with or without the fix" defect class; removing it is the right call and I could not construct a scenario where it exercised case 2.
Kept control now discriminates on something concrete. The renamed control (...(control)) still does not distinguish base from fix on error/rejection behavior (both arms are green there, by design, since the restoring SELECT succeeds). What's new is the selects.eql([2, 0]) assertion, which pins that the guarded call at lib/Redis.ts:856-858 actually fires and switches back to db 0 as part of this path (redis.get("foo") was issued after redis.select(2), so condition.select is 2 when the READONLY reply arrives, and item.select is 0). Without this assertion there was no proof the control ever reached the guarded line at all (a control that never executes the changed code proves nothing about non-interference). This addition is real and correctly closes that gap — I traced it against handleReconnection's guard (this.condition?.select !== item.select && item.command.name !== "select", lib/Redis.ts:852-855) and confirmed 2 !== 0 is true for this test, so the branch is taken.
Boundaries carried over from the fix (unchanged this round, re-checked for completeness)
The five regression cases + this control now cover: server-rejected SELECT (ReplyError), offline-queue-disabled inline rejection, no-error-listener fallback to silentEmit, non-zero db restoration, autopipelining timing, and successful restoration. I don't see a gap in the reachable inputs to case 2:'s guarded call given the two-way guard (condition.select !== item.select, command.name !== "select") — the removed test was the only one that exercised the command.name === "select" short-circuit, and it did so without ever entering case 2: in the first place, so no coverage is actually lost by dropping it.
What I checked
- Diffed
96cf3e5...d7b9d3a9directly (fetched both refs viagh api .../contents) rather than trusting the PR body's console transcript. - Traced
handleReconnectionat the current head (lib/Redis.ts:828-864) to confirm the removed test's command never reachescase 2:. - Confirmed
lib/Redis.tsis unchanged from thee4aaa23fix commit (still matches the seven sibling.catch((err) => self.silentEmit("error", err))sites inlib/redis/event_handler.ts:480-546byte-for-byte in style). - Re-ran prior art myself:
gh api repos/redis/ioredis/pulls?state=openfor reconnect/select-related PRs (only redis#2065, cluster subscriber reconnect — unrelated) and checked upstream'slib/Redis.tscommit history — no competing fix upstream. gh pr checks 2— 21/21 passing at the current head, full matrix (Node 20/22/24/26 x Redis 8.2/8.4/8.8/cluster/replica-set).- Did not re-run the suite locally this round; relied on CI plus static trace, consistent with the reduced-scope, tests-only diff.
Upstream fit (maintainer's-eye read)
- The fix line itself is idiomatic for this file — it's the same shape (
.catch((err) => this.silentEmit("error", err))) as the seven sites redis#2187 already established, so a maintainer has no basis to ask for a different treatment. - The test file's structure (per-protocol
describe,MockServerfixture,unhandledRejectionrecorder swap) is lifted fromtest/unit/resubscribe.ts(added by redis#2187), which is the right prior art to imitate here. - This round's commit message ("test: drop the non-discriminating select-command case") is honest about the correction and doesn't overstate scope — consistent with how this repo's fix PRs are titled (imperative, scoped to one behavior).
- No changelog line or separate issue filed for this fork PR; that's an upstream submission-process detail for the operator, not a fork-PR finding.
SECOND READ: READY
VerificationIndependent adversarial run against head A/B resultHead $ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts"
reconnectOnError db restoration (RESP3)
✔ surfaces a failing db-restoring SELECT as an error event (647ms)
✔ surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled (507ms)
✔ surfaces a failing db-restoring SELECT when no error listener is registered (596ms)
✔ surfaces a failing db-restoring SELECT for a non-zero db (591ms)
✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (593ms)
✔ issues one restoring SELECT for several commands dropped together (592ms)
✔ resends the command after a successful db restoration (control) (589ms)
reconnectOnError db restoration (RESP2)
✔ surfaces a failing db-restoring SELECT as an error event (588ms)
✔ surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled (505ms)
✔ surfaces a failing db-restoring SELECT when no error listener is registered (590ms)
✔ surfaces a failing db-restoring SELECT for a non-zero db (590ms)
✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (587ms)
✔ issues one restoring SELECT for several commands dropped together (588ms)
✔ resends the command after a successful db restoration (control) (592ms)
14 passing (8s)Base arm — reconnectOnError db restoration (RESP3)
1) surfaces a failing db-restoring SELECT as an error event
2) surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled
3) surfaces a failing db-restoring SELECT when no error listener is registered
4) surfaces a failing db-restoring SELECT for a non-zero db
5) surfaces a failing db-restoring SELECT when auto pipelining is enabled
6) issues one restoring SELECT for several commands dropped together
✔ resends the command after a successful db restoration (control) (592ms)
reconnectOnError db restoration (RESP2)
7) surfaces a failing db-restoring SELECT as an error event
8) surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled
9) surfaces a failing db-restoring SELECT when no error listener is registered
10) surfaces a failing db-restoring SELECT for a non-zero db
11) surfaces a failing db-restoring SELECT when auto pipelining is enabled
12) issues one restoring SELECT for several commands dropped together
✔ resends the command after a successful db restoration (control) (592ms)
2 passing (8s)
12 failing
6) reconnectOnError db restoration (RESP3)
issues one restoring SELECT for several commands dropped together:
must not surface as an unhandled rejection
+ expected - actual
-[
- "ReplyError: ERR DB index is out of range"
-]
+[]
at Context.<anonymous> (test/unit/reconnectOnError.ts:302:74)
at processTicksAndRejections (node:internal/process/task_queues:104:5)
12) reconnectOnError db restoration (RESP2)
issues one restoring SELECT for several commands dropped together:
must not surface as an unhandled rejection
+ expected - actual
-[
- "ReplyError: ERR DB index is out of range"
-]
+[]
at Context.<anonymous> (test/unit/reconnectOnError.ts:302:74)
at processTicksAndRejections (node:internal/process/task_queues:104:5)The 2 base-arm passes are exactly the one declared control × RESP3/RESP2. Every other case discriminates on both protocols. The test I added, and what it uncovered
My first draft asserted one error event per dropped command ( This also exercises boundary row 10 (guard equal) directly, which no previous test did. A test I wrote and then deletedI also wrote an eighth case aimed at reconnectOnError db restoration (RESP3)
PROBE-CASE2 condsel=2 itemsel=0 name=get
✔ does not crash when the client is closed while the restoring SELECT is still in flight (647ms)
reconnectOnError db restoration (RESP2)
PROBE-CASE2 condsel=2 itemsel=0 name=get
✔ does not crash when the client is closed while the restoring SELECT is still in flight (607ms)
2 passing (1s)The branch is entered, but Checks$ npx prettier --check test/unit/reconnectOnError.ts
All matched files use Prettier code style!
$ npx eslint test/unit/reconnectOnError.ts # rc=0, no output
$ npx tsc --noEmit # rc=0, no outputFork CI at Behaviour outside the stated bugNone found. Verified. The PR body has been updated to describe this head. |
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 operator submission; no blocking issues found.
I reviewed the live head f5002bc369193338d1568f9cfbb6f6f47b353252 against base d95d05a964be3687b01381224753f82c23427177, including both changed files and the OSS-candidate facts sheet. The base has the unhandled call at lib/Redis.ts:856 (this.select(item.select);); the changed lines attach a rejection handler and send it to the established error sink:
lib/Redis.ts:856-858this.select(item.select).catch((err) => this.silentEmit("error", err) );
The test covers both RESP arms and distinguishes the fix: its error/rejection cases fail on the base arm and pass at head, while the explicitly labelled successful-restoration control also asserts [2, 0] select events, demonstrating that it actually reaches the guarded restoration call. The one added expression has no new predicate or index boundary; the submitted boundary ledger covers its rejection sources, listener/no-listener behavior, db-zero/nonzero, protocol variants, autopipelining, and multi-command reconnect behavior.
I also independently traced the reported base behavior in the fetched base file, checked the candidate’s stated policy and test evidence, repeated upstream open/closed PR and issue searches for the reported failure, and found no duplicate. Commit text contains no prohibited AI attribution. CI is green: all 21 reported checks pass at this head.
What's good: this is a minimal, behavior-preserving error-handling fix that follows the existing silentEmit pattern, with unusually strong regression and boundary evidence.
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 — the fix is correct, minimal, and the new regression coverage is genuine (independently re-verified below).
What I checked
- Re-read
lib/Redis.ts:853-861at headf5002bc. The change wraps the previously fire-and-forgetthis.select(item.select);incase 2:ofhandleReconnectionwith.catch((err) => this.silentEmit("error", err)). - Confirmed by direct grep that this is the exact treatment already applied to every sibling restoring-
SELECTcall site added by upstream PR redis#2187 (lib/redis/event_handler.ts:53,480,519,545,554, e.g. line 519:.select(item.select).catch((err) => self.silentEmit("error", err));). The guard predicate atlib/Redis.ts:856(this.condition?.select !== item.select && item.command.name !== "select") is byte-identical to the guard atevent_handler.ts:515-517, so this is the same construct in a different file, not a new pattern. - Cloned the branch and ran the new suite (
test/unit/reconnectOnError.ts, 7 cases × RESP3/RESP2) myself rather than trusting the PR body's pasted output:- At head
f5002bc: 14 passing, matching the PR's claim. - With
lib/Redis.tsreverted to base (git checkout d95d05a -- lib/Redis.ts) and only the newest test re-run (issues one restoring SELECT for several commands dropped together, added since the last review's headd7b9d3a): 2 failing (both protocol arms), with the exactmust not surface as an unhandled rejectiondiff the PR claims —ReplyError: ERR DB index is out of rangeleaking as an unhandled rejection. This is a genuine regression case, not vacuous. tsc --noEmitclean; worktree left clean (git status --porcelainempty) after restoring the source.
- At head
- Verified the "issues one restoring SELECT..." test's premise directly in source:
sendCommand(lib/Redis.ts:655-662) updatescondition.selectsynchronously and emits"select"whenever a SELECT changes the connection's db — this is what makes the guard self-disarm after the first restore and is also what the control test'sselectsarray (test/unit/reconnectOnError.ts:354-355,365-368) observes to prove the guarded line actually ran, rather than assuming it. gh pr checks 2— 21/21 pass atf5002bc(run 34885625208), including the live-Redis functional/cluster lane across Node 20/22/24/26 — the lane this review environment cannot run.- Confirmed this is a fresh, non-duplicate head: my last review of this PR was at
d7b9d3a; the diff since then (d7b9d3a..f5002bc) is test-only (one new case added,lib/Redis.tsuntouched — byte-identical to the fix commite4aaa23).
Findings
None. The production change is a single line, matches established project idiom exactly, and every new test case was independently confirmed to discriminate (fails on base, passes with the fix) except the labelled control, which is correctly documented as green-on-both-arms by design and is pinned by its own selects assertion rather than relying on the surrounding test's pass/fail alone.
What's good
- Minimal, surgical diff (one production line) that reuses an existing sink (
silentEmit) instead of inventing new error-handling behavior. - The "several commands dropped together" test (
test/unit/reconnectOnError.ts:294-335) pins a real, non-obvious invariant (the guard self-disarms after the first restore, so N dropped commands produce exactly one error event, not N) and I confirmed both halves of that claim againstsendCommand's synchronouscondition.selectupdate. - The control test doesn't just accept both-arms-green silently — it records the
selectevent stream and asserts[2, 0], which proves the guarded line executed rather than merely not crashing.
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 supersedes my prior COMMENTED review at this same head (f5002bc), which was missing the required OSS-candidate verdict line — the content below is the completed review, not a re-review of new changes.
What I checked
- Diff at head
f5002bcis two files:lib/Redis.ts:853-861(production, one guarded call wrapped in.catch) and the newtest/unit/reconnectOnError.ts(355 lines, 7 cases × RESP3/RESP2). lib/Redis.ts:853-861:Confirmed this matches the sibling treatment atif ( this.condition?.select !== item.select && item.command.name !== "select" ) { this.select(item.select).catch((err) => this.silentEmit("error", err) ); }
lib/redis/event_handler.ts:519and:545(.select(item.select).catch((err) => self.silentEmit("error", err));), added by upstream PR redis#2187 (merged 2026-09-10, four commits before this base). Independently fetched redis#2187's body: it explicitly names "the two SELECT calls in the resend and offline-queue loops" as the remaining, uncovered sibling call sites — this PR is exactly one of those two. Prior art is genuine, not asserted.- Traced
silentEmit(lib/Redis.ts:781-808): emits to registerederrorlisteners, suppresses connection-close noise whilemanuallyClosing, else falls back toconsole.error. This is the same sink used at every other restoration call site, so the change introduces no new error-handling policy. - Re-ran the new suite myself against base (
git checkout d95d05a -- lib/Redis.ts, only the newest case since last review): the "several commands dropped together" case fails on both protocol arms withReplyError: ERR DB index is out of rangeleaking as an unhandled rejection, and passes at head. Matches the PR's own base/head transcripts. selectis confirmed present inlib/autoPipelining.ts:9-25'snotAllowedAutoPipelineCommands, which backs the "auto pipelining enabled" test's premise that the restoringSELECTstill goes throughsendCommand/.catchrather than the auto-pipeline path.gh pr checks 2: 21/21 pass atf5002bc(run 34885625208), covering Node 20/22/24/26 against live Redis 8.2/8.4.0/8.8.0/custom-debian/rs-7.4.0-v1.- Searched upstream
redis/ioredisfor prior art / duplicates: no open PR or issue targets this call site; the only related closed history is redis#2187 (the seven-site sweep this PR completes) and redis#2194 (unrelated, merged same base range).
Findings
None that block. One observation for the record, not a defect: the "surfaces a failing db-restoring SELECT when no error listener is registered" test (test/unit/reconnectOnError.ts:183-201) has a single assertion (unhandled is empty) and no client error listener is attached, so it cannot separately distinguish "rejection reached silentEmit's console.error fallback" from "rejection vanished silently" — both outcomes look identical to this assertion. It still correctly discriminates against the base bug (unhandled rejection vs. none), which is the case it's named for, so this doesn't affect the verdict.
What's good
- Minimal, single-line production change reusing an established sink (
silentEmit) rather than introducing new error-handling behavior. - Test suite distinguishes real regression coverage from its control: the control (
test/unit/reconnectOnError.ts:339-369) is explicitly labelled and asserts theselectevent stream ([2, 0]) to prove the guarded line actually executed, rather than relying on both-arms-green alone. - "Several commands dropped together" case pins a real, non-obvious invariant (the guard self-disarms after the first restore) and I confirmed the mechanism (
sendCommand's synchronouscondition.selectupdate,lib/Redis.ts:655-662) independently rather than taking the test's comment at face value.
Boundaries ledger (rebuilt from the diff)
| Predicate / guard changed | Inputs exercised | Code behavior | Test pinning it |
|---|---|---|---|
this.select(item.select).catch(...) — rejection now handled vs. unhandled |
server rejects SELECT (ERR DB index is out of range) |
routed to silentEmit("error", err) instead of escaping |
case 1 (:129-146), both RESP arms |
same, rejection source = inline (enableOfflineQueue:false, stream not writeable) |
sendCommand rejects before write |
same sink | case 2 (:148-165), both arms |
same, no error listener registered |
nothing listening | falls back to console.error; single assertion only proves "no unhandled rejection" (see Findings) |
case 3 (:183-201), both arms |
| restored db = non-zero (5→2) vs. the other cases' 0 | db 5 rejected specifically | same sink, error message is the restoring SELECT's own, not the READONLY trigger | case 4 (:203-229) |
auto-pipelining enabled — does select still route through sendCommand/.catch rather than the auto-pipeline batch path |
enableAutoPipelining: true |
confirmed via notAllowedAutoPipelineCommands including "select" |
case 5 (:231-260) |
guard this.condition?.select !== item.select re-evaluated per dropped command — disarms after first restore |
3 commands dropped together, only first crosses the guard | exactly 1 error event, all 3 resent | case 6 (:262-335) |
successful restore path (guard true, .catch never fires) |
SELECT accepted | no error event, resent command settles, select events [2,0] prove the guarded line ran |
control (:339-369), explicitly labelled both-arms-green |
Every reachable predicate state I could identify from the diff (rejection via server reply, rejection via inline write failure, no-listener fallback, non-zero db, auto-pipeline path, repeated-drop disarm, and the successful/no-op path) has a corresponding case. I found no missed boundary and no case whose assertions are vacuous with the fix reverted.
SECOND READ: READY
VerificationThird independent adversarial pass, at head
1. The inherited claims reproduce exactlyBefore attacking anything I re-executed the body's numbers at the head I was handed ( $ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts"
14 passing (8s)
$ git checkout d95d05a964be3687b01381224753f82c23427177 -- lib/Redis.ts
$ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts"
2 passing (8s)
12 failingThe 2 base-arm passes are exactly 2. A hole in the ledger: row 8 was not unreachableBoundary rows 8 and 9 ( There is a second one. With PROBE-PRE-DISCONNECT status=reconnecting offline=2 command=0If the client then stops retrying, $ # base (git checkout d95d05a -- lib/Redis.ts)
PROBE-END status=end failing="Connection is closed." errors=[] unhandled=["Error: Connection is closed."]
$ # head
PROBE-END status=end failing="Connection is closed." errors=[] unhandled=[]So this was a genuine untested crash route — reachable by anyone running a bounded New test (case 7, both protocols): "surfaces a db-restoring SELECT abandoned when the client Row 9 is now grounded in code rather than in a probe, and it is genuinely unreachable from this 3. A/B at the new head — every test, both arms$ # ---------- HEAD f2ef94f ----------
reconnectOnError db restoration (RESP3)
✔ surfaces a failing db-restoring SELECT as an error event (670ms)
✔ surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled (505ms)
✔ surfaces a failing db-restoring SELECT when no error listener is registered (593ms)
✔ surfaces a failing db-restoring SELECT for a non-zero db (592ms)
✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (597ms)
✔ issues one restoring SELECT for several commands dropped together (589ms)
✔ surfaces a db-restoring SELECT abandoned when the client gives up reconnecting (308ms)
✔ resends the command after a successful db restoration (control) (590ms)
reconnectOnError db restoration (RESP2)
✔ surfaces a failing db-restoring SELECT as an error event (589ms)
✔ surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled (506ms)
✔ surfaces a failing db-restoring SELECT when no error listener is registered (591ms)
✔ surfaces a failing db-restoring SELECT for a non-zero db (589ms)
✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (589ms)
✔ issues one restoring SELECT for several commands dropped together (596ms)
✔ surfaces a db-restoring SELECT abandoned when the client gives up reconnecting (304ms)
✔ resends the command after a successful db restoration (control) (590ms)
16 passing (9s)$ # ---------- BASE ARM: git checkout d95d05a -- lib/Redis.ts, all 8 cases in place ----------
reconnectOnError db restoration (RESP3)
1) surfaces a failing db-restoring SELECT as an error event
2) surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled
3) surfaces a failing db-restoring SELECT when no error listener is registered
4) surfaces a failing db-restoring SELECT for a non-zero db
5) surfaces a failing db-restoring SELECT when auto pipelining is enabled
6) issues one restoring SELECT for several commands dropped together
7) surfaces a db-restoring SELECT abandoned when the client gives up reconnecting
✔ resends the command after a successful db restoration (control) (590ms)
reconnectOnError db restoration (RESP2)
8) surfaces a failing db-restoring SELECT as an error event
9) surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled
10) surfaces a failing db-restoring SELECT when no error listener is registered
11) surfaces a failing db-restoring SELECT for a non-zero db
12) surfaces a failing db-restoring SELECT when auto pipelining is enabled
13) issues one restoring SELECT for several commands dropped together
14) surfaces a db-restoring SELECT abandoned when the client gives up reconnecting
✔ resends the command after a successful db restoration (control) (590ms)
2 passing (9s)
14 failingThe new case fails on base as redis#7 (RESP3) and redis#14 (RESP2): 7) reconnectOnError db restoration (RESP3)
surfaces a db-restoring SELECT abandoned when the client gives up reconnecting:
must not surface as an unhandled rejection
+ expected - actual
-[
- "Error: Connection is closed."
-]
+[]
at Context.<anonymous> (test/unit/reconnectOnError.ts:342:74)Every test on the branch except the one declared control fails on base, in both protocol arms. 4. Toolchain at this head$ npx tsc --noEmit
TSC rc=0
$ npx eslint ./lib test/unit/reconnectOnError.ts
✖ 23 problems (0 errors, 23 warnings) # pre-existing member-ordering/unused-vars in lib/, none in changed files
$ npx prettier --check test/unit/reconnectOnError.ts
All matched files use Prettier code style!
5. Fork CI at this head: 21/21 passRun 34914040168 — the full $ gh pr checks 2 --repo askalf/ioredis | awk -F'\t' '{print $2}' | sort | uniq -c
21 passOne job on the first attempt — 6. Behaviour outside the stated bugNone. The diff is still the single Verdict: verified 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: ready for the operator to submit; no blocking issues found.
I reviewed the production hunk and the complete new reconnect regression suite, traced the base implementation at d95d05a964be3687b01381224753f82c23427177, and checked the candidate facts sheet, boundary ledger, upstream policy, prior-art searches, and commit messages. The new handler is confined to the previously fire-and-forget promise and preserves the existing resend path; the new offline-queue-abandonment case directly covers the previously missing settled-rejection route. The regression evidence states 14 failures/2 declared-control passes on base and 16 passes at this head. Fork CI is green: 21/21 checks passed, including the functional/cluster matrix.
What's good: the successful-restoration control asserts the observable select sequence [2, 0], so it proves the guarded restoration call is actually reached rather than merely passing vacuously. The candidate also records the rejected vacuous-test attempts and distinguishes the terminal offline-queue route from a server-held promise that never settles.
Notes for the operator: the fork title's [oss-candidate] prefix is pipeline metadata; use the suggested upstream title and adapt the supplied facts to the upstream PR description when submitting.
I did not run the local test suite, per reviewer environment policy; CI and the documented executed evidence were used for test signal.
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; the fix is minimal, correct, and matches established project idiom.
What I checked
- Diff at head
f2ef94f(lib/Redis.tsunchanged sincee4aaa23; this head only adds one regression test case,test/unit/reconnectOnError.ts+35 lines, to case 7 — "surfaces a db-restoring SELECT abandoned when the client gives up reconnecting"). - Verified independently against upstream
redis/ioredis:- PR redis#2187 (
de2fbdf, merged 2026-09-10) does add the identical.catch((err) => self.silentEmit("error", err))guard atlib/redis/event_handler.ts:518-520,:544-546,:479-481,:554for the siblingselect/subscribe/psubscribe/ssubscriberestoration call sites — confirmed by reading those lines directly in the cloned tree. - Base commit
d95d05a(PR redis#2194, "reject unfulfilled commands dropped on reconnect", merged 2026-09-14) is exactly the stated base and leaveslib/Redis.ts:856(this.select(item.select);) unguarded — confirmed viagit diff d95d05a f2ef94f -- lib/Redis.ts, which shows only the one-line-to-three-line change described. silentEmit(lib/Redis.ts:781-811) does what the PR claims: emits toerrorlisteners if any, suppresses connection-close noise whilemanuallyClosing, logs viaconsole.errorotherwise.
- PR redis#2187 (
test/unit/reconnectOnError.tsfollows the same structure astest/unit/resubscribe.ts(added by redis#2187 itself): sameunhandledRejection-recorder pattern inbeforeEach/afterEach, sameMockServer+PROTOCOLS = [3, 2]loop. This is the idiom the upstream maintainer already accepted for this exact bug class.gh pr checks 2: all CI jobs pass (test matrix across Node 20/22/24/26 × several Redis versions, plus coverage).
Boundaries ledger (rebuilt from the diff)
The only new predicate touching runtime behavior is the pre-existing guard at lib/Redis.ts:852-855 (unchanged by this PR) gating a .select(item.select).catch(...) call that is new. There's no new conditional added — the change is purely "attach .catch to an existing call." Ledger of the reachable states for that call:
| Input / state | Behavior | Pinned by |
|---|---|---|
Restoring SELECT rejected by server (bad db index) |
.catch routes to silentEmit("error", ...), no crash |
case 1 (test/unit/reconnectOnError.ts:145-160) |
Restoring SELECT rejected inline (enableOfflineQueue: false, unwritable stream) |
same | case 2 |
No error listener registered |
silentEmit falls back to console.error, rejection still consumed |
case 3 |
| Restore to non-zero db | same guarded path, different item.select value |
case 4 |
| Auto-pipelining enabled | .select still returns a promise via sendCommand (select is in notAllowedAutoPipelineCommands) |
case 5 |
| Several dropped commands restoring the same db | only first restore attempted per the pre-existing condition.select sync in sendCommand |
case 6 |
Client gives up reconnecting (retryStrategy: () => null) while restoring SELECT is queued |
closeHandler's close() flushes queue with CONNECTION_CLOSED_ERROR_MSG, .catch still catches it |
case 7 (added at this head) |
| Restore succeeds (control) | unchanged happy path, no spurious error, resent command settles | case 8 |
I don't find a reachable case this ledger or the test suite misses — the guarded call is a single .select().catch() with no new branching, and case coverage matches the sibling call sites' rejection sources one-for-one (server rejection, inline rejection, closed-connection rejection).
Assertion check
Each of cases 1–7 asserts unhandled (the recorded unhandledRejection list) .to.eql([]), which is the assertion that actually distinguishes fixed from broken: on base (this.select(item.select); with no .catch), the PR's own repro output shows these exact cases failing with unhandled populated. Case 8 (control) does not assert on unhandled failing on base — it's designed to pass on both arms, and its assertions (selects sequence, no error emitted, command resolves) are about the happy path being undisturbed, not about the fix. That's an intentional and correctly labeled control, not a vacuous regression assertion.
What's good
- Single-line functional change, in the same idiom as 7 already-merged sibling fixes at
lib/redis/event_handler.ts. - Test file mirrors the structure of the PR that established this idiom (
resubscribe.ts), reusing itsunhandledRejection-swap pattern rather than inventing a new one. - Alternatives (
void,await, rejecting the command) are considered and correctly rejected in the PR body —awaitin particular would be a real behavior change tohandleReconnection's synchronous execution, correctly avoided. - No CHANGELOG.md edit needed or expected — the file is machine-generated via semantic-release commit links, not hand-maintained.
SECOND READ: READY
The restoring SELECT rejected by the server reaches the error listener instead of escaping as an unhandled rejection, under both protocols. The other cases were checked during verification and are not committed.
Verification at 652b8f0Rebuilt the boundary ledger from the one-expression production diff, then executed the committed test on both arms. Same test file on base
Rules: reads-as-generated=covered(whole added diff grep and check); mutate-the-rejected-alternatives=unreachable(no requirement to prove the alternative prose; body now only describes the applied fix); prior-art-recheck-at-gate=covered(upstream Redis.ts commit b597303 diff and distinct issue); no-control-cases-in-the-suite=covered(base arm, 0 passing); test-comment-density-matches-neighbours=covered(resubscribe.ts harness); deleted-tests-named-by-what-they-called=covered(trim diff and body: extra test paths executed as uncommitted boundary probes); tests-sized-to-the-bug=covered(one regression under two protocols); ci-red-in-untouched-files=covered(docker:setup job fails before tests); run-every-ci-step-not-just-the-red-one=covered(tsc, eslint, prettier and 19 matrix jobs); base-arm-revert-committed=unreachable(separate base worktree, head clean) |
sprayberry-redline
left a comment
There was a problem hiding this comment.
Approve.
The restoring SELECT now forwards rejection through silentEmit, matching the other restoration paths without changing command resend ordering. The regression uses the neighbouring MockServer harness and distinguishes base from head under both protocols.
Notes for the operator: CI has 19 passing jobs; the Node 24 / Redis 8.8.0 job was cancelled on rerun, and coverage was skipped, so this head is not fully green. Carry the contribution-policy guidance and disclosure facts into the upstream submission body.
Summary
handleReconnectionrestores a command's database before resending it whenreconnectOnErrorreturns2. If the restoringSELECTrejects, its fire-and-forget promise formerly caused an unhandled rejection even with a clienterrorlistener. Attach.catch((err) => this.silentEmit("error", err)), matching the other restoration sites inlib/redis/event_handler.ts. One regression case runs under RESP3 and RESP2 against the real client and aMockServer.Bug and repro
With a command issued against db 0, switch the connection's selected db to 2 before its
READONLYresponse is handled. The reconnect hook requests resending, and the restoringSELECTfails withERR DB index is out of range. At based95d05a964be3687b01381224753f82c23427177, both protocol arms collect an unhandled rejection; the new test fails. At head652b8f051c0713c04b346461066eac9f5c58f220, the error reaches the client listener and neither protocol collects an unhandled rejection.Fix
In
lib/Redis.ts, the singlethis.select(item.select);expression inhandleReconnectionbecomesthis.select(item.select).catch((err) => this.silentEmit("error", err));. The resend after it remains unconditional and synchronous. Other restoration paths already usesilentEmit, including the two closest guardedSELECTcalls inlib/redis/event_handler.tsintroduced by upstream PR 2187. No other production behaviour is changed deliberately.Test evidence
One committed test,
surfaces a failing db-restoring SELECT as an error event, runs once each under RESP3 and RESP2 intest/unit/reconnectOnError.ts. Neighbouring unit tests use the sameMockServerharness. Base arm is a separate worktree atd95d05a, with this exact test file; head arm is652b8f0. Command in each worktree (environment:HOME=/agent-workspace/tmphome TMPDIR=/agent-workspace/tmp npm_config_cache=/agent-workspace/.npmcache CARGO_BUILD_JOBS=2 RUST_TEST_THREADS=2 GOMAXPROCS=2 GOFLAGS=-p=2 MAKEFLAGS=-j2 TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test):./node_modules/.bin/mocha --no-experimental-strip-types 'test/unit/reconnectOnError.ts'Boundaries
The ledger is rebuilt from the changed expression and its guard. For boundary verification, the uncommitted eight-case fixture from
f2ef94fwas executed in a temporary worktree at that commit.git hash-objectconfirmed itslib/Redis.tsblobe4e00ac181169694155bfc3273fd88436a384069is byte-identical to current head. Run with the same environment above and./node_modules/.bin/mocha --no-experimental-strip-types 'test/unit/reconnectOnError.ts': 16 passing (9s) (eight cases under both protocols). The boundary fixture is not in this PR diff; one diagnostic regression remains committed. The base-arm discriminating output for the committed case is above; prior eight-case base results atd95d05awere 2 controls passing / 14 failing (historical, from verification atf2ef94f).surfaces a failing db-restoring SELECT as an error event, 2 PASS; base 2 FAILErrorsurfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled, 2 PASSsurfaces a failing db-restoring SELECT when no error listener is registered, 2 PASS; emits fallback diagnosticsilentEmitfallback consumes the rejection.surfaces a failing db-restoring SELECT for a non-zero db, 2 PASSsurfaces a failing db-restoring SELECT when auto pipelining is enabled, 2 PASSSELECTstill returns a catchable promise.issues one restoring SELECT for several commands dropped together, 2 PASSstatus === "end"surfaces a db-restoring SELECT abandoned when the client gives up reconnecting, 2 PASSresends the command after a successful db restoration, 2 PASS (also passed on base); this is an uncommitted boundary probe, not a control in the PRitem.command.name === "select"or hook not returning2conditionabsent at reconnectionconnect()assigns it before the connected-clientcase 2pathmanuallyClosingduring queue flushcloseHandlerclears the flag before the flush (event_handler.ts:384-388)status === "end"arm above; other races are not claimed covered.Verification method
At current head
652b8f0, focused test file and neighbouringtest/unit/autoPipelining.ts: 5 passing (1s).tsc --noEmit: exit 0. ESLint on both touched files: 0 errors, 18 existingmember-orderingwarnings inlib/Redis.ts. Prettier on the test file:All matched files use Prettier code style!. Container lacks the live Redis/Docker setup for the functional/cluster suites, so no whole suite was run locally. The fork's CI does run those suites.gh pr checks 2 --repo sprayberry-code/ioredisat this head, run37361884456: 19 pass, 1 fail, 1 skipping. The failingtest / test (24.x, 8.8.0)failed on attempt 1 atRun npm run docker:setup, before npm install, lint, build, test:cov or test:cluster; its rerun on attempt 2 was cancelled after 15 minutes without a completed test result. This fork infrastructure job is unrelated on inspection, not re-confirmed green.test / code-coveragewas skipped. Do not describe the fork as all green at this head.lib/Redis.tsis byte-identical to the reviewed fix atf2ef94f; that earlier head's CI was 21/21 pass, but is historical evidence only.Prior art and policy
Upstream PR 2187 handles rejected restoration commands at sibling call sites in
lib/redis/event_handler.tsbut not thehandleReconnectioncall inlib/Redis.ts. Upstream commitb59730310716(PR 2204) touchesRedis.tsbut only resetsretryAttemptswhenconnect()is called fromend; it does not touch this SELECT. The open upstream PRs in this area concern different issues.AGENTS.mdwelcomes coding agents; no CLA or DCO was found. This fork PR does not contact upstream.test/unitusesMockServerand does not require live Redis. No generated code or changelog is changed.Suggested upstream PR title
fix: handle rejection from the db-restoring select on reconnectOnError