Skip to content

redis: fix reply-desynchronization bugs that delivered wrong values to wrong commands - #32858

Open
robobun wants to merge 1 commit into
mainfrom
farm/b76b2a03/redis-reply-desync
Open

redis: fix reply-desynchronization bugs that delivered wrong values to wrong commands#32858
robobun wants to merge 1 commit into
mainfrom
farm/b76b2a03/redis-reply-desync

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

What

Fixes a set of reply-desynchronization bugs in Bun.RedisClient where a command's promise resolves with some other command's reply (the "wrong value for the wrong key" class). These do not crash; they silently hand application code incorrect data under conditions that production Redis usage hits constantly.

All reproduce deterministically against an in-process byte-scripted RESP3 server (no real Redis needed).

Rebased onto #33072. That PR independently fixed two of the bugs this one originally covered (out-of-band pushes consuming a command's reply slot, and in-flight commands surviving a reconnect). This PR keeps that behavior, replaces the is_reply_kind prefix heuristic with explicit push-kind map entries (which also covers the pmessage/smessage kinds it did not), and fixes the remaining bugs below. The two tests covering #33072's fixes are kept as extra regression coverage: they pass on main today, the rest fail.

The bugs

Multi-channel subscribe(["a","b","c"], cb) steals other commands' reply slots

SUBSCRIBE with N channels emits N confirmation pushes but occupies one in-flight slot. The 2nd..Nth confirmations were consumed by whatever commands followed:

