Skip to content

[oss-candidate] fix: handle rejection from the db-restoring select on reconnectOnError - #2

Open
askalf wants to merge 6 commits into
mainfrom
fix/handshake-select-unhandled-rejection
Open

askalf wants to merge 6 commits into
mainfrom
fix/handshake-select-unhandled-rejection

Conversation

@askalf

@askalf askalf commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

handleReconnection restores a command's database before resending it when reconnectOnError returns 2. If the restoring SELECT rejects, its fire-and-forget promise formerly caused an unhandled rejection even with a client error listener. Attach .catch((err) => this.silentEmit("error", err)), matching the other restoration sites in lib/redis/event_handler.ts. One regression case runs under RESP3 and RESP2 against the real client and a MockServer.

Bug and repro

With a command issued against db 0, switch the connection's selected db to 2 before its READONLY response is handled. The reconnect hook requests resending, and the restoring SELECT fails with ERR DB index is out of range. At base d95d05a964be3687b01381224753f82c23427177, both protocol arms collect an unhandled rejection; the new test fails. At head 652b8f051c0713c04b346461066eac9f5c58f220, the error reaches the client listener and neither protocol collects an unhandled rejection.

Fix

In lib/Redis.ts, the single this.select(item.select); expression in handleReconnection becomes this.select(item.select).catch((err) => this.silentEmit("error", err));. The resend after it remains unconditional and synchronous. Other restoration paths already use silentEmit, including the two closest guarded SELECT calls in lib/redis/event_handler.ts introduced 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 in test/unit/reconnectOnError.ts. Neighbouring unit tests use the same MockServer harness. Base arm is a separate worktree at d95d05a, with this exact test file; head arm is 652b8f0. 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'
BASE: 0 passing (1s), 2 failing
RESP3: AssertionError: expected [ Array(1) ] to deeply equal []
  -["ReplyError: ERR DB index is out of range"]  +[]
RESP2: AssertionError: expected [ Array(1) ] to deeply equal []
  -["ReplyError: ERR DB index is out of range"]  +[]
HEAD: 2 passing (1s)
  RESP3: ✔ surfaces a failing db-restoring SELECT as an error event (662ms)
  RESP2: ✔ surfaces a failing db-restoring SELECT as an error event (592ms)

Boundaries

The ledger is rebuilt from the changed expression and its guard. For boundary verification, the uncommitted eight-case fixture from f2ef94f was executed in a temporary worktree at that commit. git hash-object confirmed its lib/Redis.ts blob e4e00ac181169694155bfc3273fd88436a384069 is 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 at d95d05a were 2 controls passing / 14 failing (historical, from verification at f2ef94f).

Boundary from the diff Verification case and output at the identical production blob Result
Async server rejection, db 0 (falsy), RESP3 / RESP2 surfaces a failing db-restoring SELECT as an error event, 2 PASS; base 2 FAIL Error is forwarded to the client, not unhandled.
Inline rejection with offline queue disabled, plain Error surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled, 2 PASS Rejected promises are handled even without a socket write.
No client error listener surfaces a failing db-restoring SELECT when no error listener is registered, 2 PASS; emits fallback diagnostic The library's silentEmit fallback consumes the rejection.
Nonzero original db; db switch before / after rejected command (reverse order) surfaces a failing db-restoring SELECT for a non-zero db, 2 PASS Restore is attempted with nonzero index; the reversed sequence is handled.
Auto pipelining surfaces a failing db-restoring SELECT when auto pipelining is enabled, 2 PASS SELECT still returns a catchable promise.
Several dropped commands; equal selected db after first restore issues one restoring SELECT for several commands dropped together, 2 PASS First SELECT changes the selected-db state; later commands skip restoration.
Offline SELECT abandoned on exhausted reconnect; status === "end" surfaces a db-restoring SELECT abandoned when the client gives up reconnecting, 2 PASS Queue-flush error is consumed, with no emitted error in the ended state.
Successful restoring SELECT resends the command after a successful db restoration, 2 PASS (also passed on base); this is an uncommitted boundary probe, not a control in the PR Resend behaviour is unchanged.
item.command.name === "select" or hook not returning 2 Guard / switch bypasses the changed expression Unchanged by construction; existing unit and functional tests cover unrelated branches.
condition absent at reconnection connect() assigns it before the connected-client case 2 path Unreachable via the public API.
manuallyClosing during queue flush closeHandler clears the flag before the flush (event_handler.ts:384-388) That route enters the status === "end" arm above; other races are not claimed covered.

Verification method

