Files
urus/README.md
T
Claude ad19848db3 feat(ws+pubsub): on_open hook + ws_chat — v0.5 chunk 2
WsHandler::on_open(&mut self, sender) — defaulted, non-breaking,
added mid-chunk for veto-by-diff (the v0.3 "push" precedent for
defaults-level decisions): without it a listen-only ws client can
never be subscribed, since on_message never fires for a client that
doesn't send. Runs exactly once, first, in the conn actor, before any
buffered pipelined frame is decoded. Panic contract identical to
on_message: check_cancelled re-raise dance, else 1011 and no
on_close.

DISCOVERY 1 (documented, not fixed — inherent): the 101 reaches the
client BEFORE on_open runs, so "is my subscription live yet" is a
race client-side. App-level ack is the answer (any reply proves
on_open completed; callbacks are sequential). The integration tests
hit this immediately (first read of a join notice flaked into a 5s
read-timeout) and use a sync/synced ack; same reason Phoenix joins
reply.

DISCOVERY 2 (the structural one): PubSub::new() is in-runtime only,
but pipelines are built pre-runtime. crud's static-OnceLock bootstrap
DOES NOT COMPOSE with serve_with_shutdown here: a static pins the
table actor's last ServerRef forever, its inbox never closes, the
gen_server never exits, AllDone is unreachable — serve hangs (the
cross-thread-unpark limitation closes the workaround routes). The
pattern that works: NON-static Arc<OnceLock<PubSub<M>>> captured by
the route closure. Drain stops conns+listeners -> last Arc<Pipeline>
drops IN-runtime -> cell+handle drop -> inbox closes -> table exits.
Corollary: relays hold the Receiver only, NEVER a PubSub clone
(relay holds table's inbox open, table holds relay's receiver open:
mutual keepalive, shutdown hangs). Both rules in module docs, README,
and enforced by the new shutdown_with_open_chat_terminates test:
two open chat sockets + live relays + live table, shutdown must
return within 5s.

examples/ws_chat.rs: rooms as topics (room:{name}), on_open
subscribes (conn-actor pid: the monitor scopes cleanup to the
session) + spawns the relay (rx -> WsSender clone; exits on prune or
WsClosed), on_message broadcast_from(self_pid()) so the speaker is
never echoed, on_close broadcasts the leave notice and relies on the
monitor for unsubscribe.

