Evict idle pooled HTTP/2 connections; cover them in close_idle_connections! (#1331) - #1332
Merged
Conversation
…tions! (#1331) Pooled HTTP/2 connections (client.h2_conns) had no idle eviction and no checkout validation, unlike the HTTP/1 Transport pool (90s idle_timeout_ns). NATs and load balancers silently drop idle flows (commonly after a few minutes); a request written onto such a connection stalls until the kernel retransmission limit (~15 min on Linux, errno 110) because no RST/FIN ever arrives, surfacing as "HTTP/2 read loop failed ... SystemError(read, 110)". - H2Connection gains idle_since_ns (state_lock-guarded), stamped at creation and on each transition into the fully-idle state (last stream unregistered, last pending reservation released). A connection that keeps serving traffic is never considered idle. - _acquire_h2_conn! evicts connections idle past the transport's idle_timeout_ns (one knob for both pools; 0 disables, matching h1) before considering them for reuse. Culled connections are closed after h2_lock is released -- close(H2Connection) waits for the read loop to exit, and that must not run under the pool lock (this also moves the pre-existing non-reusable-connection close out from under the lock). - close_idle_connections!(client) and the no-argument form now also close pooled HTTP/2 connections with no in-flight streams; previously they only touched the HTTP/1 transport pool, so the documented remedy never reached the pool that had the problem. Connections with active streams or pending reservations are kept. Tests: end-to-end eviction (mute-server stand-in for a silently dead path: second request completes on a fresh connection instead of hanging), fresh connections are reused and idle_timeout_ns=0 disables eviction, and close_idle_connections! closes idle h2 conns while keeping active ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1332 +/- ##
==========================================
+ Coverage 88.07% 88.23% +0.16%
==========================================
Files 30 30
Lines 11871 11921 +50
==========================================
+ Hits 10455 10519 +64
+ Misses 1416 1402 -14 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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.
Fixes #1331.
Root cause
Pooled HTTP/2 connections (
client.h2_conns) had no idle eviction and no checkout validation — unlike the HTTP/1Transportpool, which evicts anything idle pastidle_timeout_ns(90s default). The checkout scan consulted only reactive flags (closed,conn_error, GOAWAY state); a NAT/load-balancer that silently drops an idle flow produces no event, so a 12-minutes-idle connection still looked perfectly available. The next request's HEADERS/DATA were written into the dead path, the kernel retransmitted untiltcp_retries2(~15.9 min on Linux), then killed the socket withETIMEDOUT— delivered to the parked reader, henceHTTP/2 read loop failed … SystemError(read, 110). Every timing in the report matches. (1.x is immune because it has no HTTP/2; 2.x's h1 pool was equally immune — the gap was h2-only.)A second bug compounded it:
close_idle_connections!never touched the h2 pool — both theClientand no-arg forms delegated to the transport only, so the documented remedy (and the reporter's workaround) didn't reach the pool with the problem; the green workaround run passed for other reasons (a GOAWAY/RST arriving in time self-invalidates the connection — the pathology needs the drop to be silent).Both reproduced deterministically on master with a frame server that answers once and then goes mute (stands in for the dead path): the pooled conn is handed out again, request 2 blocks indefinitely, and
close_idle_connections!leaves the conn pooled.Fix (design)
Idleness is a property of the connection's stream lifecycle, tracked where that lifecycle already changes, and evicted lazily at checkout (the h1 pool's established pattern — no background reaper, no new locks, no new API):
H2Connection.idle_since_ns(guarded by the existingstate_lock), stamped at creation and on each transition into the fully-idle state — last stream unregistered, last pending reservation released. A connection continuously serving traffic is never considered idle._acquire_h2_conn!evicts connections idle past the transport's existingidle_timeout_ns— one knob, one semantic, same 90s default (comfortably inside any NAT window),0disables, exactly like h1.h2_lockis released:close(H2Connection)waits for its read loop to exit, and that must not run under the pool lock. This also moves the pre-existing inline close of non-reusable connections out from under the lock — a latent acquire-path latency hazard.close_idle_connections!(client)(and the no-arg form) now also close pooled h2 connections with no in-flight streams and no pending reservations, keeping active ones — the documented remedy now works.Deliberately not included: per-checkout h2 PING health checks (adds an RTT to every reuse and needs its own timeout machinery; Go ships this as an opt-in
ReadIdleTimeout, off by default, for good reason). Eviction removes the hang class; keepalive PINGs can be a separate opt-in follow-up if wanted.Behavior after the fix
The original repro now completes the post-idle request in ~20ms on a fresh connection instead of hanging ~16 minutes:
Tests
Three new testsets: end-to-end eviction against a mute-after-first-response server (second request must complete fast on a fresh connection, evicted conn closed); fresh-connection reuse +
idle_timeout_ns=0disables eviction (guards against over-eviction);close_idle_connections!closes idle h2 conns while keeping ones with pending reservations. Full h2 suite 181/181; transport 106, refused-stream retry 22, client 586 all green locally.🤖 Generated with Claude Code