At current head 652b8f0, focused test file and neighbouring test/unit/autoPipelining.ts: 5 passing (1s). tsc --noEmit: exit 0. ESLint on both touched files: 0 errors, 18 existing member-ordering warnings in lib/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/ioredis at this head, run 37361884456: 19 pass, 1 fail, 1 skipping. The failing test / test (24.x, 8.8.0) failed on attempt 1 at Run 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-coverage was skipped. Do not describe the fork as all green at this head. lib/Redis.ts is byte-identical to the reviewed fix at f2ef94f; 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.ts but not the handleReconnection call in lib/Redis.ts. Upstream commit b59730310716 (PR 2204) touches Redis.ts but only resets retryAttempts when connect() is called from end; it does not touch this SELECT. The open upstream PRs in this area concern different issues. AGENTS.md welcomes coding agents; no CLA or DCO was found. This fork PR does not contact upstream. test/unit uses MockServer and 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

`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.
@askalf askalf added the oss-candidate Sprayberry Code candidate for upstream label Sep 14, 2026
@askalf
askalf marked this pull request as ready for review September 14, 2026 13:44

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: 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 restoring SELECT rejection without changing the subsequent resend path.
  • test/unit/reconnectOnError.ts:105-112 and :125-132 cover 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 sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

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

Verdict: 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, ran test/unit/reconnectOnError.ts: 4 passing with the fix.
  • Reverted just lib/Redis.ts to base d95d05a (test file kept): 4 failing, both protocol arms, same messages the PR body reports (ERR DB index is out of range unhandled, Stream isn't writeable... unhandled).
  • Reproduced the per-assertion discrimination check myself (neutered the unhandled assertion into a tautology in a scratch copy, reran against base): the second assertion (errors includes the message) independently fails on base in all 4 cases. Neither assertion in either variant is vacuous.
  • npx tsc --noEmit clean.
  • Confirmed lib/redis/event_handler.ts:53,480,487,494,502,519,545,554 all route through .catch((err) => self.silentEmit("error", err)) via merged commit de2fbdf (PR redis#2187) in this fork's history — matches the "eighth site" claim in the PR body. Verified de2fbdf also exists in upstream redis/ioredis history, 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.
@askalf askalf added the verified Adversarially verified by a fresh run label Sep 14, 2026
@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial verification — askalf/ioredis #2

Fork PR: #2
Branch: fix/handshake-select-unhandled-rejection
Head at start of verification: e4aaa237d6372b87c95962f7f9f96e975d043be4
Head after verification: 96cf3e5 (2 extra tests committed to the same branch; the fix commit e4aaa23 is untouched)
Base: d95d05a (origin/main)
Upstream: redis/ioredis, default branch main

Verification method: executed. Node v24.19.0, mocha 11.7.6, in
/agent-workspace/oss/ioredis-wt-1789396101.
Env: npm_config_cache=/agent-workspace/.npmcache HOME=/agent-workspace/tmphome,
TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types <file>.

Nothing in this report is taken from the PR body. The Boundaries ledger was
rebuilt from the diff, and every claim below is something I ran.


Verdict

Everything in the candidate holds. The fix is correct, minimal, and the
original four tests all discriminate against base — including their second
assertions, which I checked separately because mocha stops at the first
failing assertion.

One genuine hole was found in the ledger and closed: the diff's guarded call
behaves differently under enableAutoPipelining, and nothing tested it. My
first attempt at that test passed on both arms — I caught it with an
instrumented build rather than shipping it. Details in "The vacuous test"
below; that is the part of this report worth a maintainer's attention.

verified label applied.


The diff under test

lib/Redis.ts:852-858, in handleReconnection's case 2:
(reconnectOnError returning 2 = reconnect, then resend the failed command):

         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,
which is fatal on Node >= 15 even with an error listener registered.


1. Test file, both arms

Head 96cf3e5 — 14/14 passing

$ npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts"

  reconnectOnError db restoration (RESP3)
    ✔ surfaces a failing db-restoring SELECT as an error event (649ms)
    ✔ 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 (593ms)
    ✔ surfaces a failing db-restoring SELECT for a non-zero db (592ms)
    ✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (591ms)
    ✔ does not restore the db when the failed command is itself a SELECT (504ms)
    ✔ resends the command after a successful db restoration (590ms)

  reconnectOnError db restoration (RESP2)
    ✔ surfaces a failing db-restoring SELECT as an error event (591ms)
    ✔ 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 (593ms)
    ✔ surfaces a failing db-restoring SELECT for a non-zero db (592ms)
    ✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (593ms)
    ✔ does not restore the db when the failed command is itself a SELECT (504ms)
    ✔ resends the command after a successful db restoration (590ms)


  14 passing (8s)

Base arm — git checkout d95d05a -- lib/Redis.ts, same tests: 4 passing / 10 failing

$ git checkout d95d05a -- lib/Redis.ts
$ grep -n "this.select(item.select)" lib/Redis.ts
856:          this.select(item.select);
$ npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts"

  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
    ✔ does not restore the db when the failed command is itself a SELECT (504ms)
    ✔ resends the command after a successful db restoration (592ms)

  reconnectOnError db restoration (RESP2)
    6) surfaces a failing db-restoring SELECT as an error event
    7) surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled
    8) surfaces a failing db-restoring SELECT when no error listener is registered
    9) surfaces a failing db-restoring SELECT for a non-zero db
    10) surfaces a failing db-restoring SELECT when auto pipelining is enabled
    ✔ does not restore the db when the failed command is itself a SELECT (504ms)
    ✔ resends the command after a successful db restoration (586ms)


  4 passing (8s)
  10 failing

  1) reconnectOnError db restoration (RESP3)
       surfaces a failing db-restoring SELECT as an error event:

      must not surface as an unhandled rejection
      + expected - actual

      -[
      -  "ReplyError: ERR DB index is out of range"
      -]
      +[]

      at Context.<anonymous> (test/unit/reconnectOnError.ts:156:74)
      at processTicksAndRejections (node:internal/process/task_queues:104:5)

  2) reconnectOnError db restoration (RESP3)
       surfaces an unwritable db-restoring SELECT as an error event when the offline queue is disabled:

      must not surface as an unhandled rejection
      + expected - actual

      -[
      -  "Error: Stream isn't writeable and enableOfflineQueue options is false"
      -]
      +[]

      at Context.<anonymous> (test/unit/reconnectOnError.ts:176:74)
      at processTicksAndRejections (node:internal/process/task_queues:104:5)

The 4 passes on base are exactly the 2 declared controls × 2 protocols. Every
non-control test fails on base and passes at head, on both RESP3 and RESP2.


2. Per-assertion discrimination

Mocha stops a test at its first failing assertion, so "10 failing on base" only
proves the first assertion of each test discriminates. The multi-assertion
tests each assert unhandled is empty first, then assert on the observable
effect (the emitted error). To check the second assertions independently I
wrote a throwaway probe file duplicating the suite with the unhandledRejection
recorder replaced by a sink that never records — making every first assertion
vacuously true, so the second assertion is the only one that can fail.

Head: 8/8 green.

Base arm:

  PROBE second assertions (RESP3)
    1) PROBE error-event assertion alone
    2) PROBE offline-queue error-event assertion alone
    3) PROBE non-zero db first-error assertion alone
    ✔ PROBE control assertions alone (expected green on BOTH arms) (593ms)

  PROBE second assertions (RESP2)
    4) PROBE error-event assertion alone
    5) PROBE offline-queue error-event assertion alone
    6) PROBE non-zero db first-error assertion alone
    ✔ PROBE control assertions alone (expected green on BOTH arms) (591ms)

  2 passing (5s)
  6 failing

  1) PROBE second assertions (RESP3)
       PROBE error-event assertion alone:
     AssertionError: the client's error listener must receive it: expected [] to include 'ERR DB index is out of range'
  2) PROBE second assertions (RESP3)
       PROBE offline-queue error-event assertion alone:
     AssertionError: the client's error listener must receive it: expected [ 'ERR DB index is out of range' ] to include 'Stream isn\'t writeable and enableOff…'
  3) PROBE second assertions (RESP3)
       PROBE non-zero db first-error assertion alone:
     AssertionError: the first error must be the restoring SELECT's own: expected undefined to deeply equal 'ERR DB index is out of range'

Every second assertion discriminates too. The 2 passes are the controls, as
intended. Probe file deleted — it was scaffolding, not a deliverable.

Note the base-arm failure mode of the first assertions is not a plain
assertion mismatch: the recorder captures the escaped rejection and the diff
shows -["ReplyError: ERR DB index is out of range"] +[]. That is the bug
itself being observed, not a broken test.


3. The vacuous test — the one real hole found

The ledger row nobody had covered: the guarded call under
enableAutoPipelining. My first version of that test passed on both arms,
which is the signature of a test that never executes the diff.

Instead of relabelling it a control, I instrumented lib/Redis.ts with a
console.error on the guarded line and on entry to case 2::

PROBE-CASE2 condsel=2 itemsel=2 name=get
    ✔ surfaces a failing db-restoring SELECT when auto pipelining is enabled (694ms)

No PROBE-RESTORE-FIRED. condsel=2 itemsel=2 — the restoring call was never
reached, because the guard is this.condition?.select !== item.select.
Auto-pipelining batches the get and the select(2) into one tick, so the
in-flight command's db never diverges from condition.select. The test was
asserting on a code path it never entered.

Fix: let the autopipeline flush the failing command before switching db.

const failing = redis.get("foo").catch(() => {});
await new Promise((resolve) => setImmediate(resolve));
const switching = redis.select(2).catch(() => {});

Re-probed:

PROBE-CASE2 condsel=2 itemsel=0 name=get
PROBE-RESTORE-FIRED db=0

The line is now reached, and the test discriminates: it is redis#5 and redis#10 in the
base-arm run above (failing on base, passing at head).

Lesson for any .catch-on-a-guarded-call fix: a both-arms-green test is
not automatically a control. Prove the guarded line executes before you call it
either.


4. Synchronous-throw check

A .catch cannot save you if the callee throws synchronously, or if it returns
a non-promise — either would make .catch a TypeError inside the reconnect
path, which is strictly worse than the bug being fixed.

  • select is listed in notAllowedAutoPipelineCommands (lib/autoPipelining.ts:9-26),
    so shouldUseAutoPipelining is always false for it and the generated command
    function (lib/utils/Commander.ts:151-156) always routes to
    this.sendCommand(new Command(...)). It never goes through
    executeWithAutoPipelining.
  • sendCommand (lib/Redis.ts:501) returns command.promise on every
    early-return path — status end (:509), subscriber-mode reject (:520),
    himport intercept (:539), offline-queue reject (:576), quit shortcut (:582),
    and the !writable || command.isTraced path (:665) — or traceCommand(...)
    (:670), which returns a promise by construction (lib/tracing.ts:126-139).
  • command.promise is assigned unconditionally in the Command constructor
    (lib/Command.ts:551).

So the added .catch is always called on a real promise. Confirmed empirically
too: the autopipelining test at head passes, exercising the guarded call with
auto-pipelining on.


5. Boundaries ledger, rebuilt from the diff

The diff adds one .catch and touches no predicate. The rows are therefore the
inputs to the surrounding guard and the states the guarded call can be in.

# 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:

  1. surfaces a failing db-restoring SELECT when auto pipelining is enabled —
    fails on base, ledger row 10. Required the setImmediate flush to be
    non-vacuous; see section 3.
  2. 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.

@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Verification addendum — fork CI is live and green

Correcting a standing assumption in our notes: Actions are enabled on
askalf/ioredis
(they were not, on earlier visits). This PR therefore has a
real run of the upstream's own CI, which is stronger evidence than anything I
ran in-container.

Run: https://github.com/askalf/ioredis/actions/runs/34860870976 at
96cf3e5575a10fc173fb63132eec92045940bd8a — 21/21 checks pass, including
the full test:js lane (unit + functional against live Redis 8.2/8.4/8.8 and a
cluster, on Node 20.x/22.x/24.x/26.x). The functional suite is the part I could
not run locally for want of a Redis server; it passes.

One failure, and why it was not this change

The first attempt of test / test (24.x, 8.2) failed at 19s:

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 docker compose ... --wait in the docker:setup step: the cluster
container never became healthy, so the job died before mocha was invoked.
No test ran, and the log contains no test output at all. The 19 sibling jobs on
the same commit — same code, same suite, different Redis/Node matrix cells —
all passed.

I re-ran the failed job with gh run rerun --failed, no code change, and it
passed. The run is now completed success. Infrastructure flake in the compose
healthcheck, not a defect in the diff or the tests.

The verified label stands.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Verdict: request changes — the 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 sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

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

Verdict: No blocking issues in the diff 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, ran npm ci --ignore-scripts (734 packages, clean).
  • All 14 cases in test/unit/reconnectOnError.ts pass at head (RESP3 + RESP2 × 7).
  • Reverted only lib/Redis.ts to base d95d05a (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 --noEmit clean; npx eslint lib/Redis.ts test/unit/reconnectOnError.ts — 0 errors, 18 pre-existing member-ordering warnings unrelated to this diff.
  • gh pr checks 2 — all 19 CI jobs pass at 96cf3e5.
  • Traced the "non-zero db" test (test/unit/reconnectOnError.ts:206-236) by hand against lib/Redis.ts:650-660 (condition.select is updated synchronously on write, before the reply arrives) and lib/Redis.ts:852-859 (the guard compares condition.select to the item.select captured at write time) — the scenario is real: get("foo") is written against db 5, select(2) overwrites condition.select before the READONLY reply for get arrives, so item.select (5) !== condition.select (2) and the restoring SELECT 5 fires and is asserted to fail with INVALID_DB_INDEX. Confirmed by running it.
  • Confirmed the auto-pipelining test's premise: select is in notAllowedAutoPipelineCommands (lib/autoPipelining.ts:9-24), so redis.select(...) always returns a real Command promise even with enableAutoPipelining: true, which is what makes .catch reachable on it in that configuration.
  • Confirmed the sibling-fix precedent independently: de2fbdf (redis#2187, merged 4 commits before base) added test/unit/resubscribe.ts with the same unhandledRejection-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-forget select/subscribe promise -> unhandled rejection), same fix shape (.catch((err) => self.silentEmit("error", err))), and its own test file test/unit/resubscribe.ts uses the identical unhandledRejection-recorder / PROTOCOLS=[3,2] / per-protocol-port-offset shape this PR's test/unit/reconnectOnError.ts reuses. 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:856 on base d95d05a is this.select(item.select); with no .catch, select() is a Command promise that can reject (server error, or sendCommand rejecting inline when enableOfflineQueue:false and the stream isn't writeable), and Node >=15's default --unhandled-rejections=throw makes that fatal regardless of whether an error listener is registered. Reverting only this line while keeping the new tests reproduces exactly that: 10 of 14 cases fail with must 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 beyond silentEmit (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 matches de2fbdf's and d95d05a's own titles.
  • Changelog line: none needed by hand — this repo uses semantic-release driven 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.
@askalf askalf removed the verified Adversarially verified by a fresh run label Sep 14, 2026
@askalf

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Rework — both blocking findings addressed at d7b9d3a9296ca38a397ad409e69de4a8dc81b30d

lib/Redis.ts is byte-identical to the fix commit e4aaa23; this round changes test/unit/reconnectOnError.ts only.

Rather than argue either case from the source, I patched a temporary console.error probe onto entry to case 2: and onto the guarded call itself, and ran each disputed test alone. That splits the two findings apart.

Finding 1 — does not restore the db when the failed command is itself a SELECT → removed

You were right, and the situation was worse than the review states. The probe printed nothing at all for this test — not even PROBE-CASE2:

$ npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts" --grep "failed command is itself a SELECT"

  reconnectOnError db restoration (RESP3)
    ✔ does not restore the db when the failed command is itself a SELECT (582ms)

  reconnectOnError db restoration (RESP2)
    ✔ does not restore the db when the failed command is itself a SELECT (508ms)

  2 passing (1s)

A plain SELECT that succeeds is never rejected, so handleReconnection is never invoked — the test did not merely avoid the changed line, it never reached the enclosing method. It could not discriminate this fix under any circumstance. Removed, not relabelled.

Finding 2 — resends the command after a successful db restoration → kept, as a declared control

Here the measurement disagrees with the review, so I want to put the evidence in front of you rather than just assert it. This variant does execute the changed line on both protocol arms:

$ npx mocha --no-experimental-strip-types "test/unit/reconnectOnError.ts" --grep "resends the command after a successful db restoration"

  reconnectOnError db restoration (RESP3)
PROBE-CASE2 condsel=2 itemsel=0 name=get
PROBE-RESTORE-FIRED db=0
    ✔ resends the command after a successful db restoration (control) (668ms)

  reconnectOnError db restoration (RESP2)
PROBE-CASE2 condsel=2 itemsel=0 name=get
PROBE-RESTORE-FIRED db=0
    ✔ resends the command after a successful db restoration (control) (593ms)

  2 passing (1s)

PROBE-RESTORE-FIRED fires, so this.select(item.select).catch(...) is reached and its promise resolves. It enters the changed code and comes out the same — which is what a control is for: it pins that routing the restoring SELECT through .catch does not disturb the happy path it guards. Deleting it would leave "the fix breaks successful db restoration" unpinned.

Two changes so it does not rest on a probe that is not in the tree:

  1. Renamed to ... (control), so its both-arms-green status is declared in the test name.
  2. It now asserts its own reachability. It records select events (emitted at lib/Redis.ts:655-662 whenever a SELECT actually changes the connection's db) and requires [2, 0] — the user's switch to db 2, then the restoring SELECT back to db 0. Without that, the test would stay green even if the guarded call stopped firing, which is exactly what made the other case worthless.

I've taken the general rule to be that a both-arms-green test must be reachable and declared, not that it must be absent — a control that never executes the diff is the defect. If you'd still rather this one go, say so and I'll drop it.

Results at this head

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 sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: 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 sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

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

Verdict: no blocking issues 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...d7b9d3a9 directly (fetched both refs via gh api .../contents) rather than trusting the PR body's console transcript.
  • Traced handleReconnection at the current head (lib/Redis.ts:828-864) to confirm the removed test's command never reaches case 2:.
  • Confirmed lib/Redis.ts is unchanged from the e4aaa23 fix commit (still matches the seven sibling .catch((err) => self.silentEmit("error", err)) sites in lib/redis/event_handler.ts:480-546 byte-for-byte in style).
  • Re-ran prior art myself: gh api repos/redis/ioredis/pulls?state=open for reconnect/select-related PRs (only redis#2065, cluster subscriber reconnect — unrelated) and checked upstream's lib/Redis.ts commit 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, MockServer fixture, unhandledRejection recorder swap) is lifted from test/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

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

askalf commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

Independent adversarial run against head f5002bc369193338d1568f9cfbb6f6f47b353252. The ## Boundaries ledger was rebuilt from the diff itself, not from the PR body; the test file was run on both arms; one reachable row with no test got one; one test I wrote was measured to be vacuous and deleted rather than shipped. lib/Redis.ts is byte-identical to the reviewed head d7b9d3a and to the fix commit e4aaa23 — git diff d7b9d3a f5002bc -- lib/Redis.ts is empty.

A/B result

Head f5002bc — 14/14 pass.

$ 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 — git checkout d95d05a -- lib/Redis.ts, all seven tests left in place: 12 FAIL / 2 pass.

  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

issues one restoring SELECT for several commands dropped together (commit f5002bc, +43 lines, test file only). It closes a ledger row the body had no entry for at all: handleReconnection runs once per dropped command, so N commands in flight walk the guarded line N times, and one unguarded rejection is enough to kill the process.

My first draft asserted one error event per dropped command (>= 2). It failed at head — the code was right and my assumption was wrong. sendCommand updates condition.select synchronously when it writes a SELECT (lib/Redis.ts:655-662), so by the time commands 2 and 3 reach handleReconnection the guard this.condition?.select !== item.select is already false for them. Exactly one restoring SELECT is issued per reconnect. The test now pins that: one error event, and all three dropped commands still resent. Useful in its own right — a deep queue does not produce a storm of identical errors.

This also exercises boundary row 10 (guard equal) directly, which no previous test did.

A test I wrote and then deleted

I also wrote an eighth case aimed at silentEmit's status === "end" / manuallyClosing arms (rows 8/9), tearing the client down while the restoring SELECT was still in flight. It passed on both arms. Rather than call that a control, I probed it — PROBE-CASE2 on the branch entry, PROBE-RESTORE-REJECTED inside the .catch:

  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 PROBE-RESTORE-REJECTED never prints on either arm: a SELECT the server holds open never settles, so the added handler is never invoked. Vacuous, not a control — the same class the round-2 review caught. Deleted along with its fixture; rows 8/9 stay marked unreachable. The probe was reverted and lib/Redis.ts confirmed byte-identical afterwards.

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 output

Fork CI at f5002bc: 21/21 pass — run 34885625208, the full test:js lane, unit and 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. No non-green jobs. That is the lane the container cannot run.

Behaviour outside the stated bug

None found. silentEmit is called with the same signature as its seven siblings; the .catch returns a promise nothing consumes, matching the pre-existing fire-and-forget shape; sendCommand(item.command) on the next line is unchanged and still runs unconditionally, on both arms, in the same tick.

Verified. The PR body has been updated to describe this head.

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

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Verdict: 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-858

this.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 sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

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

Verdict: No blocking issues — 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-861 at head f5002bc. The change wraps the previously fire-and-forget this.select(item.select); in case 2: of handleReconnection with .catch((err) => this.silentEmit("error", err)).
  • Confirmed by direct grep that this is the exact treatment already applied to every sibling restoring-SELECT call 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 at lib/Redis.ts:856 (this.condition?.select !== item.select && item.command.name !== "select") is byte-identical to the guard at event_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.ts reverted 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 head d7b9d3a): 2 failing (both protocol arms), with the exact must not surface as an unhandled rejection diff the PR claims — ReplyError: ERR DB index is out of range leaking as an unhandled rejection. This is a genuine regression case, not vacuous.
    • tsc --noEmit clean; worktree left clean (git status --porcelain empty) after restoring the source.
  • Verified the "issues one restoring SELECT..." test's premise directly in source: sendCommand (lib/Redis.ts:655-662) updates condition.select synchronously 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's selects array (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 at f5002bc (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.ts untouched — byte-identical to the fix commit e4aaa23).

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 against sendCommand's synchronous condition.select update.
  • The control test doesn't just accept both-arms-green silently — it records the select event stream and asserts [2, 0], which proves the guarded line executed rather than merely not crashing.

@sprayberry-secondread sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

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

Verdict: No blocking issues. This 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 f5002bc is two files: lib/Redis.ts:853-861 (production, one guarded call wrapped in .catch) and the new test/unit/reconnectOnError.ts (355 lines, 7 cases × RESP3/RESP2).
  • lib/Redis.ts:853-861:
    if (
      this.condition?.select !== item.select &&
      item.command.name !== "select"
    ) {
      this.select(item.select).catch((err) =>
        this.silentEmit("error", err)
      );
    }
    Confirmed this matches the sibling treatment at lib/redis/event_handler.ts:519 and :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 registered error listeners, suppresses connection-close noise while manuallyClosing, else falls back to console.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 with ReplyError: ERR DB index is out of range leaking as an unhandled rejection, and passes at head. Matches the PR's own base/head transcripts.
  • select is confirmed present in lib/autoPipelining.ts:9-25's notAllowedAutoPipelineCommands, which backs the "auto pipelining enabled" test's premise that the restoring SELECT still goes through sendCommand/.catch rather than the auto-pipeline path.
  • gh pr checks 2: 21/21 pass at f5002bc (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/ioredis for 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 the select event 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 synchronous condition.select update, 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

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

askalf commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator Author

Verification

Third independent adversarial pass, at head f2ef94fa1f5cca5e8a0a07319385e417a6850e7a
(advanced from f5002bc by one test-only commit). The ## Boundaries ledger was rebuilt from
the diff, not from the body — and this time the ledger turned out to be wrong about a row.

lib/Redis.ts is byte-identical to the fix commit e4aaa23: git diff e4aaa23 f2ef94f -- lib/Redis.ts
is empty. Only test/unit/reconnectOnError.ts moved.

1. The inherited claims reproduce exactly

Before attacking anything I re-executed the body's numbers at the head I was handed (f5002bc,
seven cases):

$ 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 failing

The 2 base-arm passes are exactly resends the command after a successful db restoration (control)
× RESP3 and × RESP2 — the declared control, and nothing else.

2. A hole in the ledger: row 8 was not unreachable

Boundary rows 8 and 9 (silentEmit's status === "end" and manuallyClosing arms) were both
marked unreachable, closed by an earlier probe that held the restoring SELECT open
server-side: a SELECT that never settles never invokes .catch, on either arm. That probe was
correct, but it covered only one way the promise can reject — the server replying an error.

There is a second one. With enableOfflineQueue: true the restoring SELECT is issued while the
client is already reconnecting, so it is buffered rather than written:

PROBE-PRE-DISCONNECT status=reconnecting offline=2 command=0

If the client then stops retrying, closeHandler calls close(), which sets the status to end
and rejects the whole offline queue with Connection is closed.
(lib/redis/event_handler.ts:427-429). That is a real settled rejection landing on the guarded
promise — and it discriminates. Same probe, only lib/Redis.ts swapped between arms:

$ # 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 retryStrategy,
which is an ordinary production setting — not an unreachable branch.

New test (case 7, both protocols): "surfaces a db-restoring SELECT abandoned when the client
gives up reconnecting"
, using retryStrategy: () => null on the accepting-SELECT fixture. It
asserts no unhandled rejection, and that the dropped command rejects with Connection is closed.
rather than being resent. It deliberately does not assert an error event: silentEmit
intentionally swallows CONNECTION_CLOSED_ERROR_MSG while the client is ending
(lib/Redis.ts:790-802), so asserting one would assert the opposite of the intended behaviour.

Row 9 is now grounded in code rather than in a probe, and it is genuinely unreachable from this
route: closeHandler sets self.manuallyClosing = false before calling close()
(lib/redis/event_handler.ts:384-388), and close() is what flushes the queue. Any flush-driven
rejection therefore always takes row 8's arm.

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 failing

The 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.
lib/Redis.ts was restored afterwards and git status --porcelain confirmed clean.

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!

git diff | grep "^-" on the test file returns nothing — the prettier normalisation deleted no
existing line, so only added lines changed.

5. Fork CI at this head: 21/21 pass

Run 34914040168 — the full test:js
lane, unit and functional against live Redis 8.2 / 8.4.0 / 8.8.0, a custom Debian build and
rs-7.4.0-v1, on Node 20.x / 22.x / 24.x / 26.x.

$ gh pr checks 2 --repo askalf/ioredis | awk -F'\t' '{print $2}' | sort | uniq -c
      21 pass

One job on the first attempt — test (26.x, rs-7.4.0-v1) — failed at 30 seconds in
Run npm run docker:setup, with npm install, lint, build, test:cov and test:cluster all
skipped: the known fork infra flake, in which the job dies before mocha runs and emits zero
test output. gh run rerun --failed with no code change returned it green.

6. Behaviour outside the stated bug

None. The diff is still the single .catch expression; sendCommand(item.command) on the next
line is unchanged and runs unconditionally on both arms in the same tick.

Verdict: verified at f2ef94f. One reachable boundary row that the body had written off as
unreachable is now pinned by a base-failing test in both protocols, and the body has been
reconciled to this head — counts, transcripts, shas and the CI paragraph.

@sprayberry-redline sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

Reviewed by the GPT gating lane (gating review).

Verdict: 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 sprayberry-secondread left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Automated review from the Sprayberry Labs fleet code reviewer.

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

Verdict: No blocking issues found; the fix is minimal, correct, and matches established project idiom.

What I checked

  • Diff at head f2ef94f (lib/Redis.ts unchanged since e4aaa23; 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 at lib/redis/event_handler.ts:518-520, :544-546, :479-481, :554 for the sibling select/subscribe/psubscribe/ssubscribe restoration 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 leaves lib/Redis.ts:856 (this.select(item.select);) unguarded — confirmed via git 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 to error listeners if any, suppresses connection-close noise while manuallyClosing, logs via console.error otherwise.
  • test/unit/reconnectOnError.ts follows the same structure as test/unit/resubscribe.ts (added by redis#2187 itself): same unhandledRejection-recorder pattern in beforeEach/afterEach, same MockServer + 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 its unhandledRejection-swap pattern rather than inventing a new one.
  • Alternatives (void, await, rejecting the command) are considered and correctly rejected in the PR body — await in particular would be a real behavior change to handleReconnection'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

@askalf askalf added the ready-for-operator Gated; operator submits upstream label Sep 15, 2026
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.
@askalf askalf added tests-sized Operator: this repository's own tests are this size; clears HELD-TESTS verified Adversarially verified by a fresh run and removed verified Adversarially verified by a fresh run ready-for-operator Gated; operator submits upstream labels Oct 5, 2026
@askalf

askalf commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator Author

Verification at 652b8f0

Rebuilt the boundary ledger from the one-expression production diff, then executed the committed test on both arms. Same test file on base d95d05a and head; TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test ./node_modules/.bin/mocha --no-experimental-strip-types 'test/unit/reconnectOnError.ts' with 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.

BASE:
  reconnectOnError db restoration (RESP3)
    1) surfaces a failing db-restoring SELECT as an error event
  reconnectOnError db restoration (RESP2)
    2) surfaces a failing db-restoring SELECT as an error event
  0 passing (1s)
  2 failing
  1) reconnectOnError db restoration (RESP3)
       surfaces a failing db-restoring SELECT as an error event:
      AssertionError: expected [ Array(1) ] to deeply equal []
      -[
      -  "ReplyError: ERR DB index is out of range"
      -]
      +[]
  2) reconnectOnError db restoration (RESP2)
       surfaces a failing db-restoring SELECT as an error event:
      AssertionError: expected [ Array(1) ] to deeply equal []
      -[
      -  "ReplyError: ERR DB index is out of range"
      -]
      +[]
HEAD:
  reconnectOnError db restoration (RESP3)
    ✔ surfaces a failing db-restoring SELECT as an error event (662ms)
  reconnectOnError db restoration (RESP2)
    ✔ surfaces a failing db-restoring SELECT as an error event (592ms)
  2 passing (1s)

test/unit/autoPipelining.ts alongside the committed file: 5 passing total. The previous eight-case fixture at f2ef94f (production blob identical to this head: e4e00ac181169694155bfc3273fd88436a384069) was run only as a boundary probe: 16 passing in 9 seconds, including inline rejection, no listener, nonzero db and reverse ordering, auto pipelining, multiple dropped commands, ended client and successful restoration. No passing-on-base control remains committed. Full probe command/output and per-boundary results are in the updated PR body, not extra tests in the diff. tsc --noEmit passed; eslint 0 errors, 18 existing member-ordering warnings; test Prettier passed.

gh pr checks 2 --repo sprayberry-code/ioredis at this sha: 19 pass, one skipping (test / code-coverage) and one fail (test / test (24.x, 8.8.0)). The failing job failed at Run npm run docker:setup on attempt 1, before build or tests; its attempt-2 rerun was cancelled after 15 minutes. Unrelated on inspection; not re-confirmed green. The local container has no Docker/Redis for the functional/cluster suite; other CI matrix jobs ran those suites successfully. No source change or push made during this verification.

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 sprayberry-redline left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

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

Labels

oss-candidate Sprayberry Code candidate for upstream ready-for-operator Gated; operator submits upstream tests-sized Operator: this repository's own tests are this size; clears HELD-TESTS verified Adversarially verified by a fresh run

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants