ADR-326: Many Connections at Once — net::serve Below Every Protocol, Admission by an Exchangeable Strategy, and a Panic That Ends Only Its Task

Status: Accepted Date: October 6, 2026 (#121; D6-D9 the same day) Answers: #121, the server takes many connections at once Related: ADR-289 D6-D15 (std::net, the http package, its caps); ADR-303 (the runtime, readiness for sockets, the executor at both settings); ADR-297 (cleanup on the way out); ADR-327 (shared state after a panic, memory budgets); Part II 11-12, Part III A.2

1. Question

How does a server take many connections at once, so that a small service - one route, a page of API documentation, a protocol of its own - does not run into a dead end, a busy machine is not overrun, and one bad request does not take the server down?

The http package takes one connection at a time. std::net already gives the thread up while a socket waits, and the executor runs many tasks at both values of user_parallelism (ADR-303); nothing starts one task per connection. A panic at user_parallelism = no ends the process.

2. How others do it

Tool Accepting Limit on connections A crash in one connection
Erlang/Elixir (Ranch, Cowboy) an acceptor pool below every protocol; a protocol is a module max_connections, configured, 1024 by default ends that process
Go net/http a goroutine per connection none; the operating system says no ends the process unless recover is written
Rust (tokio, hyper, tonic) the program’s loop, spawn per connection none built in ends that task
Netflix concurrency-limits, Envoy - computed from measured latency; the algorithm is an exchangeable Limit (Vegas, Gradient, AIMD, Fixed) -

From memory, not checked against a version. What transfers: Ranch’s layering, the adaptive limit and its exchangeable algorithm, and a crash that ends only its own unit of work.

3. Options, weighed

Option Why it lost
Many connections inside http::listen every other protocol - WebSocket, gRPC, one of the program’s own - writes the loop, the limits and the timeouts again
A maximum number of connections as an option or in a configuration file how many connections a machine serves depends on the load at the moment; a number fixed in advance is wrong at most moments
An adaptive limit computed on every request measuring in the hot path costs most when the machine has least to spare
A panic ends the process at no one malformed request ends every connection

4. Decision

D1 — std::net::serve accepts connections and runs each in a task of its own; every protocol stands on it.

net::serve("0.0.0.0:7000") fn(conn) { echo(conn) }
http::listen("0.0.0.0:8080") fn(request) { … }      // net::serve, and HTTP/1.1 per connection

It is the one place that accepts, limits and stops; http is one of its users, and WebSocket, gRPC or a program’s own protocol are others.

D2 — There is no number of connections to configure; a strategy decides admission, beside the hot path.

Changes ADR-289 D13: there is no connection cap.

serve takes an admission: strategy, a value implementing net::Admission:

pub trait Admission {
    fn decide(ref mut self, load: net::Load) -> net::Decision sync
}

decide runs on the runtime’s side, on a beat it chooses itself: Load carries what the runtime counts anyway - connections open, accepted and finished, the tasks waiting to run, the descriptors in use and allowed - and Decision the limit until the next decision and when that is. Per connection the hot path compares and counts, nothing else. std provides the default, which adapts the limit to the load; a package may provide another, a fixed number read from a file among them. Load grows and never shrinks.

D3 — What no strategy can change, the runtime enforces.

Not more descriptors than the system allows; at the ceiling or over the limit the server stops accepting and the system’s queue holds the next connection - it never accepts to fail, and a ceiling never ends the server. A strategy is sync.

D4 — A panic in a task ends that task, at both values of user_parallelism.

Changes Part III A.2’s no row.

The task’s cleanups run (ADR-297), the panic hook runs first as on every panic, and the program goes on. A panic in main ends the program. Where the machine cannot unwind - wasm32-unknown traps - the module ends, as before. For serve, the connection is closed; http answers 500 where the response has not begun.

What a panic leaves in shared state is ADR-327’s.

D5 — connections: on http::listen stays, as what it is: a number of connections after which the server returns.

A test’s tool, not a limit.

D6 — The head of a request must arrive within head_timeout, ten seconds by default.

Changes ADR-289 D13: head_wait, a count of reads, is gone.

http::listen(at; head_timeout: time::Duration = 10.seconds()). The clock runs from the accept for a connection’s first request and from the first byte for every later one, and covers the whole head: a client that sends a byte now and then is held to it as one that sends nothing. Past it the server answers 408 and closes the connection. A fixed default fits here, where the number of connections did not: it says how long a client may take, which is the protocol’s, not the machine’s load.

D7 — A body must keep body_bytes_per_second, 500 by default, on average since it began.

http::listen(at; body_bytes_per_second: i64 = 500). The rate is the bytes so far over the time since the body’s first byte, checked after a start-up period of std’s, so a burst or a stall of the network is not a refusal, a slow upload that keeps coming is never cut off, and a body sent a byte at a time is. Past it the server answers 408 and closes. The unit is in the name because no type carries it.

D8 — A request’s timers run only while a request comes in; a quiet connection is its protocol’s.

net::serve gives every protocol a deadline per read and sets no idle limit of its own: whether a silent connection is waiting or dead only the protocol knows. A connection handed to another protocol - a WebSocket after its upgrade - is no longer HTTP’s, and its owner decides how it knows the peer is alive, typically by a heartbeat.

D9 — HTTP/1.1 connections are kept alive, idle_timeout 75 seconds between requests, and closed early only after a response.

Changes ADR-289 D15: not Connection: close after every response.

http::listen(at; idle_timeout: time::Duration = 75.seconds()). A connection waits for its next request this long, and then closes. The default lies above the 60 seconds of common load balancers, because a service that closes a quiet connection before the balancer in front of it does loses the request the balancer sends into it at that moment. Under load the admission strategy (D2) may end connections early, and only in order: the next response on such a connection says Connection: close; a quiet connection is never closed into its silence before idle_timeout.

5. Consequences

6. What this does not decide

This site is open source. Improve this page.