Skip to content

fix core: close idle HTTP/2 connections on keepalive_timeout#1304

Open
SSE4 wants to merge 1 commit into
userver-framework:developfrom
SSE4:h2-idle-timeout-fix
Open

fix core: close idle HTTP/2 connections on keepalive_timeout#1304
SSE4 wants to merge 1 commit into
userver-framework:developfrom
SSE4:h2-idle-timeout-fix

Conversation

@SSE4

@SSE4 SSE4 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

An idle or slowly-dripping HTTP/2 connection is never closed by the server, so
a client can hold connections open at near-zero cost and exhaust the server's
max_connections slots (default 32768), after which new connections are
refused for every client — a classic Slowloris. HTTP/1.1 closes such a
connection after keepalive_timeout; HTTP/2 used to as well, but the
enforcement was dropped in the HttpReader refactor (f753790) that replaced
the deadline-carrying WaitOnSocket with a deadline-less WaitAnyContext
loop.

Root cause: Http2Connection::ListenForRequests() blocked in
wait_any.Wait() with no deadline. keepalive_timeout is still applied inside
SocketBufferedReader::TryRead, but that runs only after Wait() reports
the socket readable — a connection that never becomes readable never enters
TryRead, so the deadline never applies.

This restores the timeout on the current concurrent connection loop:

  • The wait now uses WaitUntil(Deadline::FromDuration(config_.keepalive_timeout))
    instead of Wait(). On expiry the connection is closed only when it is idle
    (IsIdle() — no in-flight handler task); otherwise the loop re-arms and
    keeps waiting. This makes the deadline a purely inbound idle timeout: a
    connection actively serving a response is never torn down for lack of
    incoming bytes. The close reuses HTTP/1.1's "Closing idle connection on timeout" log line.
  • Cancellation and empty-context expiry keep the existing early return; only
    the new kTimeout branch is added.

Reusing keepalive_timeout keeps parity with HTTP/1.1. Like HTTP/1.1, the
timeout is a per-idle-period deadline, not a whole-connection cap — a slow
drip of bytes resets it — so a separate longer whole-connection cap could be
added later if desired; it is out of scope here.

Verified end-to-end against ng200ok with keepalive_timeout: 5 over h2c: an
idle connection (preface only, no request) is closed at exactly 5.0s and logs
the idle-close line. The in-flight-request path (a GET /sql/?delay=8 handler
slower than the keepalive survives the deadline and returns 200 at 8.3s) was
verified on the body-streaming-restore build, since ng200ok uses
response-body-stream: true, which only works there; the IsIdle() gate that
protects it is identical.

closes: #1303

Comment thread core/src/server/net/http2_connection.cpp Outdated
Comment thread core/src/server/net/http2_connection.cpp Outdated
Comment thread core/src/server/net/http2_connection.cpp
Comment thread core/src/server/net/http2_connection.cpp Outdated
@SSE4
SSE4 force-pushed the h2-idle-timeout-fix branch from e0f11c7 to 57410cd Compare July 23, 2026 09:53
if (!ready_id) {
UASSERT(ready_id == utils::unexpected(engine::WaitAnyError::kCancelled));
// Keep waiting while a request is still in flight; otherwise (idle
// keepalive timeout or cancellation) close the connection.

@ArkadyRudenko ArkadyRudenko Jul 23, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A good comment should explains WHY we do that (not WHAT we do). Lets remove that

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

removed

An idle or slowly-dripping HTTP/2 connection was never closed by the
server: ListenForRequests() blocked in wait_any.Wait() with no deadline.
keepalive_timeout is still applied inside SocketBufferedReader::TryRead,
but that runs only after Wait() reports the socket readable, so a
connection that never becomes readable never enters TryRead and the
deadline never applies. A client can therefore hold connections open at
near-zero cost and exhaust the server's max_connections slots, after
which new connections are refused for every client -- a Slowloris.
HTTP/1.1 closes such a connection after keepalive_timeout; HTTP/2 used
to as well, until the HttpReader refactor replaced the deadline-carrying
WaitOnSocket with a deadline-less WaitAnyContext loop.

Wait with the keepalive deadline instead: wait_any.WaitUntil(
Deadline::FromDuration(keepalive_timeout)) replaces wait_any.Wait(). A
timeout while a handler task is still in flight keeps waiting (the
connection is serving a response and must not be torn down for lack of
incoming bytes); an idle timeout or a cancellation closes the
connection.

Reusing keepalive_timeout keeps parity with HTTP/1.1. Like HTTP/1.1, the
timeout is a per-idle-period deadline rather than a whole-connection
cap, so a slow drip of bytes resets it; a separate longer cap can be
added later if wanted.

Covered by a new IdleKeepAliveTimeout test in connection_test.cpp
(TYPED_UTEST over both connection types): after a keep-alive request the
connection must terminate on the keepalive timeout on its own, which
previously failed for HTTP/2.
@SSE4
SSE4 force-pushed the h2-idle-timeout-fix branch from 57410cd to eef9563 Compare July 24, 2026 03:09
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.

HTTP/2 connections have no idle timeout (Slowloris / resource exhaustion)

2 participants