Repository navigation
feat(h2): reap a connection that never opens a stream separately - #37
Merged
Merged
Conversation
`h2_idle_timeout` is the only thing that closes a downstream HTTP/2 connection with no streams in flight, and it cannot tell apart the two situations that produce that state. A connection that has served requests and is being held for the next one is worth keeping: closing it costs a fresh TCP and TLS handshake. HTTP/2 clients also keep channels warm with PINGs, which open no stream, so by this measure a healthy warm channel looks idle and is reaped on the same timer. Raising the timeout to accommodate that is the obvious fix, and it silently raises how long a connection that completed the handshake and never opened a single stream can sit on a file descriptor — because `ActiveSessions` reports a count of zero for both, and nothing else in the accept loop reaps such a connection. So the two periods get their own bounds. `IdleReaping::first_stream` applies until the first stream is accepted and `IdleReaping::idle` after that, exposed as `h2_first_stream_timeout` alongside `h2_idle_timeout` on `HttpServerOptions`. Unset, `first_stream` falls back to `idle`, which is exactly the previous behaviour. A stream the codec rejects as malformed does not promote the connection: answering a protocol error is not evidence it is being used, so a client that only ever sends malformed HEADERS keeps the shorter bound. The two durations are passed as a struct rather than as adjacent `Option<Duration>` parameters, which would be silently swappable at every call site. Two tests, each failing for its own reason: one that a connection opening nothing is reaped on the short bound while the idle bound is far longer, and a control that the short bound stops applying once a stream has been carried — without which every keepalive connection would be closed between requests.
Review follow-up. The bound did not hold for the case its own documentation named. `tokio::select!` rebuilds its futures on every iteration of the accept loop, so passing a `Duration` meant the first-stream timer restarted from zero each time round. A stream the codec rejects is a `continue`, so a client sending nothing but malformed HEADERS bought itself a fresh period per rejection: at a 10s bound, roughly 310s of connection lifetime before the malformed-stream budget ends it, against the 10s the option advertises. Bounded, so not unlimited, but 31x the documented ceiling — and holding a file descriptor against a client doing nothing useful is the exact property this option exists to provide. It is now an `Instant` computed once, before the loop, and `wait_for_deadline` sleeps only the remaining time. Being rebuilt by `select!` no longer grants a fresh period. The post-first-stream bound stays a restarting period, which is what makes keepalive worth having, so the two are now separate select arms rather than one value chosen per iteration. The test sends repeated conflicting-Content-Length streams, which h2 passes through to be reset at the stream level. A control byte in `:path` — the other rejection route — turned out to be caught inside h2 and never to reach the accept loop at all, so a test built on it passed without exercising anything; worth recording, since it looks like the obvious choice. Also from review: * `IdleReaping` was `pub` while the only function taking it is `pub(crate)`, so it was unusable public API that would have needed `#[non_exhaustive]` to grow a third bound safely. Now `pub(crate)`. * The `idle` doc claimed `None` disables reaping, which is false when `first_stream` is set — that connection is still reaped before its first stream. Scoped to the phase it governs. * `h2_first_stream_timeout` now says what it costs: preconnect hints, gRPC channel warm-up and connection coalescing all complete a handshake and may legitimately open no stream for some seconds.
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.
h2_idle_timeoutis the only thing that closes a downstream HTTP/2connection with no streams in flight, and it cannot distinguish the two
situations that produce that state.
A connection that has served requests and is being held open for the next
one is worth keeping: closing it costs a fresh TCP and TLS handshake. HTTP/2
clients also keep channels warm with PINGs, which open no stream, so by this
measure a healthy warm channel looks idle and is reaped on the same timer.
Raising the timeout to accommodate that is the obvious response, and it
silently raises how long a connection that completed the handshake and never
opened a single stream can hold a file descriptor.
ActiveSessionsreports acount of zero for both cases, and nothing else in the accept loop reaps such
a connection — so tuning for the first case loosens the second by the same
factor, with no way to opt out.
Change
The two periods get their own bounds:
surfaced as
h2_first_stream_timeoutalongsideh2_idle_timeoutonHttpServerOptions.first_streamunset falls back to
idle, which is exactly what the accept loop didbefore, and
IdleReaping::uniformexpresses that at call sites.time. Answering a protocol error is not evidence the connection is
being used. The first-stream bound is therefore a deadline fixed at the
start of the connection, not a period:
tokio::select!rebuilds itsfutures each iteration and a rejected stream is a
continue, so aduration here would restart on every rejection — at a 10s bound, roughly
310s of lifetime before the malformed-stream budget intervenes. The
post-first-stream bound stays a restarting period, which is what makes
keepalive worth having.
Option<Duration>parameters, which would be silently swappable at everycall site.
Tests
Two, each failing for its own reason:
test_h2_first_stream_timeout_closes_a_connection_that_opens_nothing—the idle bound is set an order of magnitude longer than the test's own
ceiling, so passing can only mean the first-stream bound did the work.
test_h2_first_stream_timeout_stops_applying_once_a_stream_is_carried—the control. Without it, a regression that applied the short bound
unconditionally would close every keepalive connection between requests
and the first test would still pass.
test_h2_first_stream_timeout_is_not_restarted_by_a_rejected_stream—repeated conflicting-
Content-Lengthstreams, which h2 passes through tobe reset at the stream level. Worth noting that the obvious route, a
control byte in
:path, is caught inside h2 and never reaches the acceptloop, so a test built on it passes while exercising nothing.
Verified by mutation rather than by reading: making
first_streamfallthrough to
idle, treating every connection as having carried a stream,treating none as having done so, and re-arming the deadline per iteration
each fail the corresponding test and only that test.
cargo clippy --all-targets -- --deny warningsand thepingora-corelibsuite are clean (626 pass).
connectors::l4::tests::test_conn_error_addr_not_availfails identically on unmodified
mainin this environment and is unrelated.