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