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:
+35
-4
@@ -25,7 +25,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smarm::{channel, Sender};
|
||||
use std::sync::OnceLock;
|
||||
use urus::{serve_with, Config, Conn, Next, Pipeline, Router};
|
||||
use urus::{serve_with_shutdown, shutdown_handle, Config, Conn, Next, Pipeline, Router};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain
|
||||
@@ -78,8 +78,19 @@ fn store_loop(rx: smarm::Receiver<Request>) {
|
||||
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
||||
|
||||
loop {
|
||||
let req = match rx.recv() {
|
||||
Ok(r) => r,
|
||||
// recv with a timeout rather than a bare recv: the store must be
|
||||
// stoppable at shutdown, but a cross-thread Sender::send (from the
|
||||
// stdin thread) can't wake a parked actor — same smarm limitation
|
||||
// that motivates urus's SHUTDOWN_POLL. So we wake on our own timer
|
||||
// and poll the flag. This poll dies with that limitation too.
|
||||
let req = match rx.recv_timeout(std::time::Duration::from_millis(250)) {
|
||||
Ok(r) => r,
|
||||
Err(smarm::channel::RecvTimeoutError::Timeout) => {
|
||||
if SHUTTING_DOWN.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(_) => return, // all senders dropped
|
||||
};
|
||||
match req {
|
||||
@@ -165,6 +176,12 @@ fn persist(users: &[User]) {
|
||||
|
||||
static STORE_TX: OnceLock<Sender<Request>> = OnceLock::new();
|
||||
|
||||
// Set by the stdin thread at shutdown; the store actor polls it (see
|
||||
// store_loop). An always-on app actor that never returns would otherwise
|
||||
// block smarm's AllDone and keep serve_with_shutdown from returning.
|
||||
static SHUTTING_DOWN: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn store() -> &'static Sender<Request> {
|
||||
STORE_TX.get_or_init(|| {
|
||||
let (tx, rx) = channel::<Request>();
|
||||
@@ -266,5 +283,19 @@ fn main() {
|
||||
|
||||
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
||||
println!("urus-crud: DB at {DB_PATH}");
|
||||
serve_with(cfg, pipeline).unwrap();
|
||||
println!("urus-crud: listening on 127.0.0.1:8080 — press Enter to shut down");
|
||||
|
||||
// Graceful shutdown on stdin-Enter: a plain OS thread blocks on
|
||||
// read_line and fires the handle. No signal handling crate needed.
|
||||
let (handle, signal) = shutdown_handle();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut line);
|
||||
println!("urus-crud: shutting down (draining in-flight requests)…");
|
||||
SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
||||
println!("urus-crud: bye");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user