Files
urus/README.md
T
Claude ffc579c500 feat(ws): duplex wiring + handler API + echo example (v0.4 chunk 3)
Topology (b) as agreed: ONE actor per connection via smarm RFC 008 fd
arms. After the 101, ws::duplex::run_duplex replaces the HTTP loop:
try_select(&[&outbound_rx, &FdArm::readable(fd)]) per iteration,
outbound at index 0 (priority order = owed writes drain before reads;
honest backpressure). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.

Handler API (deferred questions, as answered this session):
- WsHandler { on_message, on_close } runs IN the conn actor's select
  loop; concurrency = spawn an actor with a WsSender clone (the SSE
  producer pattern). Conn::upgrade(self, handler) flat signature;
  WsUpgrade grows from marker to Box<dyn WsHandler> payload.
- WsSender: Clone; send/text/binary/ping/close -> Err(WsClosed).
  Unbounded channel like SSE; slow client bounded by write_timeout.
- Control frames invisible v1: auto-pong inline (5.5.2), peer close
  auto-echoed (5.5.1; server closes TCP first per 7.1.1) surfacing
  only as on_close(Some(code), reason); wordless endings (EOF, write
  failure, drain timeout) = on_close(None, ""). on_ping/on_pong can
  land later as default methods, non-breaking.
- Server-initiated close (WsSender::close, FrameError->1002/1009/1007,
  handler panic->1011): close out, bounded drain (one write_timeout
  budget) for the peer echo, data discarded (1.4). Handler panics
  re-raise smarm's stop sentinel first (the pipeline catch_unwind
  dance, replicated).
- Caps: Config.max_frame_payload (1 MiB) / max_message_bytes (4 MiB).

Conn actor: 101 branch now hands fd + leftover buf (pipelined first
frame carries over; tested) + boxed handler to run_duplex; registry
entry stays Busy for the ws lifetime, so graceful shutdown force-stops
the conn out of the select park at the drain deadline (tested — the
in-runtime request_stop wake covers select parks too).

Tests: chunk-1 EOF test rewritten into a 9-test duplex suite (echo,
pipelined first frame, ping/pong, both close directions, 1002 unmasked,
1009 header-cap, fragmentation with interleaved ping, shutdown
force-stop). Suite 59u+40i+2d. Hammer: 35x lifecycle+ws subset + 3 full
+ 1 full under smarm-trace, all green; no AlreadyExists out of
try_select (the fresh eager-cleanup path held). Validated against
python websocket-client (echo, pong payload, close 1000).
examples/ws_echo.rs: /echo in-actor + /clock producer-spawning.
2026-06-12 09:44:35 +00:00

13 KiB

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 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

[dependencies]
urus = { path = "../urus" }
smarm = { path = "../smarm" }

Minimal Example

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:

cargo run --example hello
curl http://localhost:8080/

Routing with Path Parameters

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:

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:

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:

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:

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):

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):

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):

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:

|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):

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.

Examples

CRUD with Actor Ownership

See 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:

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:

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. 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