Skip to content

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

Open
mgravell wants to merge 2 commits into
mainfrom
marc/transport-read-before-handshake
Open

Transport seam: start reading before the handshake, and report a tunnel's refusal#3188
mgravell wants to merge 2 commits into
mainfrom
marc/transport-read-before-handshake

Conversation

@mgravell

Copy link
Copy Markdown
Collaborator

Two small fixes to the experimental transport seam (SER009), both found by putting a real
DuplexTransport implementation through shapes nothing had exercised yet. Independent changes, one
commit each.

1. Start reading before the handshake is written

A transport is already connected when ConnectedAsync adopts it, and OnConnectedAsync sends the
handshake. The receiver was attached afterwards — BeginConnectAsync reaches StartReading only once
ConnectedAsync has returned — so the reply could be on the wire before Start.

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 entirely healthy and only
pub/sub was broken. The lost 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. That also makes
DuplexTransport.Start's contract true as written — "exactly one receiver, set once, before any data
is 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

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.
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=true with no TLS provider configured; it presented as a hang.

Recording it via RecordConnectionFailed puts the reason in the connect log and the ConnectionFailed
event, 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 pair
rather than a one-sided check:

change with it without it
read-before-write pub/sub through the tunnel passes with the implementation's own staging workaround removed SUBSCRIBE times out, every time
refusal reporting the tunnel's own sentence is in the connect log the log carries only the timeout

RESPite.Tests passes (1717) and the library builds across all TFMs.

One API note, no code

TlsOptions exposes CheckCertificateRevocation as a resolved bool, and it defaults to true. A
transport 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. SslProtocols already has the shape that solves this — nullable, so unset
is visible. A matching signal for revocation (nullable, or a …Specified companion) would let a
transport refuse only what was actually chosen. Happy to add it if that seems right.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant