Files
urus/README.md
T
Claude 8065ea561e 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.
2026-06-11 22:24:02 +00:00

274 lines
8.8 KiB
Markdown

# urus
A cowboy/bandit-style HTTP library for the smarm actor runtime.
## Overview
`urus` is a lightweight, actor-first HTTP/1.1 library designed to integrate seamlessly with the [smarm](https://github.com/Markk116/smarm) actor runtime. Instead of traditional shared mutable state and locking patterns, `urus` embraces the actor model: connection handling is fully concurrent, and request processing pipelines are message-passing all the way down.
**Key design principles:**
- **No locks**: State is owned by actors; concurrency is via channels.
- **Actor-native**: Built from the ground up for smarm; each connection is an actor.
- **Pluggable pipelines**: Compose HTTP request handling logic with `Plug` and `Pipeline`.
- **Fast HTTP/1.1 parsing**: Uses `httparse` for robust, battle-tested RFC 7230 compliance.
## Quick Start
```toml
[dependencies]
urus = { path = "../urus" }
smarm = { path = "../smarm" }
```
### Minimal Example
```rust
use urus::{Pipeline, Router, Conn, Next, serve};
let pipeline = Pipeline::new().plug(
Router::new()
.get("/", |c: Conn, _n: Next| {
c.put_status(200).put_body("hello")
})
);
serve("0.0.0.0:8080", pipeline).unwrap();
```
Then:
```bash
cargo run --example hello
curl http://localhost:8080/
```
### Routing with Path Parameters
```rust
Router::new()
.get("/users/:id", |c: Conn, _n: Next| {
let id = c.params.get("id").unwrap_or("").to_string();
c.put_status(200).put_body(format!("user: {}", id))
})
```
Path parameters are extracted into `c.params: HashMap<String, String>`.
## Core Concepts
### `Conn` — The Connection Object
The `Conn` struct represents a single HTTP request/response pair:
```rust
pub struct Conn {
pub method: Method,
pub path: String,
pub params: HashMap<String, String>, // Path parameters (e.g., :id)
pub assigns: Assigns, // Request-scoped state
pub body: Body, // Request body
pub headers: HeaderMap, // Request headers
pub status: u16, // Response status (default 200)
pub resp_headers: HeaderMap, // Response headers
pub resp_body: RespBody, // Response body
// ... (internal fields)
}
```
Builders make it ergonomic to modify response state:
```rust
c.put_status(201)
.put_header("content-type", "application/json")
.put_body(r#"{"ok": true}"#)
```
`c.assigns` is a request-scoped key-value store (similar to Plug's Assigns in Elixir/Phoenix) for passing data between plugs in a pipeline.
### `Pipeline` — Composable Handlers
A pipeline chains plugs (middleware/handlers) together. Each plug receives a `Conn`, can modify it, and passes it to the next plug via the `Next` callback:
```rust
use urus::{Pipeline, Plug, Conn, Next};
struct LoggingPlug;
impl Plug for LoggingPlug {
fn call(&self, mut c: Conn, n: Next) -> Conn {
println!("{} {}", c.method, c.path);
n.call(c)
}
}
let pipeline = Pipeline::new()
.plug(LoggingPlug)
.plug(Router::new().get("/", |c, _| c.put_body("ok")));
```
### `Router` — Path Matching
The built-in router matches HTTP methods and paths:
```rust
Router::new()
.get("/", handler_fn)
.post("/users", create_user)
.get("/users/:id", get_user)
.put("/users/:id", update_user)
.delete("/users/:id", delete_user)
```
Handlers are closures taking `(Conn, Next) -> Conn`. Call `Next::call(c)` to continue to the next plug; omitting it short-circuits the pipeline (e.g., for authentication failures).
### Server Configuration
`serve(addr, pipeline)` binds and listens on the given address. For more control, use `serve_with(config, pipeline)`:
```rust
use urus::{serve_with, Config};
use std::time::Duration;
let cfg = Config {
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
See [`examples/crud.rs`](examples/crud.rs) for a complete example demonstrating:
- A background "store" actor that owns all data.
- Handlers sending requests to the store via a channel.
- No locks, no shared mutable state.
- Automatic JSON serialization and file persistence.
Run it:
```bash
cargo run --example crud
curl -s http://localhost:8080/users
curl -s -X POST -d '{"name":"alice","email":"a@x"}' http://localhost:8080/users
curl -s http://localhost:8080/users/1
```
## Testing
Integration tests spawn the server on an ephemeral port and issue real TCP requests:
```bash
cargo test
```
Tests in `tests/integration.rs` cover:
- 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
### Connection Actors
Each incoming TCP connection is handled by a dedicated actor (spawned in `conn_actor.rs`). The actor:
1. Parses the HTTP request line and headers.
2. Reads the body (if present).
3. Runs the request through the pipeline.
4. Writes the HTTP response to the socket.
5. Closes the connection (or handles pipelining if HTTP/1.1 Keep-Alive is enabled).
All of this happens concurrently with other connections—no thread pool juggling required. The smarm scheduler handles actor fairness.
### Parsing
HTTP request parsing uses the robust `httparse` crate, which handles:
- Chunked transfer encoding (for request bodies).
- Header validation.
- Method and URI parsing.
- HTTP version detection.
### No Async/Await
`urus` uses **synchronous code with blocking channels**. This is intentional:
- Simpler to reason about; no complex state machines.
- Each actor runs in a smarm worker thread, blocked on I/O or channels.
- The scheduler multiplexes many actors across a thread pool.
This avoids the complexity of async ecosystems while maintaining full concurrency.
## Roadmap
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.
## License
MIT