Skip to content

Fix two RESP3 connect failure modes: credential desync (#3172) and subscription poisoning on downgrade (#3154) - #3176

Merged
mgravell merged 2 commits into
mainfrom
marc/resp3-connect-downgrade
Aug 14, 2026
Merged

Fix two RESP3 connect failure modes: credential desync (#3172) and subscription poisoning on downgrade (#3154)#3176
mgravell merged 2 commits into
mainfrom
marc/resp3-connect-downgrade

Conversation

@mgravell

@mgravell mgravell commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Two independent bugs in the same area - what happens when a HELLO doesn't go to plan. Both are reproduced by new tests that fail on main. Reviewable commit-by-commit.

1. Never send both HELLO AUTH and a standalone AUTH - fixes #3172

There 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 AUTH only 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:

batch 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 pass and a standalone AUTH 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 a PARSE_DETAIL trace of the handshake: the tie-breaker GET consumed the tracer's ECHO reply, so the tracer never completed, the connection never became usable, and callers saw no connection became available timeouts instead of a clean AuthException. Topology detection read the wrong replies too - 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 it 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.

Fix: AUTH goes first in the batch, where its errors are reported correctly, and HELLO follows it bare. HELLO carries credentials only when AUTH is unavailable in the command map - which the ConnectionMultiplexer constructor already only permits when RESP3 is being attempted. Never both.

Verified against the live sentinel topology, same probe either side of the fix:

  • before: RESP3 + credentials the sentinel rejects → Detected as: Standalone, no primary connection. Every other protocol/credential combination fine, which is exactly why protocol=resp2 cured it for the reporter.
  • after: all four combinations → Sentinel, primary resolved.

ConfigTests.MutableOptions no longer needs Assert.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 HELLO fails (timeout, expired token, failover to a down-level node), the tracer settles the connection as RESP2. The SUBSCRIBEs that EnsureSubscriptions pushed 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:

ERR only [P|S][UN]SUBSCRIBE / PING / QUIT allowed in this context (got: 'SET')

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 HELLO is only issued when RESP3 is expected in the first place, so the mirror case cannot arise from a protocol change.

Resp3DowngradeTests drives 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 existing OnAcceptClientAsync hook 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 main with the errors quoted above.

Not in scope

  • The redis server bug itself needs filing upstream; we no longer trip it, but any pipelining client can - done (redis 15640)
  • The symmetric RESP2 → RESP3 reroute is deliberately absent: I could not construct a path that reaches it.

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.
@mgravell

Copy link
Copy Markdown
Collaborator Author

thanks @philon-msft - did you see the linked server bug, btw? fun!

@mgravell
mgravell merged commit 228cd37 into main Aug 14, 2026
6 checks passed
@mgravell
mgravell deleted the marc/resp3-connect-downgrade branch August 14, 2026 15:03
@philon-msft

Copy link
Copy Markdown
Collaborator

thanks @philon-msft - did you see the linked server bug, btw? fun!

Yes - I needed a good baffling to start my Friday properly! 🤣

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

Labels

None yet

Projects

None yet

2 participants