SIGN IN SIGN UP

Transport seam: start reading before the handshake, and report a tunnel's refusal (#3188)

* Transport mode: start reading before the handshake is written

A transport is already connected when ConnectedAsync adopts it, and OnConnectedAsync
sends the handshake. Until now the receiver was attached afterwards -- BeginConnectAsync
reaches StartReading only once ConnectedAsync has returned -- so the reply could be on
the wire before Start, and the transport had no way to know that would happen.

It is not a theoretical window. Against a transport-backed multiplexer every SUBSCRIBE
failed with "no connection became available": the subscription connection lost that race
every time, while the interactive connection happened to win it, so ordinary command
traffic looked perfectly healthy. The bytes were the handshake reply.

StartTransportReading moves up to immediately after InitTransportOutput and becomes
idempotent, since StartReading still calls it on the path every connection takes.

This also makes DuplexTransport.Start's contract true as written -- "exactly one
receiver, set once, before any data is expected" -- rather than a promise each
implementer discovers is conditional and has to work around by staging bytes.

Verified cross-repo against a real transport implementation (SocketSet's tunnel) and a
real redis-server, as a discriminating pair: with that implementation's own staging
workaround REMOVED, pub/sub through the tunnel passes with this change and fails without
it. Nothing in this repo's tests touches the transport API, so there is no unit test to
add here.

Claude-Session: https://claude.ai/code/session_01QHTFkVFxokbBCukuUnkhGe

* A tunnel that refuses a transport should say why

ConnectTransportAsync is called before the try in BeginConnectAsync is entered, and that
method runs as BeginConnectAsync(log).RedisFireAndForget() -- so an exception out of a
tunnel went nowhere at all. The caller waited out the full connect budget and got the
generic "It was not possible to connect", with the tunnel's own explanation lost.

That explanation is the whole value of throwing there: a transport refuses a dial when
the configuration asks for something it cannot do, and the message names it. The case
that showed this up was a tunnel told Ssl=true with no TLS provider configured; the
diagnosis it produced instead read as a hang.

Wrapping the acquisition and recording it via RecordConnectionFailed puts the reason in
the connect log and the ConnectionFailed event, where the socket path's failures already
appear. It does not make the refusal faster -- the bridge still retries to its budget --
it makes it explicable.

Verified as a pair against a tunnel that refuses: with this change the tunnel's own
sentence is in the connect log; without it the log carries only the timeout.

Claude-Session: https://claude.ai/code/session_01QHTFkVFxokbBCukuUnkhGe
M
Marc Gravell committed
3ecc6146346f4336e08c9916acf80456bac08385
Parent: 9201860
Committed by GitHub <noreply@github.com> on 8/20/2026, 10:20:59 AM