Skip to content

httpclient: HandshakeTimeoutMs ends a TLS handshake whose peer accepted and went silent - #366

Merged
MDA2AV merged 1 commit into
mainfrom
fix/344-handshake-deadline
Oct 14, 2026
Merged

MDA2AV merged 1 commit into
mainfrom
fix/344-handshake-deadline

Conversation

@MDA2AV

@MDA2AV MDA2AV commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Fixes #344.

A TLS server that accepted the connection and then sent nothing parked the client's connect forever, and HandshakeTimeoutMs never fired. RunHandshakeAsync checked its deadline only between flights, so a connect parked in RecvAsync for the first flight never got back to the check. Through the pool only the acquire timeout bounded it, with no reason attached, and each parked open kept _live and _opening raised, so PoolSize of them stopped that origin's pool for as long as the origin held the sockets. A direct TlsClientContext.ConnectAsync caller had no bound at all.

What changed

RunHandshakeAsync now arms one ring timeout (IRingHost.SubmitTimeout) for HandshakeTimeoutMs. When it fires, it checks that the stream is not disposed (after Dispose, SSL_free has run) and that SSL_in_init still says the handshake is running. If both hold, it calls shutdown(fd, SHUT_RDWR), the same move as the server's handshake sweep. The parked recv completes, and the loop throws TLS handshake to '<name>' did not complete within <n> ms.

  • The between-flights check goes. The timer also bounds a peer that dribbles bytes, so there is one deadline instead of two.
  • One flag, on the timeout's completion object. The completion is a private HandshakeDeadline, the shape of pg's OpenDeadline (pg: a connect and its startup end at CommandTimeoutMs instead of waiting forever #318), holding the stream and an Expired bool. The loop reads that bool after each recv to name the deadline. A MonotonicMs comparison can't do this reliably: it reads CLOCK_MONOTONIC_COARSE while the ring timeout runs on CLOCK_MONOTONIC, so at the moment the timeout fires the coarse clock can still be up to a tick short of the deadline. Checking the flag after every recv, not just a failed one, also means a flight that arrives after the shutdown never completes a handshake on a socket that is already shut down.
  • One edge it does not rename. The timeout can land in the same reactor turn as a server flight, after the client's reply to it is queued but before that send is issued. The send then fails with its own errno instead of the deadline message. The connect still fails at the deadline.
  • No new per-request work. It is one timeout SQE per TLS connect, and nothing is added per request. The timeout is not cancelled after a handshake succeeds; it fires later and does nothing.
  • Core: RingSocket gains the approved one-line public IRingHost Host => _host;. The stream reaches the ring from the socket it is given, so direct ConnectAsync callers get the deadline too, not just the pool.

Proof

The pending test tls client: HandshakeTimeoutMs bounds a handshake whose peer accepted and went silent is now a Test(). A new test, tls client: HandshakeTimeoutMs bounds a direct ConnectAsync whose peer accepted and went silent, does the same against the same tarpit through TlsClientContext.ConnectAsync with no pool. Both use HandshakeTimeoutMs = 750.

without the fix (src/ reverted) with the fix
pooled (acquire timeout 4000 ms) FAIL ... got: 599|no connection to 127.0.0.1:33375 within 4000 ms PASS: 599|no connection to 127.0.0.1:<port> within 4000 ms: TLS handshake to 'localhost' did not complete within 750 ms (the pool's four opens each failed at the deadline)
direct ConnectAsync FAIL ... Unable to read data from the transport connection: Connection timed out. (the handshake never completed; the test's 20 s read gave up) PASS: TLS handshake to 'localhost' did not complete within 750 ms, measured at 750 ms
Http suite 83 passed, 2 failed, 3 pending 85 passed, 0 failed, 3 pending

The 3 pending are the tests for #342, #343 and #340, as on main (main: 83 passed, 4 pending). The timings in the right-hand column come from a run with temporary prints, which were removed before the runs counted above.

The file's remarks listed both of its Pending defects, this one and #340's ALPN cast. The paragraph is removed rather than edited down to one bullet, because each Pending test states its own reason. #340's PR removes the same lines, so the two merge cleanly in either order.

With its siblings

This batch is #356 (#342), #358 (#343), #366 (#344) and #370 (#340). They merge with each other without conflicts, checked pairwise and all four together. #358 and #366 both edit RunHandshakeAsync, in separate hunks. With all four merged, the Http suite is 88 passed, 0 failed, 0 pending.

…ed and went silent

The handshake deadline was checked only between flights, so a TLS connect parked in RecvAsync for
the server's first flight waited forever when the server accepted and never answered. Through the
pool only the acquire timeout bounded it, and each such open kept _live and _opening raised, so
PoolSize of them stopped the origin's pool for as long as the origin held the sockets. A direct
TlsClientContext.ConnectAsync caller had no bound at all.

The handshake now arms one ring timeout of HandshakeTimeoutMs on the socket's own ring. If it fires
while the stream is not disposed and SSL_in_init still says the handshake is running, it shuts
the socket down (SHUT_RDWR), the move the server's handshake sweep makes. The parked recv then
completes, and the loop reports "TLS handshake to '<name>' did not complete within <n> ms". The
between-flights check goes: the timer bounds a dribbling peer too. RingSocket gains a public Host
accessor, so the stream reaches the ring from the socket it is given, and direct ConnectAsync
callers get the deadline as well as the pool.

The pending test "tls client: HandshakeTimeoutMs bounds a handshake whose peer accepted and went
silent" is now a Test, and "tls client: HandshakeTimeoutMs bounds a direct ConnectAsync whose peer
accepted and went silent" pins the path with no pool behind it.
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.

tls client: HandshakeTimeoutMs never fires when the peer accepts and goes silent

1 participant