Skip to content

fix(redis): detect an existing connection by shape instead of instanceof, and support ioredis 6 - #71

Open
DavideCarvalho wants to merge 3 commits into
boringnode:0.xfrom
DavideCarvalho:fix/redis-cross-major-client-detection
Open

fix(redis): detect an existing connection by shape instead of instanceof, and support ioredis 6#71
DavideCarvalho wants to merge 3 commits into
boringnode:0.xfrom
DavideCarvalho:fix/redis-cross-major-client-detection

Conversation

@DavideCarvalho

@DavideCarvalho DavideCarvalho commented Sep 8, 2026

Copy link
Copy Markdown

The bug

RedisTransport decides whether it was handed a live client or a plain options object with instanceof:

if (options instanceof Redis || options instanceof Cluster) {
  this.#publisher = options.duplicate()
  this.#subscriber = options.duplicate()
  this.#useMessageBuffer = transportOptions?.useMessageBuffer ?? false
} else {
  // @ts-expect-error - merged definitions of overloaded constructor is not public
  this.#publisher = new Redis(options)     // <- silent fallback
  // @ts-expect-error - merged definitions of overloaded constructor is not public
  this.#subscriber = new Redis(options)

  if (typeof options === 'object') {
    this.#useMessageBuffer = options.useMessageBuffer ?? false
  }
}

instanceof is evaluated against the copy of ioredis this package resolved. When the host application resolves a different copy — a different major, or simply a duplicate in the dependency tree — the check is false for a perfectly valid client, and we fall through to new Redis(<a live client treated as an options bag>). A Redis instance has none of host/port/path as own properties, so ioredis keeps its defaults and connects to 127.0.0.1:6379, silently, with no error.

There is a second symptom, specific to this package. useMessageBuffer only reaches the transport through the third constructor argument, which the options-object branch ignores — it reads options.useMessageBuffer instead, which does not exist on a client. So the subscriber quietly listens on message instead of messageBuffer, and binary payloads come back through a lossy string. That is a data-corruption bug, not just a connectivity one.

Reproduced end to end, with two genuinely different majors installed

Built package, an app on ioredis@6.0.0, @boringnode/bus resolving its own ioredis@5.11.1, a Redis on port 6390 (deliberately not the default), useMessageBuffer: true and a binary encoder:

main this PR
duplicate() calls 0 4
sockets opened to :6379 5 0
message reached :6390 no yes
useMessageBuffer honoured false (raw payload was a string) true (Buffer)
payload decoded correctly no (undefined) yes

Isolating just the connection, on main, the client built out of a live foreign-major instance reports:

options.host: localhost | options.port: 6379
socket remote: ::1 6379
status: ready
CLIENT INFO -> laddr=172.17.0.6:6379

It is ready. It is connected. It is connected to the wrong server, and nothing anywhere says so.

The chain this sits at the bottom of

This is worth spelling out, because the same one-line check has now caused an incident and blocked a release train across three packages.

  1. adonisjs/redis#77 — the original production incident. @adonisjs/redis@10.0.1 shipped ioredis 6 in a patch. @adonisjs/cache passes its ioConnection straight down, so the instanceof check one layer below started failing, a second connection was opened to 127.0.0.1:6379, and production logs filled with connect ECONNREFUSED 127.0.0.1:6379 roughly every 2 seconds. It was resolved by reverting to ioredis 5 and re-shipping ioredis 6 as the 11.0.0 major.
  2. Julien-R44/bentocache#122 — the bug reported at the bentocache layer. Still open; the reporter correctly diagnosed it as the cross-major instanceof.
  3. adonisjs/cache#18 — the downstream symptom. Because of the above, @adonisjs/cache still cannot move to @adonisjs/redis@11 today, at all.
  4. Julien-R44/bentocache#123 — the sibling PR, fixing the exact same check one layer up, with the same discriminator. It deliberately did not widen bentocache's own ioredis peer, and the stated reason is this package: @boringnode/bus is a hard dependency of bentocache and peers ioredis ^5.0.0, so widening upstream would only produce unmet-peer warnings while the bus still could not accept a version-6 client.
  5. This PR — the last blocking link. With #123 applied, a foreign-major client is now correctly forwarded to RedisTransport, which then fails its instanceof and re-breaks everything one layer down. I confirmed that live while working on #123; it is what sent me here.

