Skip to main content

Module accept_bounded

Module accept_bounded 

Source
Expand description

A connection cap for accept loops, and the refusal that says why.

§The problem this closes

Every TCP accept loop in this tree used to accept without limit. A peer that connects and says nothing holds a socket, a task and an AppState connection entry for as long as it likes; a thousand of them is a free denial of service, pre-authentication, on a server that will happily accept a thousand more. Per-connection read timeouts bound how long one peer can stall, and are the other half of this — but a timeout alone still lets an attacker hold timeout × rate connections at once. Only a cap turns “bounded per connection” into “bounded”.

§Why it refuses out loud

A cap that silently drops the socket is worse than no cap at all: the client cannot tell a full server from a crashed one, so it retries immediately and forever, and the operator sees an outage with no cause. So accept_bounded writes the caller’s own bytes before closing, and each protocol supplies the phrasing its own clients already understand — MySQL error 1040, RESP -ERR max number of clients reached, HTTP 503, and so on. The bytes live in each protocol’s mod.rs, not here: a table of per-protocol literals in a shared module would be exactly the centralised per-protocol logic this codebase forbids. This module knows only “some bytes, or none”.

Where a protocol has no way to say it — the peer speaks first and every server message is a positive assertion — the right answer is an empty slice and a plain close, for the same reason twenty protocols are deliberately silent on an LLM failure: a fabricated reply is worse than silence. The refusal is still logged, and the log line is the diagnosis.

§Usage

ⓘ
const MAX_CONNECTIONS: usize = accept_bounded::DEFAULT_MAX_CONNECTIONS;
const BUSY: &[u8] = b"-ERR max number of clients reached\r\n";

let limiter = ConnectionLimiter::new(MAX_CONNECTIONS);
loop {
    let (socket, peer, permit) =
        match accept_bounded(&listener, &limiter, BUSY, "REDIS", Some(&status_tx)).await {
            Ok(v) => v,
            Err(e) => { /* the listener itself failed; break */ }
        };
    tokio::spawn(async move {
        let _permit = permit; // held for the life of the connection
        handle(socket).await;
    });
}

accept_bounded never returns a refused connection: it loops internally, so the caller’s loop only ever sees peers that were admitted. The ConnectionPermit it returns must be moved into the connection task — dropping it early releases the slot while the connection is still live, which silently un-caps the server.

Structs§

BusyGuard
Holds a connection “busy” for its lifetime. See ConnectionActivity::busy.
ConnectionActivity
Whether a connection whose loop belongs to a crate is idle, and since when.
ConnectionLimiter
Tracks how many connections a server currently has admitted.
ConnectionPermit
One admitted connection’s slot. Released when dropped, so it must live as long as the connection task does.
IdleTimeoutReader
An AsyncRead that fails with std::io::ErrorKind::TimedOut when the peer sends nothing for idle.

Constants§

DEFAULT_MAX_CONNECTIONS
Concurrent connections a server admits before it starts refusing.
KEEPALIVE_PING_PAYLOAD
The payload of the keepalive Ping the WebSocket-carried servers send through watch_idle_with_probe. A Pong echoes it (RFC 6455 §5.5.3); it is not checked, because any frame is proof of life, but it makes the Ping identifiable in a capture.

Functions§

accept_bounded
Accept the next connection the server has room for, refusing the ones it does not.
accept_bounded_unix
accept_bounded for a Unix domain socket listener (socket_file, ssh_agent).
watch_idle
Watch a ConnectionActivity and resolve once it has been idle for idle.
watch_idle_with_probe
watch_idle for a session protocol whose client may legitimately say nothing for a long time: resolve once activity has been idle for idle, having called probe once at half of it.