Skip to content

feat(server): add with_max_connection_idle (idle connection reaping)#190

Draft
iainmcgin wants to merge 2 commits into
mainfrom
feat/server-max-connection-idle
Draft

feat(server): add with_max_connection_idle (idle connection reaping)#190
iainmcgin wants to merge 2 commits into
mainfrom
feat/server-max-connection-idle

Conversation

@iainmcgin

Copy link
Copy Markdown
Collaborator

Problem

BoundServer/Server can cap connection lifetime by age (#165) but cannot reclaim connections that have simply gone quiet. Idle connections (clients behind NAT, bursty workloads, pooled clients holding connections they no longer use) sit open indefinitely. This is the standard companion to max-connection-age found in grpc-go (MaxConnectionIdle), grpc-java (maxConnectionIdle), and connect-go (http2.Server.IdleTimeout). There is no axum::serve escape hatch for it. Closes #177.

Change

Adds with_max_connection_idle(Duration) to both Server and BoundServer. When set, a connection that has had zero in-flight requests for the configured duration is retired the same way max-age retires one: HTTP/2 connections get a GOAWAY, HTTP/1.1 connections have keep-alive disabled, then the connection drains over the existing with_max_connection_age_grace period before force-closing.

  • Disabled by default. A zero duration panics (rejected, like the age knob).
  • Idle = no in-flight requests. Requests are counted at the hyper service_fn dispatch boundary via an RAII guard, so the count survives handler panics and cancellation. An epoch counter (bumped on request start and completion) lets the lifecycle detect activity that began and ended inside an idle window, so the timer resets on any activity.
  • Lazy evaluation. The idle window is re-checked when the timer expires rather than re-armed on each request, so a connection is retired between one and two times the configured duration after its last activity. No jitter is applied (idle reaping is reactive).
  • Reuses the feat(server): add max connection age to BoundServer #165 machinery. Idle and age share one per-connection lifecycle state machine and one grace period; when both are configured, whichever fires first wins. Whole-server graceful shutdown is unchanged and is never capped by the grace period. The internal AgeDraining lifecycle state was renamed Draining since it now serves both triggers.

In-flight accounting is allocated only when idle reaping is enabled, so there is no per-request overhead otherwise.

Overlap with #135

#135 (read/idle timeouts) also touches idle handling in connectrpc/src/server.rs. This PR is scoped narrowly to the dedicated with_max_connection_idle reaping knob from #177; the two will need reconciliation in whichever lands second.

Validation

  • cargo test --workspace --all-features — 654 passed.
  • cargo test -p connectrpc --features server max_connection_idle — 6 focused tests, run repeatedly for stability.
  • cargo clippy --workspace --all-features --all-targets -- -D warnings — clean.
  • cargo +nightly-2026-02-27 fmt --check, cargo doc with -Dwarnings, and minimal-features check all pass.

Tests cover: a quiet connection reaped (GOAWAY asserted) after the idle window plus reset-on-activity, an in-flight request keeping the connection alive across multiple idle windows then reaped after completion, idle firing before a longer max age, the activity counter/guard accounting, builder defaults/threading, and the zero-duration panic.

Reviewer note

A deterministic start_paused integration test for "many short requests keep the connection alive" is not feasible: the paused clock auto-advances through idle windows whenever the test parks between requests, and GracefulConnection is sealed so the lifecycle cannot be driven with a mock. The in-flight-request test covers "activity prevents reaping" without timing fragility, and reaps_quiet_connection covers the reset-on-activity branch.

Add `with_max_connection_idle(Duration)` to `Server` and `BoundServer`.
When set, a connection that has had no in-flight requests for the
configured duration is retired: the server sends an HTTP/2 GOAWAY (or
disables HTTP/1.1 keep-alive) and then drains over the existing
max-connection-age grace period before force-closing.

This complements `with_max_connection_age` (#165): age caps a
connection's total lifetime regardless of use, while idle reclaims
connections that have gone quiet (clients behind NAT, bursty workloads,
pooled clients holding connections they no longer need). Both knobs reuse
the per-connection lifecycle state machine; when both are configured,
whichever fires first retires the connection. Idle reaping is disabled by
default.

Implementation notes:

- In-flight requests are counted at the hyper service_fn dispatch
  boundary via an RAII guard, so the count stays correct across handler
  panics and cancellation. An epoch counter (bumped on request start and
  completion) lets the lifecycle detect activity that began and ended
  within an idle window, so the timer resets on any activity.
- The idle window is evaluated lazily (re-checked at timer expiry), so a
  connection is retired between one and two times the configured duration
  after its last activity. No jitter is applied.
- Whole-server graceful shutdown is unchanged and is never capped by the
  idle grace period.

In-flight accounting is only allocated when idle reaping is enabled, so
there is no per-request overhead otherwise.
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

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.

Add with_max_connection_idle (idle connection reaping)

1 participant