diff --git a/README.md b/README.md index a5f3166..caf7e5a 100644 --- a/README.md +++ b/README.md @@ -126,17 +126,72 @@ Handlers are closures taking `(Conn, Next) -> Conn`. Call `Next::call(c)` to con ```rust use urus::{serve_with, Config}; -use std::net::SocketAddr; +use std::time::Duration; let cfg = Config { - listener_pool: 2, // Number of OS threads handling accept() - scheduler_threads: Some(2), // Number of smarm worker threads + listener_pool: 2, // Supervised accept-loop actors + scheduler_threads: Some(2), // smarm worker threads (None = one per CPU) + keep_alive_timeout: Duration::from_secs(60), // Idle budget between requests + request_timeout: Duration::from_secs(30), // Whole-request read deadline + max_header_count: 64, + read_buf_size: 8 * 1024, + max_body_bytes: 1 << 20, + drain_timeout: Duration::from_secs(30), // Graceful-shutdown drain budget ..Config::new("127.0.0.1:8080".parse().unwrap()) }; serve_with(cfg, pipeline).unwrap(); ``` +**Timeout semantics:** + +- `keep_alive_timeout` — how long a connection may sit idle waiting for the + *first byte* of a request. Expiry closes the socket silently (nothing was + in flight). Pipelined leftover bytes count as a started request, not idle. +- `request_timeout` — a wall-clock deadline from a request's first byte + until its head and body are fully read. Expiry mid-head gets a + best-effort `408 Request Timeout`; expiry mid-body just closes. Deadlines + are absolute instants, so a trickling (slowloris-style) client can't + reset its budget by sending one byte at a time. + +### Graceful Shutdown + +`serve_with_shutdown` takes a `ShutdownSignal`; the paired `Handle` can be +triggered from anywhere (another thread, a signal handler): + +```rust +use urus::{serve_with_shutdown, shutdown_handle, Config}; + +let (handle, signal) = shutdown_handle(); + +std::thread::spawn(move || { + // e.g. wait for SIGTERM / stdin / an admin endpoint... + handle.shutdown(); +}); + +serve_with_shutdown(cfg, pipeline, signal).unwrap(); +// Returns once the runtime has fully wound down. +``` + +`Handle::shutdown()` is idempotent and performs, in order: + +1. Stop accepting — every listener exits; no new connections. +2. Close idle keep-alive connections immediately. +3. Drain in-flight requests for up to `Config.drain_timeout`. +4. Force-stop any stragglers past the deadline (sockets close cleanly on + unwind via `OwnedFd::drop`). + +If every `Handle` is dropped, shutdown can never be signalled and the +server runs forever — exactly `serve_with`'s semantics (it does this +internally). + +### Named Actors + +For introspection and debugging, the server registers itself in smarm's +process registry: `urus.server` (the listener-pool supervisor) and +`urus.listener.{i}` (each accept loop). `smarm::whereis(name)` resolves +them from any actor inside the runtime. + ## Examples ### CRUD with Actor Ownership @@ -165,11 +220,12 @@ cargo test ``` Tests in `tests/integration.rs` cover: -- Basic routing (GET, POST, PUT, DELETE) -- Request body echoing -- HTTP header parsing -- Path parameter extraction -- Status code responses +- Basic routing, request bodies, headers, path parameters, status codes +- Keep-alive and pipelining +- Listener supervision (a panicking listener restarts without dropping a pending accept) +- Graceful shutdown (idle close, in-flight drain, force-stop at the drain deadline) +- Timeouts (idle keep-alive reaping, slowloris-style slow headers/body) +- Registry names (`urus.server`, `urus.listener.{i}` resolve via `whereis`) ## Architecture @@ -205,11 +261,10 @@ This avoids the complexity of async ecosystems while maintaining full concurrenc ## Roadmap -v1 covers HTTP/1.1, the plug pipeline, and a built-in router. Future versions may include: -- Middleware ecosystem (auth, logging, compression, etc.). -- WebSocket support. -- Benchmarking suite (see `urus-bench-spec.md`). -- Additional utilities and examples. +See [`ROADMAP.md`](ROADMAP.md). Summary: v0.2 (supervised listener pool, +graceful shutdown, enforced timeouts, registry names) is done; next up are +streaming bodies + SSE (v0.3), WebSocket (v0.4), PubSub (v0.5), and +Phoenix-style channels (v0.6). Refer to `urus-spec.md` and `urus-v1-build-notes.md` in the artifact persistence for the original design and implementation notes. diff --git a/ROADMAP.md b/ROADMAP.md index 4f9f3ef..7ffe8c4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ select, plus RFC 008 phase 1 fd arms / timed fd waits): --- -## v0.2 — OTP refresh ⏳ +## v0.2 — OTP refresh ✅ (landed 2026-06, chunks 1-4 / commits 5fe6969..HEAD) Adopt the primitives urus predates. No protocol surface changes; `Plug`/`Conn` untouched. Chunks are independently landable, in order: @@ -59,7 +59,11 @@ reuses it. No re-dup, no window where a pool slot has no fd. Covered by injected before `accept` so the pending connection must survive into the restarted listener). -### 2. Graceful shutdown +### 2. Graceful shutdown ✅ +Landed (c658ac0): `serve_with_shutdown(config, pipeline, signal)` + +`shutdown_handle()`. Drain-then-stop as decided below; design details in +the commit body. `serve_with` keeps v1 run-forever semantics by dropping +its own Handle. `serve_with` gains a shutdown signal (an `urus::Handle` with `.shutdown()`, backed by a channel + `request_stop` fan-out): 1. Stop listeners (no new accepts; listener fds close). @@ -75,7 +79,11 @@ actor monitors-free self-deregisters via a drop guard cast. Pid set only (no activity stamps — chunk 3 went per-conn timed waits, no reaper); still the future ws/channels introspection point, so build it once here. -### 3. Enforce the timeouts we advertise +### 3. Enforce the timeouts we advertise ✅ +Landed (a60687b): timed waits as decided below; the leaning held — a +per-request `Instant` deadline keeps `request_timeout` an honest +whole-request wall clock (mid-head expiry: best-effort non-parking 408; +mid-body: close). Details in the commit body. **Decided: smarm-native timed waits** (the fork's option (a), a reaper in urus, died when RFC 008 landed at `393cdd0` — `wait_readable_timeout` / `wait_writable_timeout` exist upstream now, so there is nothing for the @@ -96,7 +104,9 @@ urus is the first real consumer of the timed wrappers — note the RFC 008 reviewer flag (`wait_fd` wraps in `NoPreempt`, select arms don't); the crud example under load doubles as that confirmation. -### 4. Hygiene +### 4. Hygiene ✅ +Landed: registry names + whereis test (in-runtime probe via a handler — +tests are foreign threads), README rewrite, smarm-trace compile verified. - Registry names: `urus.server`, `urus.listener.{n}` via `smarm::register` (debuggability; `whereis` in tests). - README: rewrite Roadmap section to point here; document `Handle`. diff --git a/src/serve.rs b/src/serve.rs index 26d6c49..bd7c35f 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -312,6 +312,15 @@ pub fn serve_with_shutdown( let sf = shutdown_flag.clone(); sup = sup.child(ChildSpec::new(Restart::Transient, move || { println!("urus: listener {} starting", i); + // Named for whereis-style introspection. On a restart the + // old binding points at a dead pid; smarm's registry + // evicts stale bindings lazily, so re-registering the + // same name is fine. Ignore the result — a registry + // hiccup must not take the listener down. + let _ = smarm::register( + format!("urus.listener.{i}"), + smarm::self_pid(), + ); listener_loop(lfd.clone(), p.clone(), limits, r.clone(), sf.clone()); })); } @@ -319,6 +328,11 @@ pub fn serve_with_shutdown( // faster than that trips the cap and tears the pool down — loud // failure over a zombie server. let sup_h = smarm::spawn(move || sup.run()); + // Register via the JoinHandle's pid rather than inside the + // closure: the binding exists before the supervisor body runs a + // single instruction, so an early `whereis("urus.server")` can't + // race a None. Result ignored for the same reason as listeners. + let _ = smarm::register("urus.server", sup_h.pid()); // Block until told to shut down. We poll `try_recv` + `sleep` // rather than parking in `recv`: a smarm `Sender::send` from a diff --git a/tests/integration.rs b/tests/integration.rs index ad4bba7..6483a34 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -89,6 +89,25 @@ fn hello_world() { assert_eq!(http_body(&resp), b"hello urus"); } +#[test] +fn server_and_listeners_are_registered() { + // `whereis` must run inside the runtime (the test itself is a foreign + // OS thread with no runtime in its TLS), so probe from a handler — + // connection actors live in the runtime by construction. + let pipe = Pipeline::new().plug( + Router::new().get("/whereis", |c: Conn, _n: Next| { + let ok = smarm::whereis("urus.server").is_some() + && smarm::whereis("urus.listener.0").is_some() + && smarm::whereis("urus.listener.1").is_some(); // pool of 2 + c.put_status(200).put_body(if ok { "registered" } else { "missing" }) + }) + ); + let port = spawn_server(pipe); + let resp = send_request(port, b"GET /whereis HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + assert_eq!(http_status(&resp), 200); + assert_eq!(http_body(&resp), b"registered"); +} + #[test] fn echo_body() { let pipe = Pipeline::new().plug(