feat(conn): keep-alive and per-request read timeouts (v0.2 chunk 3)

Two wall-clock budgets in the conn read path, both via
smarm::wait_readable_timeout:

- keep_alive_timeout: idle budget between requests — how long we park
  waiting for the FIRST byte of a request (including the first request on
  a fresh connection). Expiry closes silently; nothing is owed to a
  client that isn't talking.
- request_timeout: per-request budget as an Instant deadline from the
  first byte of a request until the request (head + body) is fully read.
  Pipelined leftovers in the buffer at cycle start count as a started
  request. The deadline is returned from read_head and threaded into
  read_body so head and body share one budget. Pipeline run time is NOT
  covered. Each read wait uses whichever budget is currently active;
  deadlines are Instants, so EAGAIN retries don't reset the clock.

Expiry mid-head gets a best-effort 408 via try_write_once: a single
non-parking write syscall — a client that stalls its read side must not
defeat the timeout by making the 408 write park forever. Expiry mid-body
just closes.

examples/crud.rs gains stdin-Enter graceful shutdown (plain OS thread on
read_line -> handle.shutdown(); no signal crate). Doing so surfaced a
pattern worth knowing: crud's lazily-spawned store actor parked forever
in recv(), which blocks smarm's AllDone, so serve_with_shutdown never
returned (gdb: scheduler idle in poll_wake, store the only live actor).
It can't be messaged awake from the stdin thread either — a cross-thread
send's unpark is a no-op without runtime TLS, the same limitation behind
SHUTDOWN_POLL. Fix: store_loop recv_timeout(250ms) + a SHUTTING_DOWN
AtomicBool set by the stdin thread before handle.shutdown(). This poll
dies with the cross-thread-unpark limitation; recorded in smarm docs.

