fix(redis): detect an existing connection by shape instead of instanceof - #123
Conversation
`RedisDriver` and `redisBusDriver` decided whether `connection` was a live client or a plain options object with `instanceof IoRedis || instanceof IoRedisCluster`. That check is evaluated against the `ioredis` copy bentocache resolved, so it returns `false` for a perfectly valid client whenever the host application resolved a different `ioredis` major. The driver then fell through to `new IoRedis(<a live client>)`; ioredis ignores the unrecognised properties and silently connects to 127.0.0.1:6379. Both call sites now duck-type on `duplicate` / `sendCommand`, which exist on `Redis` and `Cluster` in every ioredis major and are not valid option names. A value that looks like a client but is not recognisable now throws instead of silently building a connection to localhost. Closes Julien-R44#122
🦋 Changeset detectedLatest commit: f12c397 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Follow-up on the peer-range section above: I sent the same patch to That is the layer that was blocking this one. It fixes the identical It also covers the second symptom I mentioned here, which is specific to the bus: on the unrecognised-client path
So once that lands and ships, the peer bump here becomes safe and I am happy to follow up with it — which is the last thing standing between |
The regression tests simulated a foreign-major client with a `Proxy` over a null-prototype target. Install a genuine second ioredis major side by side instead ( `"ioredis-v6": "npm:ioredis@^6.0.0"` ), so the driver is handed a real `ioredis@6.0.0` client while it keeps resolving its own `ioredis@5`. Each cross-major case now asserts the fixture really is of another class ( `instanceOf` the v6 `Redis` / `Cluster`, `notInstanceOf` ours ) before using it, which is the exact condition that used to send the driver down the wrong branch. The `Redis` case also proves delivery landed on the connection we were given: the client is pointed at db 3, the value written through the driver is read back there, and a second client on the db 0 the silent fallback would have used sees nothing. The two "unrecognizable client" tests keep their hand-made object on purpose: a value that is client-shaped but is not any ioredis cannot come out of a real install. The bus forwarding test keeps a recorder proxy, now fronting a real v6 client, because `RedisTransport` keeps its connection private and repeats the same `instanceof` check, so a bare v6 client cannot discriminate the two code paths from the outside. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #122.
The bug
RedisDriverandredisBusDriverdecide whetherconnectionis a live client or a plain options object withinstanceof:instanceofis evaluated against theiorediscopy bentocache resolved. When the host application resolves a differentioredismajor (two copies in the tree, which is the normal pnpm outcome), the check isfalsefor a perfectly valid client, and we fall through tonew IoRedis(<a live Redis instance treated as an options bag>). ioredis ignores the unrecognised properties and connects to127.0.0.1:6379— silently, with no error.I reproduced the end-to-end failure with the built package, an app on
ioredis@6.0.0and bentocache resolving its ownioredis@5.11.1, against a Redis on a non-default port:main127.0.0.1:6379)Note that
bento.get()returned the right value in both runs, because L1 served it. That is why this is so hard to spot: everything looks fine until L1 misses, and then L2 is empty and a second connection is quietly hammering localhost.Real-world impact
@adonisjs/redis@10.0.1shipped ioredis 6 in a patch, which broke@adonisjs/cache(it passesioConnectionstraight to bentocache) and spammedconnect ECONNREFUSED 127.0.0.1:6379every ~2s in production. It was resolved by reverting to ioredis 5 and re-shipping ioredis 6 as the11.0.0major — so today@adonisjs/cachestill cannot move to@adonisjs/redis@11, and this check is the reason.What changed
Both call sites now go through one duck-type helper:
Why these two properties:
RedisandCluster, in every ioredis major. I verified this against5.11.1and6.0.0for all four combinations (v5.Redis,v5.Cluster,v6.Redis,v6.Cluster).RedisOptionsorClusterOptionsin either major (I grepped the shipped.d.tsfiles), so a legitimate options object can never be misclassified as a client. This matters more than the reverse: a false positive fails loudly on the first command, a false negative is the silent-localhost bug we are fixing.constructor.nameis not usable here, in case it comes up in review — ioredis builds its clients through a mixin, so bothRedisandClusterreportEventEmitterin both majors.On throwing vs. the silent fallback
Issue #122 asks for a loud failure. I kept the existing fallback for genuine options objects — that is the documented API and changing it would be breaking — but added a narrow tripwire: if the value is not recognised as a client yet still carries the markers of one (a
statusstring, anoptionsobject andemit), we throwInvalidArgumentsExceptioninstead of dialing localhost. None of those three names are valid option names either, so no legitimate config trips it.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 rather than another production incident. Happy to drop that hunk if you would rather keep the diff to just the discriminator.
Peer range: I did not widen it, and here is why
I validated ioredis 6 properly before deciding, since #122 also suggests widening to
^5.3.2 || ^6.0.0.RESP3 is really negotiated (
options.protocol === 3, and the server'sHELLOagrees), and the wholeRedisDriversurface is clean on it — against Redis 7.4:get(hit + miss),getdel(hit + miss),set,set … 'PX' ttl,scanwithMATCH/COUNTreturning[cursor, keys]with thekeyPrefixstill on the keys,pipeline() + unlink + exec()returning[[err, res], …], bareunlink, andconnection.options.keyPrefix. All identical to ioredis 5.The bus path also holds up. I ran a real binary round-trip through Redis: a 363-byte
BinaryEncoderpayload (NUL bytes, a multi-byte UTF-8 key, a 300-char key) published on one duplicated client and received on another viamessageBuffer, exactly asRedisTransportdoes withuseMessageBuffer: true. Under ioredis 6 / RESP3 the receivedBufferis byte-identical to what was published andBinaryEncoder.decoderound-trips exactly. (For contrast, the non-buffermessagestring path is lossy in both majors — no regression there, just a reminder of whyuseMessageBufferis set.)So the surface itself is fine on 6. The blocker is elsewhere:
@boringnode/bus— a hard dependency of bentocache — peersioredis ^5.0.0(still true at0.9.2) and itsRedisTransportrepeats the exact sameinstanceofmistake:I confirmed this is live, not theoretical: with this PR applied, a foreign-major client handed to
redisBusDriveris now correctly forwarded toRedisTransport, which then fails itsinstanceofand would open publisher/subscriber connections to127.0.0.1:6379withuseMessageBuffersilently falling back tofalse(a binary payload decoded from a lossy string — cf. #19).Widening bentocache's peer to
^6.0.0while a transitive dependency still peers^5.0.0would produce unmet-peer warnings for every ioredis-6 user and hard-fail strict installers, without actually making the bus work across majors. That felt like your call rather than mine, so this PR leavesioredisat^5.3.2and is purely the duck-typing fix. If you want, I am happy to send the same patch to@boringnode/busand then follow up here with the peer bump — just say the word.Scoping it this way also means the fix is a strict improvement on its own: the L2 cache path, which is what actually broke in adonisjs/redis#77, is fully fixed by this PR alone.
Tests
In
tests/drivers/redis.spec.ts. The cross-major cases now use a real second ioredis major, installed side by side through an npm alias:so the driver is handed a genuine
ioredis@6.0.0client while it keeps resolving its ownioredis@5— no shape-faithful stand-in anymore. Each of those cases asserts the fixture really is a different class before using it,assert.instanceOf(client, IoRedisV6)+assert.notInstanceOf(client, IoRedis), the second one being the exact condition that used to send us down the wrong branch.Redisis reused, and the write lands on the connection it was handed: that client is pointed at db 3, the value written through the driver is read back there, and a second client on the db 0 the silent fallback targets sees nothingClusteris reusedhost/port/keyPrefixcarried overredisBusDriverforwards a foreign-major client toRedisTransportinstead of shallow-copying it into an options bagredisBusDriverthrows on an unrecognisable client-shaped objectFive of the six fail on
mainand pass here. The sixth is the options-object one — it guards the other branch against a regression, so it passes on both; I would rather say that than round it up to six.Three of them keep a hand-made object on purpose, because a real client cannot express what they cover:
Proxyfixture covered that a real client does not.RedisTransportkeeps its connection private, and a bare v6 client cannot tell the two code paths apart from the outside: the transport repeats the sameinstanceofcheck and builds its own connection either way (cf. the peer-range section above and fix(redis): detect an existing connection by shape instead of instanceof, and support ioredis 6 boringnode/bus#71). So the recorder observes the only thing that is ours to get right here — that the connection is passed by reference. A null-prototype target has no own enumerable key, so{ ...connection }would read nothing, and any property access proves the object itself was forwarded.What I ran
Redis driver30 passed (29 passed / 1 skipped withCI=1, the real-cluster test). TheRedis+Valkey+File+Memory+DynamoDBdriver specs together: 130 passed, 0 failed — byte-for-byte the same counts before and after this test change.unitsuite (which is where the bus specs live): 244 passed, 0 failed. TheTagging | deleteByTag / can remove by tagtiming flake I hit onmainearlier did not reproduce in this run.src/drivers/redis.tsto theinstanceofversion with the new tests in place: 25 passed, 5 failed of the file's 30 — the five listed above, failing ongetConnection() === client, on the two tripwires not throwing, and on the bus connection not being forwarded. That run also never exits on its own: the stray localhost client the old code builds is never closed, which is the leak side of the same bug.pnpm lintandpnpm typecheckclean.What I did not validate
Clustercase does build a real ioredis 6Cluster, but withlazyConnect, so it covers the detection rather than a live cluster round-trip.