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§
- Busy
Guard - Holds a connection “busy” for its lifetime. See
ConnectionActivity::busy. - Connection
Activity - Whether a connection whose loop belongs to a crate is idle, and since when.
- Connection
Limiter - Tracks how many connections a server currently has admitted.
- Connection
Permit - One admitted connection’s slot. Released when dropped, so it must live as long as the connection task does.
- Idle
Timeout Reader - An
AsyncReadthat fails withstd::io::ErrorKind::TimedOutwhen the peer sends nothing foridle.
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_boundedfor a Unix domain socket listener (socket_file,ssh_agent).- watch_
idle - Watch a
ConnectionActivityand resolve once it has been idle foridle. - watch_
idle_ with_ probe watch_idlefor a session protocol whose client may legitimately say nothing for a long time: resolve onceactivityhas been idle foridle, having calledprobeonce at half of it.