Tests: idle keep-alive conn reaped at a small keep_alive_timeout;
slowloris partial-head stall killed at request_timeout with the 408
observed. Timeout+shutdown subset hammered 30x clean; full suite 3x;
smarm-trace build and suite clean.
This commit is contained in:
Claude
2026-06-11 22:07:18 +00:00
parent c658ac06b2
commit a60687bfec
5 changed files with 266 additions and 21 deletions
+117 -17
View File
@@ -23,7 +23,7 @@ use smarm::ServerRef;
use std::io::{self, ErrorKind};
use std::os::fd::RawFd;
use std::time::Duration;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// Limits
@@ -41,7 +41,16 @@ pub struct ConnLimits {
/// Hard cap on Content-Length we'll accept. 16 MiB is enough for a CRUD
/// example; configurable in `Config`.
pub max_body_bytes: usize,
/// Idle budget between requests: how long we'll park waiting for the
/// FIRST byte of a request (including the first request on a fresh
/// connection). Expiry closes the connection silently — nothing is
/// owed to a client that isn't talking.
pub keep_alive_timeout: Duration,
/// Per-request wall-clock budget, measured from the first byte of a
/// request until the request (head + body) is fully read. Expiry
/// mid-head gets a best-effort 408; expiry mid-body just closes.
/// Pipeline run time is NOT covered — that's the handler's business.
pub request_timeout: Duration,
}
impl Default for ConnLimits {
@@ -52,6 +61,7 @@ impl Default for ConnLimits {
max_head_bytes: 64 * 1024,
max_body_bytes: 16 * 1024 * 1024,
keep_alive_timeout: Duration::from_secs(60),
request_timeout: Duration::from_secs(30),
}
}
}
@@ -83,14 +93,27 @@ pub fn run_connection(
// ----- 1. Read until we have a full request head. -----
// We are idle until a head parses: stoppable by a draining
// registry while parked here.
let parsed = match read_head(raw, &mut buf, &limits) {
let (parsed, request_deadline) = match read_head(raw, &mut buf, &limits) {
Ok(p) => p,
Err(ReadHeadErr::ClientClosed) => {
// Clean EOF between requests (or before any request). Normal.
return;
}
Err(ReadHeadErr::IdleTimeout) => {
// keep_alive_timeout expired waiting for the first byte of
// a request. Nothing is owed; close silently.
return;
}
Err(ReadHeadErr::RequestTimeout) => {
// request_timeout expired mid-head (slowloris and friends).
// Best-effort 408 WITHOUT parking on writability — a client
// that stalls reads must not defeat the timeout by making
// the 408 write park forever.
try_write_once(raw, b"HTTP/1.1 408 Request Timeout\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
return;
}
Err(ReadHeadErr::Io(_)) => {
// Network error or timeout. Best-effort close; we're done.
// Network error. Best-effort close; we're done.
return;
}
Err(ReadHeadErr::Parse(e)) => {
@@ -116,8 +139,10 @@ pub fn run_connection(
}
}
let body = match read_body(raw, &mut buf, parsed.head_len, body_len) {
let body = match read_body(raw, &mut buf, parsed.head_len, body_len, request_deadline) {
Ok(b) => b,
// Timeout mid-body (and any other body io error) -> just close;
// there's no point talking HTTP to a client this far gone.
Err(_) => return,
};
@@ -194,6 +219,12 @@ pub fn run_connection(
#[allow(dead_code)] // io::Error is captured for future logging
enum ReadHeadErr {
ClientClosed,
/// keep_alive_timeout expired while waiting for the first byte of a
/// request. Close silently.
IdleTimeout,
/// request_timeout expired after the request had started arriving.
/// Best-effort 408.
RequestTimeout,
Io(io::Error),
Parse(ParseError),
}
@@ -201,17 +232,41 @@ enum ReadHeadErr {
/// Read until `parse_head` succeeds or fails definitively. `buf` may already
/// contain leftover bytes from a previous keep-alive cycle; we try to parse
/// those before reading more from the socket.
///
/// Two wall-clock budgets govern the waits (each wait uses whichever budget
/// is currently active):
///
/// - while `buf` is empty and nothing has arrived, we are *idle* and the
/// wait is bounded by `keep_alive_timeout`;
/// - the instant the request has started (first byte read, or pipelined
/// bytes already in `buf` at entry), the *request* clock starts: an
/// `Instant` deadline of `request_timeout` from that moment, which also
/// covers body reads — it is returned alongside the parsed head so the
/// caller can thread it into `read_body`.
fn read_head(
fd: RawFd,
buf: &mut Vec<u8>,
limits: &ConnLimits,
) -> Result<parser::ParsedHead, ReadHeadErr> {
) -> Result<(parser::ParsedHead, Instant), ReadHeadErr> {
let entry = Instant::now();
let idle_deadline = entry + limits.keep_alive_timeout;
// Pipelined leftovers count as a started request.
let mut request_deadline: Option<Instant> = if buf.is_empty() {
None
} else {
Some(entry + limits.request_timeout)
};
loop {
// Try to parse what we already have. On the first iteration of a
// fresh keep-alive cycle, `buf` may already hold the next request.
if !buf.is_empty() {
match parser::parse_head(buf, limits.max_headers) {
Ok(h) => return Ok(h),
Ok(h) => {
let deadline = request_deadline
.unwrap_or_else(|| Instant::now() + limits.request_timeout);
return Ok((h, deadline));
}
Err(ParseError::Incomplete) => {} // need more bytes
Err(e) => return Err(ReadHeadErr::Parse(e)),
}
@@ -221,10 +276,24 @@ fn read_head(
return Err(ReadHeadErr::Parse(ParseError::TooManyHeaders));
}
// Read more.
match read_some(fd, buf, limits.initial_read_buf) {
// Read more, bounded by whichever budget is active.
let deadline = request_deadline.unwrap_or(idle_deadline);
match read_some(fd, buf, limits.initial_read_buf, deadline) {
Ok(0) => return Err(ReadHeadErr::ClientClosed),
Ok(_) => continue,
Ok(_) => {
if request_deadline.is_none() {
// First byte(s) of this request: the request clock
// starts now.
request_deadline = Some(Instant::now() + limits.request_timeout);
}
}
Err(e) if e.kind() == ErrorKind::TimedOut => {
return Err(if request_deadline.is_some() {
ReadHeadErr::RequestTimeout
} else {
ReadHeadErr::IdleTimeout
});
}
Err(e) => return Err(ReadHeadErr::Io(e)),
}
}
@@ -239,6 +308,7 @@ fn read_body(
buf: &mut Vec<u8>,
head_len: usize,
body_len: usize,
deadline: Instant,
) -> io::Result<Vec<u8>> {
// Bytes already in `buf` past the head belong to the body.
let already = buf.len().saturating_sub(head_len);
@@ -250,10 +320,11 @@ fn read_body(
return Ok(buf[head_len..head_len + body_len].to_vec());
}
// Read until we have the rest.
// Read until we have the rest, on the same request budget that the
// head was read under.
let mut total_read = already;
while total_read < body_len {
match read_some(fd, buf, 8 * 1024) {
match read_some(fd, buf, 8 * 1024, deadline) {
Ok(0) => return Err(io::Error::new(ErrorKind::UnexpectedEof, "client closed during body")),
Ok(n) => total_read += n,
Err(e) => return Err(e),
@@ -263,18 +334,31 @@ fn read_body(
}
// ---------------------------------------------------------------------------
// read_some — single epoll-park + read loop.
// read_some — single epoll-park + read loop, bounded by a deadline.
// ---------------------------------------------------------------------------
//
// Appends what it reads onto `buf`. Returns bytes read, 0 for EOF, or the
// last io error.
// Appends what it reads onto `buf`. Returns bytes read, 0 for EOF,
// `ErrorKind::TimedOut` when `deadline` passes before the fd turns
// readable, or the last io error.
fn read_some(fd: RawFd, buf: &mut Vec<u8>, chunk: usize) -> io::Result<usize> {
fn read_some(
fd: RawFd,
buf: &mut Vec<u8>,
chunk: usize,
deadline: Instant,
) -> io::Result<usize> {
// Loop to absorb EAGAIN: a readable wakeup followed by EAGAIN is
// possible (signal race, etc). Re-park and retry rather than returning
// 0 (which would be confused with EOF by callers).
// 0 (which would be confused with EOF by callers). The deadline is an
// Instant, so spurious wakes don't reset the budget.
loop {
smarm::wait_readable(fd)?;
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(io::Error::new(ErrorKind::TimedOut, "read deadline elapsed"));
}
if !smarm::wait_readable_timeout(fd, remaining)? {
return Err(io::Error::new(ErrorKind::TimedOut, "read deadline elapsed"));
}
let start = buf.len();
buf.resize(start + chunk, 0);
@@ -298,6 +382,22 @@ fn read_some(fd: RawFd, buf: &mut Vec<u8>, chunk: usize) -> io::Result<usize> {
}
}
// ---------------------------------------------------------------------------
// try_write_once — single non-parking write attempt, result ignored.
// ---------------------------------------------------------------------------
//
// For best-effort farewells (the 408) to clients we've decided to drop:
// one non-blocking write syscall, no wait_writable park. A client that
// stalls its read side must not be able to keep this actor alive past its
// own timeout. The socket send buffer almost always has room for a
// one-liner, so in practice the 408 lands.
fn try_write_once(fd: RawFd, buf: &[u8]) {
unsafe {
let _ = libc::write(fd, buf.as_ptr() as *const _, buf.len());
}
}
// ---------------------------------------------------------------------------
// write_all — robust write loop.
// ---------------------------------------------------------------------------