Fix two RESP3 connect failure modes: credential desync (#3172) and subscription poisoning on downgrade (#3154) - #3176
Merged
Merged
Conversation
Fixes #3172, and the RESP3 credential-failure hang more generally. Redis has a protocol bug (reproduced on 7.4.10, 8.0.6 and an 8.9 preview; valkey 8.1.9 is unaffected): inside a pipelined batch, a *failing* AUTH only gets a reply if it is the first command in that batch. Otherwise the error is silently dropped, so every subsequent reply on that connection lands on the wrong message: AUTH bad, PING -> -WRONGPASS, +PONG (correct) PING, AUTH bad, PING -> +PONG, +PONG (error dropped) HELLO 3, AUTH bad, PING -> <hello map>, +PONG (error dropped) AUTH bad, AUTH bad, PING -> -WRONGPASS, +PONG (second dropped) The handshake wrote `HELLO <ver> AUTH user pass` *and* a standalone `AUTH user pass` ("we auth EVEN IF we have used HELLO to AUTH"), so with credentials the server rejects, the second AUTH's error vanished and the whole handshake shifted by one reply: the tie-breaker GET consumed the tracer's reply, the tracer never completed, and the connection never became usable. Instead of a clean AuthException, callers saw "no connection became available" timeouts, and topology detection read the wrong replies - which is #3172: a sentinel reporting "The ConnectionMultiplexer is not a Sentinel connection. Detected as: Standalone" under v3's RESP3 default, cured by forcing protocol=resp2. v2 never hit this because it assumed server version 3.0 and so never issued HELLO. #3140 (separate sentinel credentials) makes "credentials the sentinel rejects" an easy case to land on. So: AUTH goes first in the batch (where its errors are reported correctly), and HELLO follows it, bare. HELLO only carries credentials when AUTH is unavailable in the command map - which the ConnectionMultiplexer constructor already only permits when RESP3 is being attempted. Verified against the live sentinel topology: before, RESP3 + rejected credentials gave "Detected as: Standalone" and no primary connection; after, all four protocol/credential combinations resolve the primary. ConfigTests. MutableOptions no longer needs to skip RESP3 - that asymmetry was this bug - so it now covers both protocols as a regression test.
…e to RESP2 Fixes #3154. Under RESP3 subscriptions share the interactive connection; under RESP2 they need their own. Which bridge a subscription goes to is therefore decided by what we know-or-expect the protocol to be at the time it is queued - and that can change underneath a queued message: a connection negotiates RESP3, later reconnects, the new HELLO fails (timeout, expired token, failover to a down-level node), and the tracer settles the connection as RESP2. Anything queued in the interactive bridge's backlog while we still expected RESP3 - notably the SUBSCRIBEs that EnsureSubscriptions pushes during the disconnect - is then written to the interactive connection, which puts it into subscriber mode: from that point it rejects every ordinary command with ERR only [P|S][UN]SUBSCRIBE / PING / QUIT allowed in this context so the connection is permanently poisoned, and the multiplexer has no idea. The fix is at the write, not at the downgrade: when the interactive bridge is about to write a subscription-bridge message on a connection it now knows to be RESP2, it hands the message to the subscription bridge instead. That covers every drain path (direct write, sync and async backlog processors) without needing to catch the moment the protocol resolved, and it is robust to any other way a queue can outlive the expectation it was filled under. Only that direction is handled: we never upgrade an existing expectation from RESP2 to RESP3 (HELLO is only issued when RESP3 is expected in the first place), so the mirror case cannot arise from a protocol change. Resp3DowngradeTests reproduces the whole sequence in-process: it negotiates RESP3, makes the toy server stop understanding HELLO, holds the accept gate shut so there is a real window in which we are disconnected but still expecting RESP3, queues a subscribe into that window, then releases the gate. Before this change the SET after reconnect fails with the subscriber-mode error above. It also asserts the underlying invariant directly - under RESP2 no single connection may carry both subscription and ordinary commands - which needed a per-connection command record in the test server rather than inferring it from symptoms.
philon-msft
approved these changes
Aug 14, 2026
Collaborator
Author
|
thanks @philon-msft - did you see the linked server bug, btw? fun! |
Collaborator
Yes - I needed a good baffling to start my Friday properly! 🤣 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two independent bugs in the same area - what happens when a
HELLOdoesn't go to plan. Both are reproduced by new tests that fail onmain. Reviewable commit-by-commit.1. Never send both
HELLO AUTHand a standaloneAUTH- fixes #3172There is a redis server bug behind this one, reproduced on 7.4.10, 8.0.6 and an 8.9 preview build (valkey 8.1.9 is unaffected): inside a pipelined batch, a failing
AUTHonly gets a reply if it is the first command in that batch. Otherwise the error is silently dropped. Raw socket writes, one batch each, counting replies:AUTH bad,PING-WRONGPASS,+PONG(correct)PING,AUTH bad,PING+PONG,+PONG(error dropped)HELLO 3,AUTH bad,PING<hello map>,+PONG(error dropped)AUTH bad,AUTH bad,PING-WRONGPASS,+PONG(second dropped)It is not
HELLO-specific, and it is not "all errors" - other commands' errors are delivered mid-batch fine. Sending the same two commands as separate round trips is also fine, so it is the pipelining path.Our handshake wrote
HELLO <ver> AUTH user passand a standaloneAUTH user pass(the old "we auth EVEN IF we have used HELLO to AUTH" comment). So with credentials the server rejects, the second AUTH's error vanished and every subsequent reply on the connection landed on the wrong message. From aPARSE_DETAILtrace of the handshake: the tie-breakerGETconsumed the tracer'sECHOreply, so the tracer never completed, the connection never became usable, and callers sawno connection became availabletimeouts instead of a cleanAuthException. Topology detection read the wrong replies too - which is #3172: a sentinel reportingThe ConnectionMultiplexer is not a Sentinel connection. Detected as: Standaloneunder v3's RESP3 default, cured by forcingprotocol=resp2. v2 never hit it because it assumed server version 3.0 and so never issuedHELLO. #3140 (separate sentinel credentials) makes "credentials the sentinel rejects" an easy case to land on.Fix:
AUTHgoes first in the batch, where its errors are reported correctly, andHELLOfollows it bare.HELLOcarries credentials only whenAUTHis unavailable in the command map - which theConnectionMultiplexerconstructor already only permits when RESP3 is being attempted. Never both.Verified against the live sentinel topology, same probe either side of the fix:
RESP3+ credentials the sentinel rejects →Detected as: Standalone, no primary connection. Every other protocol/credential combination fine, which is exactly whyprotocol=resp2cured it for the reporter.Sentinel, primary resolved.ConfigTests.MutableOptionsno longer needsAssert.SkipWhen(options.TryResp3(), "only validate RESP2")- that asymmetry was this bug - so it now covers both protocols as a real-server regression test.2. Reroute subscriptions off the interactive connection on downgrade - fixes #3154
Under RESP3 subscriptions share the interactive connection; under RESP2 they need their own. Which bridge a subscription goes to is decided by what we know-or-expect the protocol to be when it is queued, and that can change underneath a queued message: negotiate RESP3, reconnect later, the new
HELLOfails (timeout, expired token, failover to a down-level node), the tracer settles the connection as RESP2. TheSUBSCRIBEs thatEnsureSubscriptionspushed into the interactive backlog during the disconnect are then written to the interactive connection, putting it into subscriber mode - from then on it rejects every ordinary command:Fix at the write, not at the downgrade: when the interactive bridge is about to write a subscription-bridge message on a connection it now knows is RESP2, it hands the message to the subscription bridge. That covers every drain path (direct write, plus both backlog processors) without having to catch the moment the protocol resolved, and it is robust to any other way a queue outlives the expectation it was filled under. Only that direction is handled: we never upgrade an existing expectation from RESP2 to RESP3, since
HELLOis only issued when RESP3 is expected in the first place, so the mirror case cannot arise from a protocol change.Resp3DowngradeTestsdrives the whole sequence in-process. Reconnects over the in-proc tunnel complete in ~200ms, so a plain "kill it and re-subscribe" races and routes correctly by luck; the test instead uses the existingOnAcceptClientAsynchook to hold the accept gate shut, giving a real window in which we are disconnected but still expecting RESP3, queues a subscribe into that window, then releases the gate. It also asserts the underlying invariant directly - under RESP2, no single connection may carry both subscription and ordinary commands - which needed a per-connection command record in the test server rather than inferring it from symptoms.Verification
Full build across all TFMs clean; test suite 5811 passed / 0 failed. Both new tests fail on
mainwith the errors quoted above.Not in scope