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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user