feat(serve): registry names + docs (v0.2 chunk 4)

Registration:
- Each listener registers "urus.listener.{i}" from inside its ChildSpec
  factory. On a restart the stale binding points at a dead pid; smarm's
  registry evicts those lazily on register/whereis, so re-registering
  the same name is safe. Result ignored — a registry hiccup must not
  take the listener down.
- "urus.server" -> supervisor pid, registered via the JoinHandle's
  .pid() immediately after spawn rather than inside the closure: the
  binding exists before the supervisor runs an instruction, so an early
  whereis can't observe None.

Test: server_and_listeners_are_registered probes whereis from a route
handler — whereis must run inside the runtime, and the test thread is a
foreign OS thread with no runtime in its TLS.

Docs:
- README: Config documented in full (timeout semantics: keep_alive =
  idle-before-first-byte, request = Instant deadline first-byte ->
  head+body, slowloris-proof), Handle/shutdown_handle/serve_with_shutdown
  section with the 4-step shutdown sequence, named-actors note, test
  coverage list refreshed, Roadmap section now points at ROADMAP.md.
- ROADMAP: v0.2 chunks 1-4 marked landed; design detail stays in the
  chunk commit bodies.

Verified: cargo build --features smarm-trace clean; full suite (32
tests) green 3x; debug-grep clean.

This closes v0.2. Exit criteria all verified across chunks 1-3: crud
drains on stdin-Enter (~100ms), idle keep-alive reaped at
keep_alive_timeout, panicking listener restarts under load.
This commit is contained in:
Claude
2026-06-11 22:24:02 +00:00
parent a60687bfec
commit 8065ea561e
4 changed files with 115 additions and 17 deletions
+68 -13
View File
@@ -126,17 +126,72 @@ Handlers are closures taking `(Conn, Next) -> Conn`. Call `Next::call(c)` to con
```rust ```rust
use urus::{serve_with, Config}; use urus::{serve_with, Config};
use std::net::SocketAddr; use std::time::Duration;
let cfg = Config { let cfg = Config {
listener_pool: 2, // Number of OS threads handling accept() listener_pool: 2, // Supervised accept-loop actors
scheduler_threads: Some(2), // Number of smarm worker threads 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()) ..Config::new("127.0.0.1:8080".parse().unwrap())
}; };
serve_with(cfg, pipeline).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 ## Examples
### CRUD with Actor Ownership ### CRUD with Actor Ownership
@@ -165,11 +220,12 @@ cargo test
``` ```
Tests in `tests/integration.rs` cover: Tests in `tests/integration.rs` cover:
- Basic routing (GET, POST, PUT, DELETE) - Basic routing, request bodies, headers, path parameters, status codes
- Request body echoing - Keep-alive and pipelining
- HTTP header parsing - Listener supervision (a panicking listener restarts without dropping a pending accept)
- Path parameter extraction - Graceful shutdown (idle close, in-flight drain, force-stop at the drain deadline)
- Status code responses - Timeouts (idle keep-alive reaping, slowloris-style slow headers/body)
- Registry names (`urus.server`, `urus.listener.{i}` resolve via `whereis`)
## Architecture ## Architecture
@@ -205,11 +261,10 @@ This avoids the complexity of async ecosystems while maintaining full concurrenc
## Roadmap ## Roadmap
v1 covers HTTP/1.1, the plug pipeline, and a built-in router. Future versions may include: See [`ROADMAP.md`](ROADMAP.md). Summary: v0.2 (supervised listener pool,
- Middleware ecosystem (auth, logging, compression, etc.). graceful shutdown, enforced timeouts, registry names) is done; next up are
- WebSocket support. streaming bodies + SSE (v0.3), WebSocket (v0.4), PubSub (v0.5), and
- Benchmarking suite (see `urus-bench-spec.md`). Phoenix-style channels (v0.6).
- Additional utilities and examples.
Refer to `urus-spec.md` and `urus-v1-build-notes.md` in the artifact persistence for the original design and implementation notes. Refer to `urus-spec.md` and `urus-v1-build-notes.md` in the artifact persistence for the original design and implementation notes.
+14 -4
View File
@@ -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` Adopt the primitives urus predates. No protocol surface changes; `Plug`/`Conn`
untouched. Chunks are independently landable, in order: 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 injected before `accept` so the pending connection must survive into the
restarted listener). 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()`, `serve_with` gains a shutdown signal (an `urus::Handle` with `.shutdown()`,
backed by a channel + `request_stop` fan-out): backed by a channel + `request_stop` fan-out):
1. Stop listeners (no new accepts; listener fds close). 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 (no activity stamps — chunk 3 went per-conn timed waits, no reaper); still
the future ws/channels introspection point, so build it once here. 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 **Decided: smarm-native timed waits** (the fork's option (a), a reaper in
urus, died when RFC 008 landed at `393cdd0``wait_readable_timeout` / urus, died when RFC 008 landed at `393cdd0``wait_readable_timeout` /
`wait_writable_timeout` exist upstream now, so there is nothing for the `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 reviewer flag (`wait_fd` wraps in `NoPreempt`, select arms don't); the
crud example under load doubles as that confirmation. 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` - Registry names: `urus.server`, `urus.listener.{n}` via `smarm::register`
(debuggability; `whereis` in tests). (debuggability; `whereis` in tests).
- README: rewrite Roadmap section to point here; document `Handle`. - README: rewrite Roadmap section to point here; document `Handle`.
+14
View File
@@ -312,6 +312,15 @@ pub fn serve_with_shutdown(
let sf = shutdown_flag.clone(); let sf = shutdown_flag.clone();
sup = sup.child(ChildSpec::new(Restart::Transient, move || { sup = sup.child(ChildSpec::new(Restart::Transient, move || {
println!("urus: listener {} starting", i); 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()); 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 // faster than that trips the cap and tears the pool down — loud
// failure over a zombie server. // failure over a zombie server.
let sup_h = smarm::spawn(move || sup.run()); 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` // Block until told to shut down. We poll `try_recv` + `sleep`
// rather than parking in `recv`: a smarm `Sender::send` from a // rather than parking in `recv`: a smarm `Sender::send` from a
+19
View File
@@ -89,6 +89,25 @@ fn hello_world() {
assert_eq!(http_body(&resp), b"hello urus"); 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] #[test]
fn echo_body() { fn echo_body() {
let pipe = Pipeline::new().plug( let pipe = Pipeline::new().plug(