Transport seam: start reading before the handshake, and report a tunnel's refusal - #3188
Open
mgravell wants to merge 2 commits into
Open
Transport seam: start reading before the handshake, and report a tunnel's refusal#3188mgravell wants to merge 2 commits into
mgravell wants to merge 2 commits into
Conversation
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
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
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 small fixes to the experimental transport seam (SER009), both found by putting a real
DuplexTransportimplementation through shapes nothing had exercised yet. Independent changes, onecommit each.
1. Start reading before the handshake is written
A transport is already connected when
ConnectedAsyncadopts it, andOnConnectedAsyncsends thehandshake. The receiver was attached afterwards —
BeginConnectAsyncreachesStartReadingonly onceConnectedAsynchas returned — so the reply could be on the wire beforeStart.Not a theoretical window: against a transport-backed multiplexer, every
SUBSCRIBEfailed withno connection became available. The subscription connection lost that race every time while theinteractive connection happened to win it, so ordinary command traffic looked entirely healthy and only
pub/sub was broken. The lost bytes were the handshake reply.
StartTransportReadingmoves up to immediately afterInitTransportOutputand becomes idempotent,since
StartReadingstill calls it on the path every connection takes. That also makesDuplexTransport.Start's contract true as written — "exactly one receiver, set once, before any datais expected" — instead of a promise each implementer discovers is conditional and works around by
staging bytes.
2. A tunnel that refuses a transport should say why
ConnectTransportAsyncis called before thetryinBeginConnectAsyncis entered, and that methodruns as
BeginConnectAsync(log).RedisFireAndForget(), so an exception out of a tunnel went nowhere.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 — which is the whole value of throwing there, since a transport
refuses when the configuration asks for something it cannot do and the message names it. The case that
surfaced this was a tunnel handed
Ssl=truewith no TLS provider configured; it presented as a hang.Recording it via
RecordConnectionFailedputs the reason in the connect log and theConnectionFailedevent, where socket-path failures already appear. It does not make the refusal faster — the bridge still
retries to its budget — it makes it explicable.
Verification
Nothing in this repo's tests touches the transport API, so both changes were verified cross-repo against
a real implementation (SocketSet's tunnel) and a real
redis-server, each as a discriminating pairrather than a one-sided check:
SUBSCRIBEtimes out, every timeRESPite.Testspasses (1717) and the library builds across all TFMs.One API note, no code
TlsOptionsexposesCheckCertificateRevocationas a resolvedbool, and it defaults to true. Atransport that cannot apply revocation per connection therefore cannot tell "this deployment asked for
it" from "nobody touched it": refusing the value refuses everybody, and ignoring it silently does less
than the configuration says.
SslProtocolsalready has the shape that solves this — nullable, so unsetis visible. A matching signal for revocation (nullable, or a
…Specifiedcompanion) would let atransport refuse only what was actually chosen. Happy to add it if that seems right.