Integration: ws_chat_broadcast_reaches_other_client_not_sender
(no-echo proven orderingly, no timeout reads: B speaks after A, A's
next frame must be B's) + shutdown_with_open_chat_terminates.

hammer.sh default subset now includes ws_ (v0.4 ran it ad hoc; ws IS
conn-lifecycle). Hammered: 35x subset (incl. both new tests) + 3x
full + 1x full under smarm-trace, all green. dbg!-grep clean. smarm
PRISTINE. README pub/sub + on_open sections; ROADMAP v0.5 as-landed.

Suite: 67 unit + 42 integration + 2 doc.
2026-06-12 10:30:08 +00:00

426 lines
15 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
write_timeout: Duration::from_secs(30), // Per-write response budget
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. Covers the **read phase
only** — a streaming response may legitimately outlive any whole-request
clock.
- `write_timeout` — a per-write budget for response bytes: the fixed
head+body write, and *each* streamed chunk, must complete within it. A
client that stops reading is dropped once its socket buffer fills and a
write stalls past the budget (the write-side twin of slowloris). Streamed
chunks each get a fresh budget; a stream as a whole has no deadline.
### 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).
### Streaming Responses
A handler can return a body it doesn't have yet: hand urus the read end of
a channel and feed it from a producer actor (the conn actor owns the
socket and all write deadlines; the producer never touches the fd):
```rust
use urus::{Conn, Next};
|c: Conn, _n: Next| {
let (tx, rx) = smarm::channel::<Vec<u8>>();
smarm::spawn(move || {
for part in ["chunk one", "chunk two"] {
if tx.send(part.as_bytes().to_vec()).is_err() {
return; // connection died — stop producing
}
}
// tx drops: end of stream.
});
c.put_status(200).put_body(rx)
}
```
On HTTP/1.1 the response uses `Transfer-Encoding: chunked` and the
connection is reusable afterwards; on HTTP/1.0 (no chunked framing) bytes
go out raw and the connection closes to delimit the body. End of stream is
signalled by dropping every `Sender`. The producer contract: a `send`
error means the connection is gone — exit. Chunked **request** bodies are
decoded transparently; handlers see `conn.body` either way.
### Server-Sent Events
`Conn::sse()` is the streaming body with the SSE headers and a heartbeat
wired up:
```rust
|c: Conn, _n: Next| {
let (c, events) = c.sse(); // or c.sse_with_heartbeat(interval)
smarm::spawn(move || {
loop {
if events.send("tick", "data").is_err() {
return; // SseClosed: client gone or server draining
}
smarm::sleep(std::time::Duration::from_secs(1));
}
});
c
}
```
While the producer is silent the connection actor emits `: keep-alive`
comment chunks every `HEARTBEAT_INTERVAL` (15s default). Liveness comes
from that ping: a vanished client is detected when a write stalls past
`write_timeout`. No request clock applies to an open SSE stream, and a
graceful shutdown force-stops it at the drain deadline.
### 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.
### WebSocket (v0.4)
A route handler accepts the upgrade by handing `Conn::upgrade` a
`WsHandler`; after the `101` the connection actor leaves HTTP and drives
the handler from a duplex select loop (smarm RFC 008 fd arms: first-of
outbound-channel / fd-readable, one actor, no fd dup):
```rust
struct Echo;
impl urus::WsHandler for Echo {
fn on_message(&mut self, msg: urus::Message, sender: &urus::WsSender) {
let _ = sender.send(msg); // or .text(..) / .binary(..) / .ping() / .close(code, reason)
}
fn on_close(&mut self, code: Option<u16>, reason: &str) {
// what the PEER said: Some(code) iff its close frame arrived;
// None for EOF / write failure / handshake timeout.
let _ = (code, reason);
}
}
Router::new().get("/echo", |c: Conn, _n: Next| c.upgrade(Echo))
```
Callbacks run **inside the connection actor** — a slow `on_message`
stops reads, which is backpressure, not a bug. A handler that wants
concurrency spawns its own actor and hands it a `WsSender` clone (the
SSE-producer pattern); every `WsSender` method returns `Err(WsClosed)`
once the connection ends. While a large outbound frame is mid-write the
actor isn't reading — accepted v1 trade-off of the one-actor topology.
Control frames are invisible to the handler in v1: pings are
auto-ponged, a peer close is auto-echoed and surfaces as `on_close`.
Protocol violations close with the mapped RFC 6455 code (1002/1009/1007;
handler panic → 1011). Caps: `Config.max_frame_payload` (1 MiB default,
enforced from the frame header before payload buffers) and
`Config.max_message_bytes` (4 MiB default, spans fragments).
Like SSE, an open WebSocket has no request clock: idle is fine, a dead
client is caught when a write stalls past `write_timeout`, and graceful
shutdown force-stops the connection at the drain deadline. See
`examples/ws_echo.rs`.
The `WsHandler` trait also has a defaulted `on_open(&mut self, sender)`,
called once before the frame loop — the place to subscribe or spawn
producers for clients that may never send. Note the client sees the 101
*before* `on_open` runs; a client that must know its subscriptions are
live uses an application-level ack (any reply proves `on_open` completed,
callbacks being sequential).
## Pub/Sub (v0.5)
`urus::pubsub` is a local-node, phoenix_pubsub-shaped topic broadcaster —
independent of HTTP (it imports only smarm) and built for the WebSocket
relay pattern:
```rust
let bus: PubSub<String> = PubSub::new(); // in-runtime only!
let rx = bus.subscribe("room:lobby")?; // Receiver<Arc<String>>
bus.broadcast("room:lobby", "hi".to_string())?;
bus.broadcast_from(smarm::self_pid(), "room:lobby", "no echo".into())?;
```
One generic instance per message domain; payloads broadcast as `Arc<M>`
(one allocation per broadcast). `subscribe` is idempotent per
`(pid, topic)` and pins the subscription to the **calling actor** — its
death prunes the entry via a monitor; a dropped `Receiver` is pruned
lazily on the next broadcast. `subscribe_as(pid, topic)` pins to an
explicit pid for relay patterns. Mailboxes are unbounded: `broadcast`
never blocks the table, and a slow subscriber's memory bill is bounded
by the two cleanup paths above.
Two composition rules that matter (both enforced by
`shutdown_with_open_chat_terminates` in the integration suite):
1. `PubSub::new()` spawns an actor, so it must run in-runtime — build it
lazily from a handler via a **non-static** `Arc<OnceLock<PubSub<M>>>`
captured by the route closure. A `static` cell pins the table actor
forever and graceful shutdown never returns.
2. Relay/producer actors hold the `Receiver` (plus e.g. a `WsSender`
clone) — **never a `PubSub` clone**, or relay and table keep each
other alive past shutdown.
See [`examples/ws_chat.rs`](examples/ws_chat.rs): rooms as topics,
`on_open` subscribes + spawns the relay, `on_message` uses
`broadcast_from` so the speaker isn't echoed.
## 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), v0.3 (streaming
bodies, chunked requests, SSE) and v0.4 (WebSocket: handshake, frame
codec, duplex handler API) are done; next up are 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