So: @boringnode/busbentocache@adonisjs/cache. Fixing it here is what lets bentocache widen its peer, which is what unblocks @adonisjs/cache. The other two cannot proceed without this one.

What changed

1. Duck-typing instead of instanceof

function isRedisClient(value: unknown): value is Redis | Cluster {
  if (typeof value !== 'object' || value === null) return false

  const candidate = value as Partial<Redis>

  return typeof candidate.duplicate === 'function' && typeof candidate.sendCommand === 'function'
}

Why those two properties, verified against 5.11.1 and 6.0.0 side by side:

  • They are on the prototype of both Redis and Cluster, in both majors — all four combinations checked (v5.Redis, v5.Cluster, v6.Redis, v6.Cluster).
  • Neither name exists in RedisOptions or ClusterOptions in either major (grepped the shipped .d.ts), so a legitimate options object can never be misclassified as a client. That direction matters most: a false positive fails loudly on the first command, a false negative is the silent-localhost bug.
  • constructor.name is not usable here, in case it comes up in review — ioredis builds its clients through a mixin, so instances of both Redis and Cluster report EventEmitter in both majors.

Because the live-client branch is now taken, useMessageBuffer from the third argument survives, which fixes the second symptom.

2. A tripwire instead of the silent fallback

If a value is not recognised as a client but still carries the markers of one — a status string, an options object and emit — we throw InvalidArgumentsException rather than dialing localhost. None of those three names are valid ioredis options either, so no legitimate config trips it. The genuine options-object path is untouched: that is the documented API and changing it would be breaking.

