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
+112
View File
@@ -425,3 +425,115 @@ fn shutdown_force_stops_at_drain_deadline() {
.recv_timeout(Duration::from_secs(5))
.expect("serve did not return after force-stop deadline");
}
// ---------------------------------------------------------------------------
// Timeouts (v0.2 chunk 3)
// ---------------------------------------------------------------------------
fn spawn_server_with_timeouts(
pipeline: Pipeline,
keep_alive: Duration,
request: Duration,
) -> u16 {
let port = free_port();
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
std::thread::spawn(move || {
let cfg = Config {
listener_pool: 2,
scheduler_threads: Some(2),
keep_alive_timeout: keep_alive,
request_timeout: request,
..Config::new(addr)
};
serve_with(cfg, pipeline).unwrap();
});
for _ in 0..50 {
if TcpStream::connect(addr).is_ok() {
return port;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("server didn't come up on {addr}");
}
/// Read from `s` until the response head is complete (double CRLF). Only
/// suitable for responses with an empty body.
fn read_response_head(s: &mut TcpStream) -> Vec<u8> {
let mut resp = Vec::new();
let mut byte = [0u8; 1];
while !resp.ends_with(b"\r\n\r\n") {
match s.read(&mut byte) {
Ok(0) => break,
Ok(_) => resp.push(byte[0]),
Err(e) => panic!("read failed mid-response: {e}"),
}
}
resp
}
/// An idle keep-alive connection is reaped once keep_alive_timeout passes
/// with no next request: the server closes the socket (clean EOF on our
/// side) well before the much larger request_timeout.
#[test]
fn idle_keepalive_reaped_at_keep_alive_timeout() {
let pipe = Pipeline::new().plug(
Router::new().get("/", |c: Conn, _n: Next| c.put_status(200))
);
let port = spawn_server_with_timeouts(
pipe,
Duration::from_millis(300), // keep_alive_timeout under test
Duration::from_secs(10), // request_timeout out of the way
);
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n").unwrap(); // keep-alive
let head = read_response_head(&mut s);
assert_eq!(http_status(&head), 200);
// Now go quiet. The server should close us at ~300ms; the 5s read
// timeout on our side is the failure detector.
let start = std::time::Instant::now();
let mut byte = [0u8; 1];
match s.read(&mut byte) {
Ok(0) => {} // clean EOF: reaped
Ok(n) => panic!("expected EOF, got {n} unexpected byte(s)"),
Err(e) => panic!("expected EOF, read errored: {e}"),
}
assert!(
start.elapsed() < Duration::from_secs(3),
"reap took {:?}, expected ~300ms", start.elapsed()
);
}
/// A slowloris client that sends a partial head and then stalls is killed
/// at request_timeout with a best-effort 408, even though the (large)
/// keep-alive budget hasn't expired.
#[test]
fn slowloris_partial_head_killed_at_request_timeout() {
let pipe = Pipeline::new().plug(
Router::new().get("/", |c: Conn, _n: Next| c.put_status(200))
);
let port = spawn_server_with_timeouts(
pipe,
Duration::from_secs(10), // keep_alive_timeout out of the way
Duration::from_millis(300), // request_timeout under test
);
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
// Partial head: request clock starts on these bytes, never completes.
s.write_all(b"GET / HTTP/1.1\r\nHost: x\r\n").unwrap();
let start = std::time::Instant::now();
let mut resp = Vec::new();
s.read_to_end(&mut resp).expect("expected 408+EOF or EOF");
assert!(
start.elapsed() < Duration::from_secs(3),
"kill took {:?}, expected ~300ms", start.elapsed()
);
// The 408 is best-effort (single non-parking write), but with our read
// side live it should land.
assert!(!resp.is_empty(), "expected a best-effort 408 before close");
assert_eq!(http_status(&resp), 408);
}