Skip to content

feat(h2): reap a connection that never opens a stream separately - #37

Merged
pigri merged 2 commits into
mainfrom
feat/h2-first-stream-timeout
Oct 2, 2026
Merged

pigri merged 2 commits into
mainfrom
feat/h2-first-stream-timeout

Conversation

@pigri

@pigri pigri commented Oct 1, 2026 •

Copy link
Copy Markdown

h2_idle_timeout is the only thing that closes a downstream HTTP/2
connection 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. ActiveSessions reports a
count 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:

pub struct IdleReaping {
    /// Applies once the connection has carried at least one stream.
    pub idle: Option<Duration>,
    /// Applies until the first stream is accepted. `None` falls back to `idle`.
    pub first_stream: Option<Duration>,
}

surfaced as h2_first_stream_timeout alongside h2_idle_timeout on
HttpServerOptions.

  • No behaviour change unless the new option is set. first_stream
    unset falls back to idle, which is exactly what the accept loop did
    before, and IdleReaping::uniform expresses that at call sites.
  • A codec-rejected stream neither promotes the connection nor buys it
    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 its
    futures each iteration and a rejected stream is a continue, so a
    duration 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.
  • The durations are passed as a struct rather than as two adjacent
    Option<Duration> parameters, which would be silently swappable at every
    call 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-Length streams, which h2 passes through to
    be reset at the stream level. Worth noting that the obvious route, a
    control byte in :path, is caught inside h2 and never reaches the accept
    loop, so a test built on it passes while exercising nothing.

Verified by mutation rather than by reading: making first_stream fall
through 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 warnings and the pingora-core lib
suite are clean (626 pass). connectors::l4::tests::test_conn_error_addr_not_avail
fails identically on unmodified main in this environment and is unrelated.

pigri added 2 commits October 1, 2026 16:32
`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.
@pigri
pigri merged commit 0949c64 into main Oct 2, 2026
4 checks passed
@pigri
pigri deleted the feat/h2-first-stream-timeout branch October 2, 2026 06:42
@linear-code

linear-code Bot commented Oct 2, 2026

Copy link
Copy Markdown

SYN-348

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