redis: route RESP3 replies on the frame an attribute decorates - #33441
redis: route RESP3 replies on the frame an attribute decorates#33441robobun wants to merge 4 commits into
Conversation
|
Warning Review limit reached
Next review available in: 7 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 2:08 PM PT - Jul 6th, 2026
❌ @robobun, your commit 189dbf4 has some failures in 🧪 To try this PR locally: bunx bun-pr 33441That installs a local version of the PR into your bun-33441 --bun |
There was a problem hiding this comment.
I didn't find any issues — the native change is minimal and safe Rust — but this alters the RESP3 response-dispatch path for the Redis client and adds a scripted wire-server test harness, so it's worth a human familiar with the valkey state machine confirming the routing semantics.
Extended reasoning...
Overview
The PR adds RESPValue::unwrap_attributes() (a ~6-line safe-Rust helper using core::mem::replace to peel off RESP3 |N attribute wrappers) and calls it once at the top of ValkeyClient::handle_response, so that HELLO/SELECT/push/promise-pair routing dispatches on the decorated inner frame rather than the Attribute variant. It also adds ~200 lines of tests in protocol-handling.test.ts, including a hand-rolled scripted RESP3 TCP server to exercise attribute-prefixed replies without a real Redis.
Security risks
None apparent. The helper is entirely safe Rust (no unsafe, no FFI); the attribute map's Vec<MapEntry> and the boxed placeholder Null drop normally when *self is overwritten, so there's no leak or double-free. The test server binds 127.0.0.1:0 and is torn down in finally.
Level of scrutiny
Medium-high. The code delta is tiny and obviously correct in isolation, but it sits at the entry of the Redis client's response dispatcher — the exact place where a subtle misclassification silently shifts every subsequent reply on the connection (which is precisely the bug being fixed). A maintainer who knows the RESP3 spec and this client's subscriber/HELLO/SELECT state machine should confirm that unconditionally stripping attributes before all routing is the intended semantics, and glance at the interaction with #32858 that the PR description calls out.
Other factors
The PR description is unusually thorough (mechanism, four failure modes, node-redis parity, verification against both the scripted server and a live redis-server). The new test harness is well-constructed (hermetic, port 0, cleanup in finally), but it's ~60 lines of new RESP command-frame parsing in JS that will live on as shared infrastructure — worth a second pair of eyes. No prior human reviews on the timeline.
|
Thanks, both points addressed. Is unconditional stripping the intended semantics? Yes, and bun had already decided so everywhere except the dispatcher. The RESP3 attribute type is auxiliary data that a client should not consider part of the reply it prefixes, and it may prefix any reply, pushes included. Concretely, in this codebase:
So Correction to my own description, re: node-redis I originally wrote that node-redis@5 handles these byte streams correctly. That is wrong and I have fixed the description. Its decoder has no The duplicated frame reader Fair, and it was worse than "new" infrastructure: it was a byte-for-byte copy of the reader already sitting in |
There was a problem hiding this comment.
LGTM — small, surgical unwrap of RESP3 attribute frames before dispatch, with hermetic mock-server tests covering each misrouting case.
Extended reasoning...
Overview
The native change is ~18 lines total: a new RESPValue::unwrap_attributes() helper in src/valkey/valkey_protocol.rs (a while let loop that swaps an Attribute frame out for the value it decorates via core::mem::replace), and a single call to it at the top of handle_response in src/runtime/valkey_jsc/valkey.rs. The test side extracts the existing readCommands RESP frame reader from connection-failures.test.ts into shared test-utils.ts as readRespCommands (byte-identical modulo while(true) → for(;;)), and adds six new tests in protocol-handling.test.ts driven by an in-process scripted mock server.
Security risks
None. This is protocol-parsing correctness in the Redis/Valkey client. No auth, crypto, permissions, or untrusted-input allocation paths are touched. The unwrap_attributes helper is pure safe Rust operating on already-parsed, already-bounds-checked RESPValue trees; the attribute payloads drop via normal Vec/Box Drop when *self is reassigned.
Level of scrutiny
Low-to-moderate. The change sits in the Redis client's response dispatcher, which is a hot path, but the edit is a single unwrap at the very top of handle_response before any variant-based routing. Non-decorated frames are entirely unaffected (the while let never enters). For decorated frames, protocol_jsc.rs:144-148 already discards attribute metadata on the way to JS ("we just return the value and ignore attributes"), so no user-visible information is lost — only the dispatch path is corrected. The RESP3 spec confirms attributes are out-of-band metadata that may prefix any reply including pushes, so unwrapping before dispatch is the correct interpretation.
Other factors
- No CODEOWNERS entries cover the touched files.
- No prior human review comments; timeline contains only bot noise.
- Bug hunter found no issues.
- Tests are hermetic (in-process
net.createServeron port 0,autoReconnect: false, cleanup infinally, no Docker dependency for the new suite) and cover five distinct misrouting faces (push-as-reply desync, subscribe confirmation, HELLO handshake, error-reply resolve-vs-reject, EXISTS boolean coercion) plus one guard test for nested attributes inside aggregates. - The
readRespCommandsextraction is a pure de-duplication refactor of test code that already existed inconnection-failures.test.ts.
A RESP3 attribute (`|N`) is out-of-band metadata that may prefix any reply or push. handle_response classified frames by their outer variant, so an attribute-decorated push was paired with the next command's promise: that command resolved with the push object and every later reply on the connection was shifted by one, permanently. The same misclassification broke the HELLO handshake, rejection of error replies, and the EXISTS boolean coercion whenever the server decorated those replies. Strip the decoration at the top of handle_response so every dispatch below it sees the reply that actually arrived.
Two scripted-server tests carried byte-identical copies of the `*N` command frame reader. Lift it into test-utils as readRespCommands.
8be2b2d to
6f37c77
Compare
There was a problem hiding this comment.
LGTM — minimal, well-tested dispatcher fix; CI failures (cookie-map, terminal) are unrelated to valkey.
Extended reasoning...
Overview
The PR fixes RESP3 attribute-frame routing in the Valkey/Redis client. Native changes are tiny: a new RESPValue::unwrap_attributes() helper in src/valkey/valkey_protocol.rs (~6 substantive lines, safe Rust using core::mem::replace) and a single call to it at the top of handle_response in src/runtime/valkey_jsc/valkey.rs. Test changes add six hermetic tests driving a scripted in-process RESP3 server, and lift a duplicated command-frame reader out of connection-failures.test.ts into test-utils.ts (net code reduction).
Security risks
None. No auth, crypto, path handling, or untrusted-size arithmetic is touched. The helper is pure safe Rust; for every non-Attribute value the while let doesn't match and the call is a no-op, so existing reply routing is unchanged. For Attribute values, the prior behavior was demonstrably wrong (misrouted as the wrapper variant), and the new behavior matches what protocol_jsc.rs already does when converting to JS ("we just return the value and ignore attributes").
Level of scrutiny
Medium — this sits on the core response-dispatch path of a database client, but the change is a targeted no-op-for-the-common-case unwrap that only alters routing for a variant that was previously mishandled. The RESP3 spec defines attributes as auxiliary metadata not part of the reply, and Bun's own JS converter already implements that semantics; this just brings the dispatcher into agreement. The while loop correctly handles nested attribute decorations, and Drop handles cleanup of the discarded wrapper.
Other factors
- Six new tests cover every face of the bug described (out-of-band push, subscribe confirmation, HELLO, error reply, EXISTS boolean coercion, nested-in-aggregate), all against a local mock server so they run without Docker.
- No CODEOWNERS for valkey paths.
- CI failures are
cookie-map.test.tsandterminal.test.ts, both unrelated to this diff (cookie-map churn is tracked by #33425 on main). - The author already addressed earlier feedback by deduplicating the mock-server frame reader into
test-utils.ts. - No prior claude[bot] reviews on this PR.
darwin-aarch64 test-bun lanes failed on 'buildkite-agent artifact download timed out after 120s for step darwin-aarch64-build-bun'. No test executed.
darwin-aarch64 test-bun lanes failed on 'buildkite-agent artifact download timed out after 120s for step darwin-aarch64-build-bun'. No test executed.
There was a problem hiding this comment.
I didn't find any issues — the fix is small and well-tested — but since it changes the single dispatch point every RESP3 reply flows through and interacts with #32858's push routing, it's worth a human confirming the semantics and landing order.
Extended reasoning...
Overview
The PR fixes RESP3 attribute handling in Bun's Valkey/Redis client. A RESP3 attribute (|N) is out-of-band metadata that may prefix any reply; handle_response was dispatching on the outer Attribute variant instead of the frame it decorates, causing misrouting (attribute-decorated pushes consumed as command replies, decorated errors resolved instead of rejected, decorated HELLO failing the handshake, etc.).
The native change is minimal: a new RESPValue::unwrap_attributes() helper (6 lines, a while let + mem::replace) in src/valkey/valkey_protocol.rs, and one call to it at the top of handle_response in src/runtime/valkey_jsc/valkey.rs. Test changes add six hermetic tests driving a scripted in-process RESP3 server, and lift a duplicated readRespCommands frame reader from connection-failures.test.ts into test-utils.ts.
Security risks
None identified. The change strips a wrapper before variant dispatch; no new parsing of untrusted bytes, no allocation based on peer-controlled sizes, no auth/crypto surface. Memory is handled by Rust ownership — the discarded attribute map's Vec<MapEntry> drops automatically when *self = decorated overwrites the enum.
Level of scrutiny
Medium-high. handle_response is the single dispatch point every RESP3 frame from the server flows through — HELLO handshake, SELECT, push routing, promise pairing, error rejection, and RETURN_AS_BOOL coercion all branch on the variant this call now rewrites. The change is small and the reasoning (attributes are transparent per the RESP3 spec, and protocol_jsc.rs already discards them on the JS-conversion side) is sound, but a mistake here is silent cross-request data corruption in a user-facing runtime API.
Other factors
- The PR explicitly notes an interaction with #32858, which also touches top-level push routing and "still misses
Attribute(Push)". A human should confirm the intended landing order / whether either needs rebasing. - The author's follow-up comment addressed prior review feedback (dedup of the frame reader, semantics clarification, correcting the node-redis comparison), so there has already been active review discussion on this PR.
- Tests are hermetic (mock
net.Server, no Docker), useport: 0, clean up infinally, and cover the full variant matrix (push, subscribe, HELLO, error, integer→bool, nested-in-aggregate). ThereadRespCommandsextraction is a byte-for-byte move. - No CODEOWNERS coverage for these paths.
|
On the #32858 interaction, I checked it rather than leave it open. Short version: the two are orthogonal, this one is still required after #32858 lands, and they compose in either order. #32858 does not handle attributes. Its rewritten self.handle_hello_response(value)?; // Attribute(Map) -> falls to the `_` arm
return match value { RESPValue::SimpleString(..) => .. } // SELECT
if let RESPValue::Push(push) = value { .. } // still will not match Attribute(Push)That last line is the one its description calls "the type byte alone marks them unsolicited" -- true for a bare They compose cleanly. I cherry-picked both of my commits onto
So there is no landing order to coordinate. Whichever merges second needs no more than a trivial rebase, and #32858 does not subsume this fix. I did not build or run #32858's test suite on the combined tree, only compile-checked it. Separately, on the red CI: the two |
CI status: the diff is green, two agents are sick280 of 282 jobs pass. Both remaining failures are agent-level infrastructure, reproduced identically across two consecutive builds (69052, 69113), and neither executed a single test from this diff. I have used my one re-roll and will not push again; flagging the details so a maintainer can merge or go look at the agents. 1. Same agent host 2. That agent's containerd image cache is corrupted, with the byte-identical parent snapshot in both builds: So To be explicit, since the timing-out file is the one this PR touches: the new tests cannot be the cause. They need no Docker, and the arithmetic above blows the budget without them. Local verification on the rebased tree ( (The earlier |
What
A RESP3 attribute (
|N ...) is out-of-band metadata that may legally prefix any reply or push; Redis and Valkey emit them for things like key-popularity hints.handle_responseclassified every frame by its outerRESPValuevariant, so an attribute-decorated frame took the path of its decoration rather than the path of the reply it decorates.The worst face is silent, permanent, cross-request data corruption: an attribute-decorated out-of-band push is consumed as the next command's reply, so
get("a")resolves with the push object and every later reply on that connection is shifted by one, forever.Three more faces of the same misclassification, all against a conforming server:
subscribeconfirmationsubscribe()resolves with the raw push object, client never enters subscriber mode, listener never firesHELLOreplyhandle_hello_responsefalls to its_arm and fails the connection-ERR ...replyget()resolves with anErrorobject instead of rejecting:1reply toEXISTSexists()returns1, nottrue(theRETURN_AS_BOOLcoercion matches onInteger)trueNo other client is a clean reference here:
node-redis@5's decoder has no|case at all andthrows
Unknown RESP type 124 "|"on these streams. Bun already parses attributes correctly; itis only the dispatcher that mishandles them.
Cause
The RESP3 spec defines an attribute as auxiliary data the client should not consider part of the
reply it prefixes, and bun's own
protocol_jsc.rsalready implements that when converting to JS("we just return the value and ignore attributes"). The dispatcher is the one place that never got
the memo.
handle_responseand everything it delegates to (handle_hello_response, the SELECT branch,should_consume_promise_pair, the subscriber branch,RETURN_AS_BOOL, the error check) match on the outer variant, which for a decorated frame is alwaysAttribute.Fix
Unwrap the decoration at the top of
handle_response, before any dispatch, so the inner frame type decides the path:RESPValue::unwrap_attributesreplaces an attribute frame with the reply it decorates. Attributes nested inside aggregates are unaffected:resp_value_to_js_with_optionsalready unwraps those recursively on the way to JS.This is orthogonal to #32858, which routes
RESPValue::Pushout-of-band by type byte at the top level and so still missesAttribute(Push). Either can land first; the unwrap sits at the entry ofhandle_response, above all routing.Verification
Six tests in
test/js/valkey/reliability/protocol-handling.test.ts, driving a scripted RESP3 wire server (no real Redis needed). Five fail onmain, one (attributes are transparent around the value they decorate) passes both ways as a guard.Also smoke-tested end to end against a live
redis-server: HELLO,SELECT, commands,EXISTSboolean coercion, error rejection, andsubscribe/message/unsubscribeall behave as before.