This branch is unreachable for every ioredis major that exists today; it only exists so that a future shape change degrades into a boot-time error instead of another production incident. Happy to drop that hunk if you would rather keep the diff to just the discriminator. (Same offer is on #123, so the two stay coherent either way.)

Peer range: widened to ^5.0.0 || ^6.0.0

Unlike bentocache, this package is in a position to widen — nothing sits below it. So I validated ioredis 6 properly first rather than assuming.

RESP3 is genuinely negotiated, not just tolerated: client.options.protocol === 3 under ioredis 6 (it is undefined under 5), and a HELLO round trip has the server reporting proto=3. Everything below ran under that.

Full existing suite, unmodified, against ioredis@6.0.0: 72 passed, 0 failed — byte-for-byte the same result as under 5.11.1, including the real-cluster test (I brought up a three-node cluster on 7000-7002 so it would not be skipped).

Targeted validation of the whole RedisTransport surface — a standalone harness against Redis 7.2, run under both majors:

check ioredis 5.11.1 ioredis 6.0.0
options.protocol === 3 no (RESP2) yes
server-side HELLO reports proto=3 no yes
publish / subscribe round trip pass pass
unsubscribe stops delivery pass pass
does not receive its own messages pass pass
messageBuffer delivers a Buffer pass pass
416-byte payload received byte-identical pass pass
binary payload decodes exactly pass pass
existing-client duplicate() path delivers pass pass
duplicate() path keeps messageBuffer pass pass
onReconnect fires after a server restart pass pass
delivery resumes after reconnect (auto re-subscribe) pass pass

The binary payload was deliberately nasty: a 300-character key, embedded control bytes, multi-byte UTF-8 (👍, 日本語) and a base64 blob of 0x00 0x01 0x02 0xFA 0xFB 0xFC 0xFF — 416 bytes total, compared with Buffer.compare against exactly what was published. Under RESP3 it comes back identical. (For contrast, the non-buffer message string path is lossy in both majors — no regression there, just a reminder of why useMessageBuffer exists.)

Cluster on RESP3 was checked separately too, against the real three-node cluster: Cluster + useMessageBuffer: true + a >320-byte multi-byte payload round-trips exactly, and the member nodes report protocol === 3.

One real code change was needed to make the widen honest. Redis#duplicate and Cluster#duplicate became generic over the reply mapping in ioredis 6, and the two signatures no longer unify, so options.duplicate() on a Redis | Cluster union does not compile:

src/transports/redis.ts(55,33): error TS2349: This expression is not callable.
  Each member of the union type '(<Override extends Partial<RedisOptions> ...>) | (<OverrideOptions extends ClusterOptionsWithReplyMapping<ReplyMappingMode> ...>)'
  has signatures, but none of those signatures are compatible with each other.

This is pre-existingmain produces the identical error on the identical two lines when typechecked against ioredis 6; my change did not introduce it. The call now goes through a narrow local signature (duplicateClient), after which yarn typecheck and yarn build are clean under both majors. Without that, widening the peer would have advertised support this package's own source could not compile against.

engines.node is already >=20.6, comfortably above ioredis 6's >=20.0.0, so no engine change is needed.

The ioredis devDependency stays at ^5.11.1, so CI keeps testing against 5 by default. Alongside it there is now a second, real copy installed under an npm alias, used only by the tests:

"devDependencies": {
  "ioredis-v6": "npm:ioredis@^6.0.0"
}

Yarn 4 with the node-modules linker resolves the npm: protocol natively — the lockfile records it as "ioredis-v6@npm:ioredis@^6.0.0" resolving to ioredis@npm:6.0.0, and it lands in node_modules/ioredis-v6 next to node_modules/ioredis@5.11.1, so the two are genuinely distinct module instances with distinct classes. No resolutions, no patch, no install script.

If you would like a CI matrix that runs the suite against both majors as the primary copy, say the word and I will add it — I kept .github/ out of this PR on purpose.

Tests

Added to tests/drivers/redis_transport.spec.ts. The cross-major cases use a real ioredis 6 client, not a simulation. Thanks to the alias above, the test file imports a second, genuine copy of the package:

import { Redis, Cluster } from 'ioredis'                                    // 5.11.1 — what the package resolves
import { Redis as ForeignRedis, Cluster as ForeignCluster } from 'ioredis-v6' // 6.0.0  — what the app hands us

That is exactly the real-world scenario: an application on @adonisjs/redis@11 (ioredis 6) handing its connection to a bus resolved against ioredis 5. Each cross-major test asserts its own premise at runtime before asserting behaviour, so it cannot silently stop covering the bug:

assert.isFalse((ForeignRedis as unknown) === (Redis as unknown))  // two different constructors
assert.instanceOf(foreignClient, ForeignRedis)                    // a real client of its own copy
assert.isFalse(foreignClient instanceof Redis)                    // ...and not of ours - the trigger condition
assert.isFalse(foreignClient instanceof Cluster)

To tell "reused the connection" from "built a new one", the tests record what duplicate() returns through an own-property spy that shadows the prototype method — the client stays an instance of its own copy, which is the whole point.

  • a foreign-major Redis client is reused — asserts duplicate() was called twice and that the duplicates are real ioredis 6 clients, then proves the message really lands on the configured server by reading it off an independent witness connection bound to the testcontainers port
  • a foreign-major Cluster client is reused — asserts the duplicates are real foreign Cluster instances (and not instanceof our Cluster), and that the transport wired onReconnect onto the duplicated subscriber
  • useMessageBuffer survives on the live-client path — asserts the subscriber duplicate listens on messageBuffer and not on message, then round-trips a binary payload through Redis and compares the bytes seen on the subscriber with the exact bytes the publisher wrote (Buffer#equals)
  • an unrecognisable client-shaped object throws instead of falling back to localhost
  • a plain options object still builds a new connection — the guard against the opposite failure, a false positive

Redis is provisioned the way the file already does it, through the existing @testcontainers/redis container started in group.setup — no second mechanism. The cluster case uses lazyConnect, so it needs no server and still runs on CI.

The first four fail on main and pass on this branch. Verified by reverting only src/transports/redis.ts and re-running the file: 13 passed / 4 failed, versus 17 passed here, and the four failures are precisely those tests. The fifth passes on both by design — it exists so a future tweak to the discriminator cannot start swallowing options objects.

Why the Proxy stand-in is gone

The previous revision simulated the foreign client with a Proxy over a null-prototype target. It was shape-faithful and it did fail on main, but a real ioredis 6 client is a strictly harder case: it covers everything the Proxy covered (different classes, instanceof false, working duplicate()) plus the parts a facade cannot vouch for — a real prototype chain, a real RESP3 connection, a real duplicate() implementation from another major.

The one thing a real client genuinely cannot exercise is the tripwire path — "client-shaped object that is no ioredis at all". That case keeps its own test, which uses a plain object literal ({ status, options, emit }) and never needed the Proxy in the first place. So the helper had no remaining job and was removed rather than kept as dead weight next to the real thing.

What I ran

  • Full suite on main, same machine, cluster up: 67 passed, 0 failed.
  • Full suite on this branch: 72 passed, 0 failed (the 5 new tests), with ioredis@5.11.1 as the resolved copy and with ioredis@6.0.0 as the resolved copy (in that second run the alias is a second copy of the same major, which is the duplicated-in-the-tree half of the bug — the tests hold there too).
  • tests/drivers/redis_transport.spec.ts alone: 17 passed on the branch, 13 passed / 4 failed with src/transports/redis.ts reverted to the instanceof version.
  • yarn lint, yarn typecheck and yarn build: clean under both majors. The duplicateClient narrow signature is still required with the alias installed — ioredis 6's generic duplicate is now in the dependency tree either way.

What I did not validate

  • Sentinel, and Valkey — untouched by this change, but not exercised on ioredis 6 either.
  • Only Redis 7.2; no other server version, and no TLS.
  • The MQTT transport, which this change does not touch.

DavideCarvalho and others added 2 commits September 8, 2026 10:57
`RedisTransport` decided whether it received a live client or a plain
options object with `instanceof Redis || instanceof Cluster`. That check
is evaluated against the copy of `ioredis` this package resolved, so a
perfectly valid client coming from another copy - a different major, or
simply a duplicate in the dependency tree - fails it, and we fall through
to `new Redis(<a live client treated as an options bag>)`. ioredis ignores
the unrecognised properties and connects to 127.0.0.1:6379, silently.

`useMessageBuffer` is a second casualty: it only reaches the transport
through the third constructor argument, which the options-object branch
ignores, so it degrades to `false` and binary payloads are decoded from a
lossy string.

Both call sites now go through a duck-type check on `duplicate` and
`sendCommand`. Both are on the prototype of `Redis` and `Cluster` in
every major, and neither name exists in `RedisOptions` or
`ClusterOptions`, so an options object can never be misclassified.

Values that carry the markers of a client (`status`, `options`, `emit`)
without being recognised as one now throw instead of quietly dialing
localhost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The whole `RedisTransport` surface was validated against ioredis 6.0.0
with RESP3 actually negotiated (`options.protocol === 3`, server-side
`HELLO` agrees): publish/subscribe/unsubscribe, self-message filtering,
the `messageBuffer` binary path (byte-identical round trip), the
`duplicate()` path for an existing client, reconnection, and a real
three-node Redis cluster.

`Redis#duplicate` and `Cluster#duplicate` became generic over the reply
mapping in ioredis 6, which made the call against a `Redis | Cluster`
union uncompilable (TS2349); it now goes through a narrow local
signature so the source typechecks and builds against both majors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The foreign-major client was simulated with a `Proxy` over a
null-prototype target, which is faithful in shape but leaves open the
fair question of whether it behaves like a real client of another major.

A second, genuine copy of `ioredis` is now installed side by side through
the npm alias `"ioredis-v6": "npm:ioredis@^6.0.0"`, so the three
cross-major tests hand `RedisTransport` an actual ioredis 6 client - the
exact shape of an application on `@adonisjs/redis@11` passing its
connection to a bus resolved against ioredis 5. Each one asserts the
premise at runtime (`instanceof ForeignRedis`, not `instanceof Redis`,
and the two constructors are not the same object) before asserting the
behaviour, so the tests cannot silently stop covering the bug.

Delivery is still proven against the configured server through a witness
connection bound to the testcontainers port, and the `useMessageBuffer`
case now compares the bytes seen on the subscriber with the bytes the
publisher wrote, on top of the `messageBuffer`/`message` listener counts.

The `Proxy` helper is dropped: a real foreign client is a strictly harder
case than a duplicated copy of the same major, and the "client-shaped
object that is no ioredis at all" tripwire is already covered by its own
test with a plain object literal.

Reverting `src/transports/redis.ts` to the `instanceof` version turns the
suite red on exactly these tests: 13 passed / 4 failed against 17 passed
on the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant