Skip to content

RFC: server internals as acceptor + driver + loop (opt-in restructure; follows #306–#308, #304) - #305

Closed
rpb-ant wants to merge 10 commits into
mainfrom
rpb/server-layers
Closed

rpb-ant wants to merge 10 commits into
mainfrom
rpb/server-layers

Conversation

@rpb-ant

@rpb-ant rpb-ant commented Sep 15, 2026

Copy link
Copy Markdown
Collaborator

This retitles #305. It is not required for any of the API in A–D (#306 ConnectionInfo + Server::serve_connection, #307 ConnectionConfig, #308 free serve_connection + axum::serve, #304 with_connection_extensions) — those four land the whole adopter-facing surface on today's server.rs / axum.rs, each under the 250-line rule by net non-test lines (B's gross churn is ~2k because setter docs move; it is mechanical), with no file moves. What is left in this branch is internal structure, offered because it addresses #250 (items 2–3 and the Notes line) and #253 (the server.rs row) directly. If you want it, I will rebase it onto D and send it as 3–4 mostly-move PRs; if not, nothing in A–D depends on it and it can be closed.

The branch as pushed (rpb/server-layers, 10 commits on main) predates A–D and still carries the earlier, larger public surface (public Acceptor family, Server::service, accept_config accessors). Read it for shape, not for API: rebased, every one of those becomes pub(crate) and the public items are exactly A–D's.

What A–D already bank (so this RFC does not claim them)

  • One connection driver behind Server, BoundServer, axum::serve, axum::serve_tls and custom loops (C), returning ConnectionClosed (A).
  • One crate-private accept loop shared by all four entry points (C) — serve_tls can no longer silently lack a setting.
  • One ConnectionConfig value instead of nine fields × two builders × four default blocks × positional plumbing (B): a new knob is field + default + setter + accessor + apply = 5 hunks, versus the measured 11 (HTTP/2 knob) to 22 (positional knob; the fourth default block really does fail cargo check) on 0.9.0, and it cannot skip axum.rs as 4 of the last 5 knob PRs did.

What the restructure adds, measured on the branch against main @ 758bd3a

main branch
server.rs 4 330 lines (2 132 non-test) in one file server/{mod 134, config 838, peer 259, acceptor 369, connection 621, accept_loop 125, standalone 784}.rs; largest non-test file 707
accept loops / TLS handshake blocks 2 / 2 1 / 1 (accept_loop::run, 72 lines; Accepted::handshake, 28) — already 1 / 1 after C; what the branch adds is the loop and the accept+handshake step as their own small units (accept_loop.rs, acceptor.rs) instead of one 130-line function
longest function serve_with_listener 174, ConnectionLifecycle::poll 115 serve_connection 126, accept_loop::run 72
connection lifecycle hand-rolled Future: struct + IdleTracker + 3-state enum + poll with 10 poll sites, 210 lines two biased select! phases + retirement() + quiet_for, 99 lines (last commit, −177/+90, independently droppable)
#[allow(clippy::too_many_arguments)] / cfg-dependent arity 2 / yes 0 / no
tests one 2 198-line mod tests (59 tests), 0 in-memory, 41 sockets server/tests/{acceptor,builders,connection,header_read_timeout,http2,max_connection_age,max_connection_idle,max_requests,peer,shutdown}.rs by topic (#253's own axes) + unit tests beside the code; a direct driver suite of 8 tests over tokio::io::duplex (max-age / idle / request-count retirement, drain, panic isolation, extension stamping) with no listener
functional diff (driver + loop + axum as one change) net −405 non-test lines, because the second loop and handshake die; commits 1–2 are a 100 % rename and a pure test move (+23 net)

Costs, also measured: 9 pub(crate) seams between files that were one; cfg(feature = "server-tls") sites 37 → 41 spread over 5 files instead of 2; the layer table has to be kept true in module docs, crate docs and the guide; +4 CI steps (already in C).

Proposed shape if accepted (rebased onto D)

  1. movesserver.rsserver/mod.rs; test module → server/tests/*.rs by topic. Pure moves, reviewable with --color-moved; closes Test modules run several times the size CONTRIBUTING allows, with the harness copied per test #253's server.rs row on its own.
  2. acceptor + loop — crate-private Acceptor / Accepted::handshake (TCP accept, nodelay, transient-error retry with a 1 s back-off on EMFILE/ENFILE, TLS handshake + timeout + cert capture in one place) and accept_loop::run in their own files; standalone.rs becomes the two builders + run. No public items.
  3. async lifecycle — the poll state machine as an async body. Droppable.
  4. example + docsexamples/custom-accept-loop (admission and runtime placement by client-certificate identity over the public serve_connection, handshake inline as in C's guide section, with an e2e test) and the module-doc layer table.

Keeping Acceptor / Accepted / ServerIo / HandshakeError / AcceptConfig crate-private until someone asks is deliberate: promoting them later is ~24 visibility tokens and re-exports; retracting them would be a breaking release, and #250's self-taking transition may want to reshape AcceptConfig first.

Evidence from the branch as it stands: per-commit fmt / clippy (all feature shapes) / rustdoc / MSRV green; 697 lib tests; conformance server mode 3600/3600 (all protocols) and 2396/2396 (Connect + TLS); cargo semver-checks vs v0.9.0 clean three ways; echo_bench --quick within noise of main.

🤖 Generated with Claude Code

rpb-ant and others added 10 commits September 14, 2026 21:29
Pure rename; no content change. Prepares for splitting the module into
one file per layer.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
Pure move: every test body is byte-identical (review with
`--color-moved`); section-banner comments are replaced by one file per
topic, and the shared fixtures (`ECHO_REQ`, `slow_router`, `pki`, ...)
live in `tests/mod.rs`.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
…r` forward to them

The per-connection settings (HTTP/1.1 keep-alive, header read timeout,
HTTP/2 windows/keepalive/max streams, max age/idle/requests and their
shared grace) become one `#[non_exhaustive]` value with a `Default`,
`ConnectionConfig`; TLS and the handshake timeout become `AcceptConfig`.
`Server` and `BoundServer` each hold one of each, gain
`with_connection_config` / `connection_config()` and
`with_accept_config` / `accept_config()` so configuration crosses
between them (#250), and every existing `with_*` setter keeps its
signature as a one-line forward whose canonical documentation now lives
on the config type. The accept loop takes the two values instead of
seven positional knobs, which retires both `too_many_arguments` allows
and the "grouping them into a struct would not improve clarity" note.
The two once-per-server diagnostics (inert grace, inert keepalive
timeout) move to `ConnectionConfig::lint`.

Setter-semantics tests (adaptive-window precedence, zero rejection,
defaults) move next to `ConnectionConfig`; the per-topic "builder
threads through" tests collapse into one `builders.rs` test that checks
every forward on both types and the crossing of one value between them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
`Acceptor` owns `accept(2)`, transient-error retry (with a one-second
pause on `EMFILE`/`ENFILE` instead of a hot loop) and `TCP_NODELAY`;
`Accepted::handshake` (run on the connection's task) owns TLS
termination within its timeout and the capture of the verified client
chain, and yields an opaque `ServerIo` so the TLS stream type is not
part of the API. `HandshakeError` names the peer (`peer_addr()`), says
whether the timeout ran out (`is_timeout()`), and carries the rustls or
timeout `io::Error` as its `source()`; the loop, not the acceptor,
decides how loudly to log it.

The acceptor's output is the new public `ConnectionInfo` (replacing the
private `PeerInfo`): `peer_addr()` (an `Option<SocketAddr>`, matching
`RequestContext::peer_addr()`), `peer_certs()`, and connection-scoped
`extensions()` / `extensions_mut()` that every request on the connection
will carry. The driver stamps requests from it; `PeerAddr` / `PeerCerts`
are always set from what the transport observed, so nothing inserted
into the extensions under those types reaches a request. It lives in
`peer.rs` with `PeerAddr` / `PeerCerts`, the seam both the acceptor and
the connection driver import, and is re-exported at the crate root. The
accept loop is now `acceptor.accept()` + `accepted.handshake()` + serve,
identical with and without TLS. CI checks and lints the `server`-only
feature shape.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
`serve_connection(io, info, service, config, shutdown)` serves HTTP/1.1
or HTTP/2 on one already-accepted stream with the whole RPC lifecycle:
protocol settings from `ConnectionConfig`, max-age (jitter now sampled
per connection inside the driver) / idle / request-count retirement
with the shared grace, graceful GOAWAY when `shutdown` resolves, panic
isolation (a `CatchPanic` per connection instead of per server), and
`ConnectionInfo`'s extensions plus `PeerAddr` / `PeerCerts` on every
request. It is generic over any tower HTTP service so `axum::Router` can
ride the same driver, uses `serve_connection_with_upgrades` (identical
on the wire for services that never answer 101), installs the HTTP/2
timer unconditionally, returns `impl Future + Send + 'static` explicitly
(the contract custom loops spawn on), and binds nothing to a runtime
until first poll, so an accept loop places a connection by choosing
where to spawn the future. `Server::serve_connection` is the method
form that applies the server's service and config.

The lifecycle state machine is restructured, not moved verbatim: one
`grace` instead of a copy per trigger, states `Serving` / `Draining` /
`Retiring`, `conn` polled first in every state; the private
`RetirementConfig` family dissolves into reads of `ConnectionConfig`.
Its semantics are pinned by the unchanged end-to-end suites
(`tests/{shutdown,max_connection_age,max_connection_idle,max_requests}.rs`)
plus new direct layer-2 tests (`tests/connection.rs`: shutdown drains
in-flight, each trigger without a listener, built-ins stamped over
extensions, panic survival on keep-alive, runtime placement, a plain
`tower::service_fn`) and auto-trait assertions on the public types and
futures. The accept loop is now accept → handshake → `serve_connection`.

Conformance: server default 3600/3600, Connect+TLS 2396/2396.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
`accept_loop::run` is the whole of layer 4: accept, spawn
handshake → `serve_connection` on the ambient runtime, track the tasks,
fan the shutdown signal out, drain; it also owns how loudly a failed
handshake is logged. It is generic over the service so the axum adapter
can share it. `Server` and `BoundServer` move to `standalone.rs` as
thin fronts for it (`Server` gains `service()` next to `router()`),
and `server/mod.rs` is now only the module map and re-exports. A new
test pins that dropping the serving future aborts live connections.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
…ion driver

`connectrpc::axum::serve(listener, router)` (new, plaintext; `axum` +
`server`) and `serve_tls` both return `Serve`, which is the same
`accept_loop::run` over `Acceptor` + `serve_connection` that `Server`
uses, with an `axum::Router` as the service. Parity with `Server` is now
structural: `Serve::with_connection_config` takes the same
`ConnectionConfig` (max age / idle / requests, HTTP/2 keepalive and
windows), plaintext apps get `PeerAddr`, and there is no second
lifecycle to keep in sync. `ServeTls` remains as a deprecated alias of
`Serve`.

Observable changes on the axum path, all inherited from the driver: a
panicking handler yields a Connect `internal` 500 and the connection
survives (previously the connection task died — every stream on an h2
connection with it); HTTP/2 adaptive flow control is on by default as on
`Server`; connection tasks are owned by the `Serve` future and aborted
when it is dropped instead of running detached. The module gate relaxes
from `axum + server-tls` to `axum + server`.

The mTLS test PKI is shared with the server tests instead of duplicated.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
…lient identity

A short loop over `Acceptor` + `Server::serve_connection`: one TLS
listener, the workload identity parsed once per connection from the
client certificate, connections without a known identity refused before
any HTTP is spoken, `batch-*` workloads served on a separate `bulk`
runtime and everyone else on the accepting one. Handlers read the
identity from the request extensions and report the thread they ran on;
`tests/e2e.rs` asserts the placement and the refusal. The README states
what the loop inherits from the connection driver and the two lifetime
rules it owns.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
`server/mod.rs` opens with the layer table (protocol service →
connection driver → acceptor → loop) and what each owns; the crate docs
gain a "Custom accept loops" entry; the guide's Hosting section is
rebuilt around the layers, with `ConnectionConfig`, `connectrpc::axum::serve`,
a custom loop trimmed from `examples/custom-accept-loop` and the two
runtime-lifetime rules, and raw hyper demoted to "hyper knobs only, no
lifecycle".

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
The hand-rolled `ConnectionLifecycle` poll state machine (with
`IdleTracker` and `ConnectionLifecycleState`) becomes two `biased`
`select!` phases inside `serve_connection`: serve until the peer closes,
the shutdown future resolves, or `retirement()` names the first trigger
to fire (max age, then `ConnectionActivity::quiet_for`, then the
request-count latch); issue graceful shutdown; then either drain
indefinitely (shutdown) or for at most the shared grace unless shutdown
arrives meanwhile and lifts the cap. `biased` preserves the old poll
order with the connection first; `latched` is a plain `async fn`. Same
semantics, pinned by the unchanged shutdown / max-age / idle /
max-requests / direct-driver suites (max-age suite run 20×, full lib
suite 6×, no failures).

This commit is separable: drop it to keep the state-machine form.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgcfmMV1ECyzhbqGvUV1a7
Signed-off-by: Ryan Brewster <rpb@anthropic.com>
@rpb-ant rpb-ant changed the title server: four layers — acceptor, connection driver, accept loop, and Server as their composition RFC: server internals as acceptor + driver + loop (opt-in restructure; follows #306–#308, #304) Sep 16, 2026
@rpb-ant

rpb-ant commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: the parts of this with adopter demand landed as #306, #307, #308 and #304 (one ConnectionConfig, ConnectionInfo, a public service-generic serve_connection, and Server/BoundServer/axum::serve sharing one accept loop and driver).

What remains of the layering proposal is internal and behaviour-neutral: splitting server.rs into server/{config,peer,connection,accept_loop,…}.rs (the #253 row), splitting the test module by topic with a socket-free driver suite, extracting the two inline TLS-handshake blocks into a crate-private acceptor, and expressing the connection lifecycle as an async body instead of the hand-rolled poll state machine. The branch here predates #306#308 and their follow-up fixes, so it would be re-cut from main as move-only commits rather than rebased. Happy to do that whenever #253/#250 want it; leaving it as an offer rather than an open diff.

@rpb-ant rpb-ant closed this Sep 19, 2026
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.

Test modules run several times the size CONTRIBUTING allows, with the harness copied per test

1 participant