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:
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user