Claude a60687bfec feat(conn): keep-alive and per-request read timeouts (v0.2 chunk 3)
Two wall-clock budgets in the conn read path, both via
smarm::wait_readable_timeout:

- keep_alive_timeout: idle budget between requests — how long we park
  waiting for the FIRST byte of a request (including the first request on
  a fresh connection). Expiry closes silently; nothing is owed to a
  client that isn't talking.
- request_timeout: per-request budget as an Instant deadline from the
  first byte of a request until the request (head + body) is fully read.
  Pipelined leftovers in the buffer at cycle start count as a started
  request. The deadline is returned from read_head and threaded into
  read_body so head and body share one budget. Pipeline run time is NOT
  covered. Each read wait uses whichever budget is currently active;
  deadlines are Instants, so EAGAIN retries don't reset the clock.

Expiry mid-head gets a best-effort 408 via try_write_once: a single
non-parking write syscall — a client that stalls its read side must not
defeat the timeout by making the 408 write park forever. Expiry mid-body
just closes.

examples/crud.rs gains stdin-Enter graceful shutdown (plain OS thread on
read_line -> handle.shutdown(); no signal crate). Doing so surfaced a
pattern worth knowing: crud's lazily-spawned store actor parked forever
in recv(), which blocks smarm's AllDone, so serve_with_shutdown never
returned (gdb: scheduler idle in poll_wake, store the only live actor).
It can't be messaged awake from the stdin thread either — a cross-thread
send's unpark is a no-op without runtime TLS, the same limitation behind
SHUTDOWN_POLL. Fix: store_loop recv_timeout(250ms) + a SHUTTING_DOWN
AtomicBool set by the stdin thread before handle.shutdown(). This poll
dies with the cross-thread-unpark limitation; recorded in smarm docs.

Tests: idle keep-alive conn reaped at a small keep_alive_timeout;
slowloris partial-head stall killed at request_timeout with the 408
observed. Timeout+shutdown subset hammered 30x clean; full suite 3x;
smarm-trace build and suite clean.
2026-06-11 22:07:18 +00:00
2026-05-26 23:16:45 +02:00
2026-05-26 23:23:11 +02:00

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::net::SocketAddr;

let cfg = Config {
    listener_pool:     2,              // Number of OS threads handling accept()
    scheduler_threads: Some(2),        // Number of smarm worker threads
    ..Config::new("127.0.0.1:8080".parse().unwrap())
};

serve_with(cfg, pipeline).unwrap();

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 (GET, POST, PUT, DELETE)
  • Request body echoing
  • HTTP header parsing
  • Path parameter extraction
  • Status code responses

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

v1 covers HTTP/1.1, the plug pipeline, and a built-in router. Future versions may include:

  • Middleware ecosystem (auth, logging, compression, etc.).
  • WebSocket support.
  • Benchmarking suite (see urus-bench-spec.md).
  • 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.

License

MIT

S
Description
No description provided
Readme
351 KiB
Languages
Rust 96.5%
Shell 3.5%