Repository navigation
fix: keep subscriber mode while other channel kinds remain subscribed - #2196
Conversation
Redis counts shard channels separately from channels and patterns, so the count in an (S)UNSUBSCRIBE reply describes only channels of the same kind. DataHandler cleared `condition.subscriber` whenever that count reached zero, discarding subscriptions of the other kinds that were still active: SUNSUBSCRIBE of the last shard channel dropped live channel and pattern subscriptions, and UNSUBSCRIBE of the last channel dropped shard ones. Messages on the surviving subscriptions were then no longer delivered — silently under RESP3, and as "Command queue state error" under RESP2. Use SubscriptionSet#isEmpty() so the state is dropped only once every kind is empty.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 66265b3. Configure here.
|
Good catch on the empty-string channel: |
`""` is a legal channel name, but the `(S|P)UNSUBSCRIBE` reply handlers guarded `del()` with `if (channel)`, so a reply naming the empty channel never removed it. `SubscriptionSet#isEmpty()` then stayed false and `condition.subscriber` was never cleared, leaving the connection stuck in subscriber mode after its last channel was unsubscribed. The earlier `Number(count) === 0` check did not have this gap. Test both guards for a missing reply element instead of truthiness, and cover subscribe/unsubscribe of an empty channel name for both regular and shard channels under RESP2 and RESP3.
|
Addressed in 7efed45. Both handlers now test for a missing reply element instead of truthiness: Four regression tests added (two per protocol): unsubscribing an empty regular channel and sunsubscribing an empty shard channel each leave subscriber mode. On 66265b3 all four fail ( $ TS_NODE_TRANSPILE_ONLY=true NODE_ENV=test npx mocha --no-experimental-strip-types "test/unit/subscriberMode.ts"
12 passing |
|
The one red cell, |
| it("leaves subscriber mode once every kind is unsubscribed", async () => { | ||
| redis = new Redis({ port, protocol }); | ||
| await redis.subscribe("regular"); | ||
| await redis.ssubscribe("shard"); | ||
| await redis.unsubscribe("regular"); | ||
| await redis.sunsubscribe("shard"); | ||
|
|
||
| expect(redis.condition.subscriber).to.equal(false); | ||
| expect(redis.mode).to.equal("normal"); | ||
| }); | ||
|
|
||
| // `""` is a legal channel name, and the reply naming it must still be | ||
| // removed from the subscription set — a truthiness check on the channel | ||
| // skips the removal and leaves the connection stuck in subscriber mode. | ||
| it("leaves subscriber mode after unsubscribing an empty channel name", async () => { | ||
| redis = new Redis({ port, protocol }); | ||
| await redis.subscribe(""); | ||
| await redis.unsubscribe(""); | ||
|
|
||
| expect(redis.condition.subscriber).to.equal(false); | ||
| expect(redis.mode).to.equal("normal"); | ||
| }); | ||
|
|
||
| it("leaves subscriber mode after sunsubscribing an empty shard channel name", async () => { | ||
| redis = new Redis({ port, protocol }); | ||
| await redis.ssubscribe(""); | ||
| await redis.sunsubscribe(""); | ||
|
|
||
| expect(redis.condition.subscriber).to.equal(false); | ||
| expect(redis.mode).to.equal("normal"); | ||
| }); |
There was a problem hiding this comment.
Could we make the redis.mode assertions RESP2-only and rename these tests to describe subscription-state cleanup, keeping the redis.condition.subscriber checks for both protocols? Under RESP3, redis.mode is "normal" even while subscribed, so those assertions pass even if cleanup is broken.
There was a problem hiding this comment.
Done in 21fb05d: the redis.mode assertions run under RESP2 only, the tests are named for the subscription-state cleanup they check, and the redis.condition.subscriber checks stay for both protocols.
`Redis#mode` is derived from `isResp2SubscriberMode`, which is false for protocol 3, so under RESP3 it reads "normal" even while the connection is subscribed. Asserting on it there passes whether or not the subscription state was cleaned up. Assert `condition.subscriber` on both protocols — that is the check which discriminates — and keep the `mode` assertion for RESP2 only. Rename the affected tests to say they cover subscription-state cleanup.
|
Done in 21fb05d, test file only ( You're right about the RESP3 arm: Both arms now discriminate: with |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 21fb05d9ab
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The subscription set keys channels by their utf8 rendering, so channel names that are distinct as bytes but decode alike (e.g. the invalid sequences <80> and <81>, which both render as U+FFFD) collapse onto one key. Unsubscribing either one empties the set while the server still reports a same-kind subscription, and clearing the state on the set alone then drops RESP3 messages and misroutes RESP2 ones to the command queue. Require both the set to be empty and the reply's count to be zero before leaving subscriber mode: the count rules out a same-kind subscription the set collapsed, and the set rules out the channel kinds the count ignores.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 73708adce8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
A channel named `__proto__` was never stored as an own key of the plain object backing the subscription set, so `channels()` did not list it and `isEmpty()` reported the set empty while the subscription was live. An (S)UNSUBSCRIBE of another kind then cleared the subscriber state and the connection dropped messages under RESP3 and misrouted them under RESP2.
There was a problem hiding this comment.
💡 Codex Review
ioredis/lib/SubscriptionSet.ts
Lines 39 to 42 in 81a8859
When a client removes many subscriptions at once, Redis sends one acknowledgement per channel and DataHandler now calls isEmpty() for every acknowledgement. Because each call materializes all remaining keys with Object.keys, unsubscribing N channels performs Θ(N²) work; with 10,000 subscriptions this repeatedly enumerates roughly 50 million entries and can block the Node.js event loop for seconds even though Redis processes the command quickly. Track the set sizes incrementally so each acknowledgement can test emptiness in constant time.
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
The subscription set keyed channels by their utf8 rendering, so names that are distinct as bytes but decode alike (e.g. the invalid sequences <80> and <81>, which both render as U+FFFD) shared one entry. Unsubscribing either one dropped both, leaving the set empty while the server still held a live subscription. The reply count only covers its own kind, so it hides this for the acknowledgement that carries it but not for a later zero count from another kind: SUBSCRIBE <80>, SUBSCRIBE <81>, UNSUBSCRIBE <80>, SSUBSCRIBE, SUNSUBSCRIBE leaves subscriber mode with <81> still subscribed. Its later messages are then dropped under RESP3 and misrouted to the command queue under RESP2. Key the set by latin1, which round-trips every byte, and keep the utf8 rendering as the value so channels() is unchanged for its callers. Storing the channels in a Map also makes isEmpty() a size check. It runs once per unsubscribe acknowledgement and the server sends one per channel, so materializing the keys made unsubscribing N channels O(N^2) work and could stall the event loop for large subscription sets.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e503279ed3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
|
Two more pushes since my last note, both answering the Codex findings:
Each case has a failing test on the previous head and passes on this one; |
A server or proxy that answers SUBSCRIBE with a simple string leaves `reply[1]` as a byte of that string rather than a channel name. Keying channels by their bytes made `SubscriptionSet` reject it outright, and the throw escaped the decoder's reply dispatch as an uncaught error. Accept the value and key it by its string form, as the previous `toString()` did, so only the key derivation changed and not which replies the set tolerates.
|
Everything from the review and the bot findings is addressed at Summary of what changed since the review: the RESP2-only Ready for another look whenever you have time. Happy to split anything out if the scope has grown past what you want in one PR. |
Regular channels and patterns share one remaining count and shard channels have their own; the comments now say so instead of "same kind". New cases: pattern plus shard in both orders, a channel and a pattern sharing one count, a positive server count with an empty local set, and the fire-and-forget blocks in the callback tests now deliver a rejection to done. The byte-writing helper is inlined.
|
@PavelPashov I cut the tests down to make this easier to review.
Happy to reshape it further if you'd like it another way. |

Small, isolated bug fix with a focused test, submitted under the carve-out in
.github/CONTRIBUTING.md("Small, isolated bug fixes where the cause and solution are clear and a focused test demonstrates the fix"). Happy to open an issue first instead if you'd prefer that route.Summary
(S)UNSUBSCRIBEreply describes only channels of the same kind.lib/DataHandler.tstreated that count as global and clearedcondition.subscriberwhenever it reached0, in both the RESP3 push path and the RESP2 reply path.SUNSUBSCRIBEof the last shard channel silently discards still-live channel and pattern subscriptions (andUNSUBSCRIBEof the last channel discards shard ones). Messages on the surviving subscriptions stop being delivered: silently under RESP3, and as aCommand queue state errorunder RESP2.SubscriptionSet#isEmpty(), which already existed inlib/SubscriptionSet.tswith no callers; the intended API was never wired up. 13 insertions, 5 deletions.test/unit/subscriberMode.tscovers RESP2 and RESP3 with the repo'sMockServer, so it needs no Docker or live Redis: 6 of 8 fail on base, 8 of 8 pass with the fix.Decisions
SubscriptionSetinstead of trusting the per-kind count. Thedel()calls immediately above already maintain the set per kind, soisEmpty()is accurate at that point, and it is the helper that was written for exactly this.countis still read and still passed tofillUnsubCommand, so whatunsubscribe()/sunsubscribe()return to the caller is unchanged; only the internal mode bookkeeping moves.condition.subscriberowned byDataHandler; standalone clients hit it too, as both suites show.lib/DataHandler.tshas a pre-existing--checkwarning at theprotocol !== 3 && handleSubscriberReply(reply)condition from Add RESP3 #2127, nowhere near these hunks; left alone to keep this PR to one bug. The new test file passes--checkcleanly.Not run here:
npm run test:js/test:cluster(neednpm run docker:setup); the tests above drivetest/helpers/mock_server.ts, a real TCP server speaking RESP2 and RESP3, so both paths execute for real.AI assistance: the bug was traced and the fix and tests were drafted with AI tooling in my workflow; the tests, build and lint were executed as pasted above. I'm responsible for the change and will handle review feedback.
Note
Medium Risk
Changes core pub/sub connection state on every unsubscribe path for RESP2 and RESP3; incorrect logic could still desync the command queue or drop message delivery.
Overview
Fixes subscriber mode being cleared too early when
(S)UNSUBSCRIBEreplies report a per-kind remaining count of zero while regular, pattern, or shard subscriptions on the same connection are still active.DataHandlernow callsleaveSubscriberModeIfDone, which clearscondition.subscriberonly when the trackedSubscriptionSetis empty and the reply count is not still positive—on both RESP2 reply routing and RESP3 push handling. Unsubscribe handling also treats empty channel names as valid (onlynull/undefinedmeans “no channel”) and passes channel values through asChannelNameinstead of forcing.toString().SubscriptionSetstores channels in Maps keyed by byte-accuratelatin1keys so binary-safe names (and edge cases like__proto__) are tracked correctly;isEmpty()uses map sizes for O(1) checks.Adds
test/unit/subscriberMode.tsfor mixed subscribe/ssubscribe flows on RESP2/RESP3 plusSubscriptionSetunit cases.Reviewed by Cursor Bugbot for commit 8f949b4. Bugbot is set up for automated code reviews on this repo. Configure here.