expect(received).toEqual(expected)
  {
-   "got": "value-of-k1",
-   "ping": "PONG",
+   "got": 3,          // GET k1 resolved with subscription count
+   "ping": 3,         // ping()  resolved with subscription count

An error reply in subscriber mode fails the whole connection instead of its own command

A -LOADING (or any -ERR) reply to PING on a subscriber connection went through fail(), which rejected every other pending command and left the PING's own promise unsettled:

expect(received).toEqual(expected)
  {
-   "status": "fulfilled",
-   "value": "PONG",
+   "reason": [Error: LOADING Redis is loading the dataset in memory],
+   "status": "rejected",      // the *second* PING, which got +PONG

Pattern and sharded subscription confirmations are not recognized

psubscribe, punsubscribe, pmessage and the Redis 7.0+ sharded ssubscribe, sunsubscribe, smessage push kinds were not in the push-kind map, so PSUBSCRIBE/SSUBSCRIBE resolved with the raw RESP push frame ({type, data}) instead of a channel count, and pmessage payloads were never dispatched as [channel, message].

Raw client.send("SUBSCRIBE", [...]) could not pair its confirmation

The raw escape hatch builds command meta via Meta::check, which only toggled auto-pipelining and never derived the subscription flags — so the confirmation push had no matching in-flight head to count against.

Extra unsubscribe confirmations could be charged to a queued SUBSCRIBE

An argless UNSUBSCRIBE emits one confirmation per currently-subscribed channel. With a single undirected SUBSCRIPTION_REQUEST flag, the extras could decrement and resolve a SUBSCRIBE that had been drained into the in-flight queue behind it.

Fix

In handle_response:

  • RESP3 push frames (>) are routed out-of-band unconditionally. The type byte alone marks them as unsolicited.
  • SUBSCRIPTION_REQUEST is split into SUBSCRIBE_REQUEST and UNSUBSCRIBE_REQUEST, so a confirmation only counts against an in-flight head of the matching direction. Each such request carries remaining_replies (its channel count) and is only consumed once all its confirmations have arrived.
  • Non-subscription pushes (invalidate, etc.) are dropped without consuming any reply slot.
  • An Error reply on a subscriber connection falls through to the same per-command rejection path as non-subscriber mode.

In Meta::check: subscription flags are derived from the command name, case-insensitively, covering the plain, pattern (P*) and sharded (S*) families — so every entry point (prototype methods and the raw send() hatch) pairs the same way. Any casing that matches also has auto-pipelining cleared, since the case-sensitive AUTO_PIPELINE_DISALLOWED_COMMANDS lookup would otherwise miss it.

In SUBSCRIPTION_PUSH_MESSAGES: all nine push kinds are mapped explicitly. This makes #33072's is_reply_kind prefix heuristic dead, so it is deleted; the explicit map additionally covers pmessage/smessage, which the heuristic rejected.

In on_close: the read buffer and reply scanner are reset (they belong to the dead socket), alongside #33072's in-flight rejection.

Behavior change

psubscribe() now resolves with the subscribed-channel count, like subscribe() (declared Promise<number>), instead of the raw RESP push frame. psubscribe is not in the public type declarations and the old value was a leaked protocol frame. The assertion in resp-nesting-depth.test.ts is updated; its load-bearing invariant (the pipelined PING still receives its own PONG) is unchanged.

Verification

$ bun bd test test/js/valkey/valkey-reply-desync.test.ts
 9 pass  0 fail

Without the src/ change, 7 of the 9 fail with the diffs shown above (the other 2 cover #33072's fixes and pass on main). The full scripted-server Valkey suite is green:

$ bun bd test test/js/valkey/{valkey-reply-desync,valkey-gc,valkey-incremental-scan,valkey-tls-verify}.test.ts \
             test/js/valkey/reliability/{connection-failures,resp-nesting-depth}.test.ts
 28 pass  0 fail  (13 skipped, need Docker)

Fixes #27861

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds reply-count tracking for Valkey commands, splits subscription request metadata, updates RESP3 push handling to consume multi-reply subscription confirmations correctly, adjusts connection-close cleanup, extends pattern-subscription protocol mapping, and adds desync regression tests.

Valkey reply-slot pairing

Layer / File(s) Summary
Command reply-count and meta contract
src/runtime/valkey_jsc/ValkeyCommand.rs
Adds Command::expected_reply_count(), initializes offline entry reply counts, splits subscription meta flags, updates the auto-pipeline disallowed command list, derives granular subscription flags from command names, and documents PromisePair.remaining_replies.
JS subscription command metadata
src/runtime/valkey_jsc/js_valkey_functions.rs
Extends cmd_strings_varargs! to accept caller-provided meta, then uses it to mark psubscribe, punsubscribe, subscribe, and unsubscribe cleanup requests with the granular subscription flags.
Pattern subscription push mapping
src/valkey/valkey_protocol.rs
Extends SUBSCRIPTION_PUSH_MESSAGES to map pmessage, psubscribe, and punsubscribe byte strings to existing subscription push variants.
RESP3 push dispatch and reply-slot tracking
src/runtime/valkey_jsc/valkey.rs
Removes SubscribeHandled, rewrites subscription push dispatch, decrements in-flight remaining_replies for subscription pushes, seeds reply counts when enqueueing and draining commands, and pairs subscription confirmations with the correct in-flight head.
Connection-close rejection
src/runtime/valkey_jsc/valkey.rs
Clears buffers and reply scanner in on_close, then rejects all still-pending in-flight commands with ConnectionClosed before manual-close or reconnect handling continues.
Desync regression tests
test/js/valkey/valkey-reply-desync.test.ts
Adds a mock RESP3 TCP server, incremental frame parser, helpers, and test cases covering out-of-band pushes, reconnect rejection, multi-channel subscribe confirmations, subscriber-mode errors, pattern subscribe/unsubscribe pairing, raw SUBSCRIBE, and unsubscribe pipeline integrity.

Suggested reviewers

  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: fixing Redis reply desynchronization bugs that misrouted replies between commands.
Description check ✅ Passed The description covers both what the PR does and how it was verified, though it does not follow the template headings exactly.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:37 AM PT - Jul 5th, 2026

@robobun, your commit db3de576dc6c57cedf450219be6ac43fc4f3ffa5 passed in Build #68522! 🎉


🧪   To try this PR locally:

bunx bun-pr 32858

That installs a local version of the PR into your bun-32858 executable, so you can run:

bun-32858 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. [CRITICAL] redis client response mismatch #27861 - Directly describes bug Fix calling #private() functions in classes #2 (in-flight commands surviving reconnect), pinpointing the exact root cause in onClose not clearing in_flight on the auto-reconnect path
  2. node-redis Pub/Sub silently fails to reconnect in rare cases #21622 - Subscriber connection entering non-functional "limbo" state after reconnect, plausibly caused by stale in-flight entries (bug Fix calling #private() functions in classes #2) and error reply failing the whole connection (bug Support import assertions #4)

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #27861
Fixes #21622

🤖 Generated with Claude Code

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Added Fixes #27861 (exact match for the reconnect desync).

Not claiming #21622: that report is about the npm redis package going through Bun's node:net, which is a different code path from the built-in Bun.RedisClient this PR touches.

Comment thread src/runtime/valkey_jsc/ValkeyCommand.rs
Comment thread src/runtime/valkey_jsc/valkey.rs
Comment thread src/runtime/valkey_jsc/valkey.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/valkey_jsc/valkey.rs`:
- Around line 632-636: The new comment blocks in valkey.rs exceed the 3-line
comment limit and should be condensed. Shorten the explanatory comments around
the affected socket/transport handling in the relevant methods (including the
close/reject logic and related helper sections) so each block keeps only the
durable invariant, and move the extra rationale into the PR description instead.
- Around line 916-940: The SUBSCRIBE promise is being resolved with a stale
subscription count because `sub_count` is read before `add_subscription()` and
`on_valkey_subscribe(value)` in the Valkey subscription handler. Update the
`SubscriptionPushMessage::Subscribe` branch in `on_valkey_push_message` so the
subscription state is applied first, then compute the current subscribed count
from `_subscription_ctx` and use that value when resolving the promise. This
keeps the `(P)SUBSCRIBE` confirmation aligned with the latest state.

In `@src/runtime/valkey_jsc/ValkeyCommand.rs`:
- Around line 111-114: The subscription reply counting in
ValkeyCommand::expected_reply_count is undercounting zero-argument
UNSUBSCRIBE/PUNSUBSCRIBE commands because it uses the current args length
instead of the active subscription count. Update the zero-arg branch to derive
the expected reply count from the pre-clear subscription total for the current
connection/context, while keeping the existing behavior for explicit channel
arguments and other subscription requests.

In `@test/js/valkey/valkey-reply-desync.test.ts`:
- Around line 5-8: The file-level and inline comments in
valkey-reply-desync.test.ts exceed the repo’s 3-line comment limit; trim the
explanatory text to concise 3-line max summaries and move the longer
reply-integrity/buffering rationale to the PR description. Update the comment
blocks around the test setup and the affected test sections so they stay terse
while still pointing to the invariant, especially in the blocks near the
Byte-scripted RESP3 server setup and the other flagged comment regions.
- Around line 320-357: The current valkey reply-desync coverage only verifies
the PSUBSCRIBE confirmation path in RedisClient.psubscribe; add scripted
mock-server tests for pattern message dispatch and PUNSUBSCRIBE reply-slot
mapping as well. Extend the existing valkey-reply-desync suite with cases that
exercise pmessage handling and unsubscribing from a pattern subscription so the
reply pairing logic is covered for all pattern paths alongside the existing
subscribe/unsubscribe behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 59037164-0d93-4d6b-b106-22baf3008b45

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and cee53da.

📒 Files selected for processing (5)
  • src/runtime/valkey_jsc/ValkeyCommand.rs
  • src/runtime/valkey_jsc/js_valkey_functions.rs
  • src/runtime/valkey_jsc/valkey.rs
  • src/valkey/valkey_protocol.rs
  • test/js/valkey/valkey-reply-desync.test.ts

Comment thread src/runtime/valkey_jsc/valkey.rs Outdated
Comment thread src/runtime/valkey_jsc/valkey.rs
Comment thread src/runtime/valkey_jsc/ValkeyCommand.rs
Comment thread test/js/valkey/valkey-reply-desync.test.ts Outdated
Comment thread test/js/valkey/valkey-reply-desync.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/valkey_jsc/ValkeyCommand.rs (1)

199-212: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the auto-pipeline disallow check match the new case-insensitive command parsing.

SUBSCRIBE/UNSUBSCRIBE flags are derived with eq_ignore_ascii_case, but SUPPORTS_AUTO_PIPELINING still uses an exact table lookup. A raw client.send("subscribe", ...) can therefore be recognized as a subscription request while remaining auto-pipelineable.

Suggested fix
+        let auto_pipeline_disallowed = AUTO_PIPELINE_DISALLOWED_COMMANDS
+            .iter()
+            .any(|disallowed| command.command.eq_ignore_ascii_case(disallowed));
         new.set(
             Meta::SUPPORTS_AUTO_PIPELINING,
-            !AUTO_PIPELINE_DISALLOWED_COMMANDS.contains(command.command),
+            !auto_pipeline_disallowed,
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/valkey_jsc/ValkeyCommand.rs` around lines 199 - 212, The
auto-pipeline gate in ValkeyCommand::new is still doing a case-sensitive lookup
even though subscription handling now uses case-insensitive command parsing.
Update the SUPPORTS_AUTO_PIPELINING check to use the same case-insensitive
matching as the SUBSCRIBE/PSUBSCRIBE and UNSUBSCRIBE/PUNSUBSCRIBE logic, so
commands like “subscribe” and “unsubscribe” are disallowed from auto-pipelining
consistently.
♻️ Duplicate comments (1)
src/runtime/valkey_jsc/valkey.rs (1)

912-936: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve SUBSCRIBE after updating subscription state.

sub_count is read before add_subscription() / on_valkey_subscribe(value), so the resolving (P)SUBSCRIBE promise can still miss the current confirmation. Move the count lookup into the Subscribe branch after the state update.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/runtime/valkey_jsc/valkey.rs` around lines 912 - 936, The SUBSCRIBE
confirmation in the valkey push handler is resolving with a stale subscription
count because `channels_subscribed_to_count` is read before `add_subscription()`
and `on_valkey_subscribe(value)`. Update `process_push_message` so the count is
fetched inside the `SubscriptionPushMessage::Subscribe` branch after the
subscription state is updated, then use that fresh value when resolving the
promise.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/valkey-reply-desync.test.ts`:
- Around line 363-387: The raw send subscription test only covers uppercase
SUBSCRIBE and uses a loose numeric assertion, so tighten it to fail
deterministically. Update the RedisClient send/raw command test around the
SUBSCRIBE case to also exercise a lowercase command path (send with "subscribe")
and assert the exact confirmation count returned is 1. Keep the existing socket
mock and reply-slot behavior in the same test, using the same send()/ping() flow
to verify casing-insensitive metadata.

---

Outside diff comments:
In `@src/runtime/valkey_jsc/ValkeyCommand.rs`:
- Around line 199-212: The auto-pipeline gate in ValkeyCommand::new is still
doing a case-sensitive lookup even though subscription handling now uses
case-insensitive command parsing. Update the SUPPORTS_AUTO_PIPELINING check to
use the same case-insensitive matching as the SUBSCRIBE/PSUBSCRIBE and
UNSUBSCRIBE/PUNSUBSCRIBE logic, so commands like “subscribe” and “unsubscribe”
are disallowed from auto-pipelining consistently.

---

Duplicate comments:
In `@src/runtime/valkey_jsc/valkey.rs`:
- Around line 912-936: The SUBSCRIBE confirmation in the valkey push handler is
resolving with a stale subscription count because `channels_subscribed_to_count`
is read before `add_subscription()` and `on_valkey_subscribe(value)`. Update
`process_push_message` so the count is fetched inside the
`SubscriptionPushMessage::Subscribe` branch after the subscription state is
updated, then use that fresh value when resolving the promise.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f707f407-7eef-47c4-9176-bae6854610ce

📥 Commits

Reviewing files that changed from the base of the PR and between cee53da and 9fe94cf.

📒 Files selected for processing (3)
  • src/runtime/valkey_jsc/ValkeyCommand.rs
  • src/runtime/valkey_jsc/valkey.rs
  • test/js/valkey/valkey-reply-desync.test.ts

Comment thread test/js/valkey/valkey-reply-desync.test.ts
Comment thread src/valkey/valkey_protocol.rs
Comment thread src/runtime/valkey_jsc/ValkeyCommand.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/valkey/valkey-reply-desync.test.ts`:
- Around line 365-369: The raw command matrix in the desync test is missing the
lowercase sharded case, so add the case-insensitive `"ssubscribe"` entry
alongside `"SSUBSCRIBE"` in the `test.each` table. Update the `raw send(%p,
[...]) pairs its confirmation push with its own reply slot` matrix so
`commandName`/`pushKind` coverage includes `["ssubscribe", "ssubscribe"]` and
the sharded path is tested for both casings.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8c8df724-773a-48e1-843e-1c1d9d089305

📥 Commits

Reviewing files that changed from the base of the PR and between 9fe94cf and 28892c8.

📒 Files selected for processing (3)
  • src/runtime/valkey_jsc/ValkeyCommand.rs
  • src/valkey/valkey_protocol.rs
  • test/js/valkey/valkey-reply-desync.test.ts

Comment thread test/js/valkey/valkey-reply-desync.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All four of my earlier regression findings are addressed and the remaining inline note is a zero-impact doc-comment nit, but this rewrites the core RESP3 reply-routing state machine and on_close rejection semantics for Bun.RedisClient, so it's worth a human pass over the final handle_response / reject_in_flight shape before merge.

Extended reasoning...

Overview

This PR fixes four independent reply-desynchronization bugs in Bun.RedisClient where a command's promise resolves with another command's reply. It touches the heart of the client's correctness model:

  • src/runtime/valkey_jsc/valkey.rs: rewrites the handle_response push-routing logic (RESP3 > frames are now routed out-of-band unconditionally; subscription confirmations decrement a per-command remaining_replies counter and only consume the in-flight head when direction matches), removes the SubscribeHandled enum, and adds reject_in_flight so on_close rejects all in-flight commands before reconnect.
  • src/runtime/valkey_jsc/ValkeyCommand.rs: adds expected_reply_count(), splits SUBSCRIPTION_REQUEST into directional SUBSCRIBE_REQUEST/UNSUBSCRIBE_REQUEST flags, derives those flags case-insensitively in Meta::check for the raw send() path, adds SSUBSCRIBE/SUNSUBSCRIBE/PUNSUBSCRIBE to the auto-pipeline disallow set (and fixes the pre-existing UNPSUBSCRIBE typo).
  • src/valkey/valkey_protocol.rs: extends SUBSCRIPTION_PUSH_MESSAGES to recognize p* and s* push kinds.
  • src/runtime/valkey_jsc/js_valkey_functions.rs: extends cmd_strings_varargs! to accept a meta expression and flags psubscribe/punsubscribe/subscribe/unsubscribe with the directional flags.
  • test/js/valkey/valkey-reply-desync.test.ts: 450-line byte-scripted RESP3 server with 7 deterministic regression tests.

Security risks

None identified. No auth, crypto, permissions, or filesystem changes. Server input is parsed by the existing RESP scanner (unchanged); the diff only changes which already-parsed values are routed to which JS promise. The new on_close path strictly rejects more (previously it leaked stale slots), which is safer.

Level of scrutiny

High. This is a data-correctness change to a production API (Bun.RedisClient) where a bug silently delivers wrong values to application code rather than crashing. The PR went through four rounds of substantive regression findings from me during review (psubscribe push-kind gap, argless-unsubscribe cross-contamination, raw send() flag derivation, sharded s* push-kind gap), each of which was a real desync the PR itself introduced and each of which was fixed in a follow-up commit (cee53da, 9fe94cf, c9d7c7f, defc764). That iteration history is exactly why a human should confirm the final state machine — the design is now sound as far as I can see, but the number of edge cases that surfaced during review is itself a signal that the routing logic deserves careful eyes.

Other factors

  • All earlier inline comments (mine and CodeRabbit's) are resolved; the author responded to each with a targeted commit and test.
  • The one remaining inline note from this run is a pure documentation nit ((P)(P|S) in five comment lines) with explicitly zero functional impact — not a reason to hold the PR.
  • One pre-existing follow-up was identified and intentionally deferred by the author with a clear rationale (case-sensitive AUTO_PIPELINE_DISALLOWED_COMMANDS lookup for non-subscription commands via raw send(); pre-existing on main, single-reply commands, no desync risk).
  • CI build #65595 has one failure (v8-heap-snapshot.test.ts SIGKILL on a single Linux x64 shard) which is unrelated to anything this PR touches.
  • No CODEOWNERS entry for valkey/redis paths; CodeRabbit suggested cirospaciari.

Comment thread src/runtime/valkey_jsc/ValkeyCommand.rs
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Independent report of bug 2 (in-flight commands surviving an auto-reconnect and cross-matching with the new connection's replies) came in today with this repro:

ok  GET a -> val1:a
BUG GET b -> val2:d
BUG GET c -> val2:e
BUG GET d -> val2:f
BUG GET e -> (TIMEOUT: never settled)
BUG GET f -> (TIMEOUT: never settled)

Confirmed the fix here resolves it. A narrower standalone fix (just the on_close in-flight rejection) plus a mock-server regression test is on farm/f14ddff0/redis-reconnect-inflight in case it's useful to split out.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (rebased onto main, sha db3de576): the diff is green.

Build 68522: 284 test jobs passed, 0 failed, 0 error annotations.

The one lane that has not reported is darwin-14-aarch64 - test-bun. Buildkite has now auto-retried it twice; every attempt reports exit_status: null, i.e. the job was never assigned to an agent / was reaped before producing any output. It is an agent-pool problem, not a test result — there is no log and no failure annotation for it. The same lane expired identically on the previous two builds (65595, 65643).

The only other annotation is a flaky warning for two Windows tests (cli/update_interactive_install.test.ts, js/bun/shell/exec.test.ts), both auto-retried and passed. Neither touches Redis.

This PR changes only the Rust Valkey client (src/valkey/, src/runtime/valkey_jsc/) and one TypeScript test file. It cannot affect whether a macOS Buildkite agent picks up a job.

Local verification on the rebased tree:

$ bun bd test test/js/valkey/valkey-reply-desync.test.ts
 9 pass  0 fail

$ git stash push -- src/ && bun bd test test/js/valkey/valkey-reply-desync.test.ts
 7 fail          # the other 2 cover #33072's fixes and pass on main

$ bun bd test test/js/valkey/{valkey-reply-desync,valkey-gc,valkey-incremental-scan,valkey-tls-verify}.test.ts \
               test/js/valkey/reliability/{connection-failures,resp-nesting-depth}.test.ts
 28 pass  0 fail   # includes #33072's three new valkey tests

The desync suite also passed on the linux-x64-asan lane in this build. Already retriggered once earlier; not retriggering again. Ready for review.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Independently reproduced and root-caused bug 1 from a separate report of the same issue: a > push frame delivered to a non-subscriber client pops the oldest in-flight promise pair, so every later command resolves with the previous command's reply. Confirmed the fix here resolves it; not opening a separate PR.

Branch with an equivalent fix plus one extra scenario, in case the test is useful to fold in: https://github.com/oven-sh/bun/tree/farm/42d71aaf/redis-resp3-push-desync

The extra test covers the sibling case this PR also fixes but does not test: a subscriber connection receiving a push of an unrecognized kind previously went through fail() ("Unexpected push message kind without promise") and tore down the whole client. test/js/valkey/reliability/resp3-push-frame.test.ts, last test in the file.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Ran an extra round of verification of the subscription changes here against a real redis-server 8.0.2 rather than the scripted server:

  • subscribe(["a","b","c"], cb) pipelined with ping() and send("GET", ["k1"]) resolves to 3 / "PONG" / the key's value. Without the fix, the ping and the get both resolve to 3 (the subscription count) and their real replies are delivered to later commands.
  • publish to one of the three channels still invokes the right listener with (message, channel).
  • A bare unsubscribe(), which the server confirms with three >unsubscribe pushes, pipelined with ping() resolves to undefined / "PONG".
  • Consecutive multi-channel subscribes, await subscribe(["x","y"]) then await subscribe(["z"]), resolve to 2 then 3. This is the case that needs remaining_replies: settling a SUBSCRIBE on its first confirmation leaves its remaining confirmations in flight to consume the next subscription command's reply slot.

A second scripted-server test written independently of this branch (multi-channel SUBSCRIBE and bare UNSUBSCRIBE, each pipelined with other commands) also passes on it.

@robobun

robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

A fuzzing lead against Bun.RedisClient lands in the code this PR rewrites, so leaving it here instead of opening a second PR into the same dispatch.

Input: a RESP3 server that answers SUBSCRIBE with an unsubscribe push frame.

const srv = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data(s, b) {
  const t = b.toString();
  if (t.includes("HELLO")) s.write("%3\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$7\r\nversion\r\n$5\r\n7.4.0\r\n");
  else if (t.includes("SUBSCRIBE")) s.write(">3\r\n$11\r\nunsubscribe\r\n$2\r\nch\r\n:0\r\n");
}}});
const c = new Bun.RedisClient(`redis://127.0.0.1:${srv.port}`, { autoReconnect: false });
await c.connect();
await c.subscribe("ch", () => {});

On current main (f789198):

  • debug build: panic: assertion failed: self.is_subscriber(). The pending SUBSCRIBE routes the push into on_valkey_unsubscribe() on a client that never entered subscriber mode.
  • release build: the unsubscribe transition runs anyway and subscribe() resolves with undefined.

I checked out this branch (28892c8) and re-ran the repro: no assertion failure and no state transition, because the unsubscribe push no longer pairs with a SUBSCRIBE_REQUEST head and on_valkey_unsubscribe() is now gated on is_subscriber(). So the rewrite here covers that case too. It would be worth adding the frame above as a regression test in this PR so the new pairing logic keeps covering it.

One behavior question for this PR: with the push silently dropped, the pending subscribe() promise never settles (the repro above hangs). A confirmation push whose direction contradicts the in-flight head means the connection is desynced, so it may be better to reject that head (or fail the connection) instead of leaving the caller waiting. I have a branch that takes the strict approach on top of main, plus a scripted-server test you are welcome to lift either way: main...farm/8aa7ed61/valkey-unsubscribe-push

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Cross-checking from a separate report: await redis.subscribe(ch, cb) never settles when the server answers the SUBSCRIBE with -ERR LOADING …, and the same happens when a message push arrives before the subscribe confirmation.

Both are fixed by this PR. I built this branch and ran the two cases against a scripted RESP3 server:

case main this branch
SUBSCRIBE answered -ERR LOADING … promise never settles rejects with the server error, connection survives
message push before the subscribe confirmation promise never settles confirmation resolves it normally

So the -ERR-in-subscriber-mode fix (bug 4) covers SUBSCRIBE itself, not just PING, and the out-of-band push routing covers the early-push window. Might be worth adding those two to valkey-reply-desync.test.ts so the SUBSCRIBE path is pinned down explicitly.

One thing this PR leaves behind: the listener subscribe() registers up front is never rolled back, so once the promise correctly rejects, the listener is still in the callback map. A retry registers it a second time and delivers every message twice, and the non-empty map keeps the event loop pinned. That's independent of the reply routing here, and I put a fix in #33290 — it touches adjacent lines in handle_subscribe_response, so whichever of the two lands second needs a trivial rebase.

…e to the wrong command

A command's promise must only ever be fulfilled with that command's own
reply. Several independent paths in the Valkey client broke this.

- SUBSCRIBE with N > 1 channels produces N confirmation pushes but only
  occupies one in-flight slot; confirmations 2..N consumed the slots of
  whatever unrelated commands followed (ping() and get() both resolved
  with the subscription count).
- In subscriber mode a '-ERR' reply to any command (e.g. -LOADING to a
  PING) was routed through fail(), which rejected every other pending
  command while leaving the erroring command's own promise unsettled.
- The pattern and sharded push kinds (psubscribe, ssubscribe, pmessage,
  smessage, punsubscribe, sunsubscribe) were not recognized as
  subscription confirmations, so a PSUBSCRIBE/SSUBSCRIBE resolved with
  the raw RESP push frame instead of the subscribed-channel count.
- The raw client.send("SUBSCRIBE", [...]) escape hatch built its meta via
  Meta::check, which never derived the subscription flags, so its
  confirmation could not be paired against its own command.
- An argless UNSUBSCRIBE emits one confirmation per subscribed channel;
  with a single undirected SUBSCRIPTION_REQUEST flag those extras could
  be charged to a following SUBSCRIBE drained into the in-flight queue.

Push frames are now routed out-of-band unconditionally: the '>' type byte
alone makes them unsolicited. SUBSCRIPTION_REQUEST is split into
SUBSCRIBE_REQUEST and UNSUBSCRIBE_REQUEST so a confirmation only counts
against an in-flight head of the matching direction, and each such
request tracks how many confirmations it is owed. Meta::check derives
those flags from the command name (case-insensitively, covering the
plain, pattern and sharded families) so every entry point pairs the same
way, and clears auto-pipelining for any casing that matches. An error
reply in subscriber mode falls through to the same per-command rejection
path as non-subscriber mode. on_close also drops the read buffer and
reply scanner, which belong to the dead socket.

Out-of-band push routing and in-flight rejection on reconnect were fixed
independently in #33072; this keeps their behavior and replaces the
`is_reply_kind` prefix heuristic with explicit push-kind map entries,
which also covers the message kinds it did not.

`psubscribe()` now resolves with the subscribed-channel count like
`subscribe()` does, rather than the raw push frame; the assertion in
resp-nesting-depth.test.ts is updated accordingly.

Tested with a byte-scripted in-process RESP3 server so each case is
deterministic and needs no real Redis.
@robobun
robobun force-pushed the farm/b76b2a03/redis-reply-desync branch from 28892c8 to db3de57 Compare July 5, 2026 09:58
@robobun robobun changed the title redis: fix four reply-desynchronization bugs that delivered wrong values to wrong commands redis: fix reply-desynchronization bugs that delivered wrong values to wrong commands Jul 5, 2026
@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (was conflicting with #33072, hardening round 11).

#33072 independently fixed two of the bugs this PR covered, in a different shape:

  • out-of-band push frames no longer consume a command's reply slot
  • in-flight commands are rejected on an auto-reconnect

Reconciled as follows:

  • Kept Hardening round 11: input validation, bounds checks, lifetimes #33072's reject_in_flight_commands and its placement on the reconnect path. It handles the finalized case by deferring through DeferredFailure, which my version did not. Added the read-buffer / reply-scanner reset on top, since those belong to the dead socket.
  • Replaced SubscriptionPushMessage::is_reply_kind with explicit entries in SUBSCRIPTION_PUSH_MESSAGES for all nine push kinds. The prefix heuristic recognized p*/s* subscribe/unsubscribe but rejected pmessage/smessage, and could not feed the directional pairing this PR needs. With the map covering them, is_reply_kind has no callers and is deleted.
  • Kept the rest of this PR's rewrite (directional SUBSCRIBE_REQUEST/UNSUBSCRIBE_REQUEST flags, remaining_replies per command, Meta::check name-based flag derivation, subscriber-mode error routing).

One behavior change worth calling out: psubscribe() now resolves with the subscribed-channel count like subscribe() does, instead of the raw RESP push frame. #33072's resp-nesting-depth.test.ts pinned the old value, so that assertion is updated; the test's load-bearing invariant (the pipelined PING still gets its own PONG) is unchanged and still asserted.

Verification after the rebase:

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Complementary: #33479 fixes the other half of this failure mode. This PR stops the subscriber path from calling fail() on a pmessage/smessage/invalidate push; #33479 makes fail() close the socket, so no caller of it (protocol parse error, non-OK SELECT, idle timeout) can leave a client stranded in failed && connected with onclose never firing.

Different functions (handle_response vs fail_with_js_value), no conflict.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Triage note: #27861 was closed as fixed by #33072 (in-flight commands rejected on reconnect). With only this PR's test files applied to current main (165dc9f), 5 of the 9 tests in valkey-reply-desync.test.ts now pass, including the multi-channel SUBSCRIBE and raw send("SUBSCRIBE") cases, so part of what this PR covered has landed since it was written. These still fail on main:

  • an error reply in subscriber mode rejects only its own command
  • PSUBSCRIBE and PUNSUBSCRIBE confirmations pair with their own reply slots
  • raw send("SSUBSCRIBE", [...]) pairs its confirmation push with its own reply slot
  • extra unsubscribe confirmations do not consume a queued SUBSCRIBE's reply slot
  • resp-nesting-depth.test.ts: the updated psubscribe assertion (main still resolves it with the raw push frame)

Staying open for those. The branch currently conflicts with main and needs a rebase against the overlapping changes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CRITICAL] redis client response mismatch

1 participant