Files
urus/ROADMAP.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

11 KiB
Raw Blame History

urus — Roadmap

Companion to urus-spec.md (artefact store, URUS project). v1 shipped 2026-05-26, built against pre-v0.4 smarm — before cancellation, supervisors, links, gen_server, registry, select, and the slot-slab runtime existed. smarm is now at v0.8. This roadmap covers catching up, then extending.

State assessment (2026-06-11)

Verified against smarm master (393cdd0: v0.8 slot slab + park-epoch + select, plus RFC 008 phase 1 fd arms / timed fd waits):

  • Builds clean, 24/24 tests pass. The scheduler surface urus consumes (spawn, wait_readable, wait_writable, sleep, init, Config, run, JoinHandle) survived the v0.5v0.8 runtime rework unchanged.
  • Timeouts are decorative. Config::{request_timeout, keep_alive_timeout} are threaded into ConnLimits and never read. read_some parks in wait_readable with no deadline → an idle keep-alive connection pins its actor (and its 64 KiB+ of buffers) forever. Slowloris-shaped.
  • No supervision. Spec §2.1 calls for a supervised listener pool; v1 predates Strategy/ChildSpec and uses bare spawn + join. A panicking listener silently shrinks the accept pool.
  • No graceful shutdown. Spec deferred it; smarm now has the pieces: request_stop + sentinel unwind, and the 06-10 io fix (deregister a stopped actor's fd wait on the unwind path) makes stopping an actor parked in wait_readable safe. The OwnedFd-drops-on-unwind discipline in conn_actor.rs already composes with this.
  • No streaming response path. RespBody is Empty | Bytes. Blocks SSE (a spec v1 goal that slipped) and chunked responses.
  • Full-duplex constraint (matters for WebSocket). smarm's epoll model is one waiter per RawFd (AlreadyExists, now surfaced as Err via the try_select twins) and one direction per parked actor. RFC 008 phase 1 (landed 393cdd0) gives first-of(fd, channel) via fd arms in select — the duplex options in v0.4-3 are both open now. Decide in v0.4 design, before the frame codec.

v0.2 — OTP refresh (landed 2026-06, chunks 1-4 / commits 5fe6969..HEAD)

Adopt the primitives urus predates. No protocol surface changes; Plug/Conn untouched. Chunks are independently landable, in order:

1. Supervised listener pool

Landed: serve_with runs a Strategy::OneForOne supervisor on the root actor, one ChildSpec per dup'd listener fd, Restart::Permanent. A listener panic (or a transient wait_readable failure, which now exits and restarts instead of being fatal) restarts that listener; the pool never silently shrinks. Connection actors stay unsupervised bare spawns (per spec: a connection is cheap and its failure is local — a 500 path, not a restart path); spawn parents them under the listener, which has no supervisor channel, so their deaths are invisible to the pool supervisor by construction.

Open question resolved as leaned: the ChildSpec factory owns the fd via Arc<OwnedFd> (OwnedFd gained Sync for this) and every (re)start reuses it. No re-dup, no window where a pool slot has no fd. Covered by tests/integration.rs::panicking_listener_restarts (pool of 1, panic injected before accept so the pending connection must survive into the restarted listener).

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(), backed by a channel + request_stop fan-out):

  1. Stop listeners (no new accepts; listener fds close).
  2. Decided: drain-then-stop with a deadline — in-flight requests get to finish; request_stop after the deadline (unwind closes fds cleanly via OwnedFd::drop). Drain also keeps the door open for an apache-reload-style restart later.
  3. Supervisor winds down; rt.run returns; serve_with returns.

Requires tracking live connection pids. Cheapest honest version: a gen_server connection registry — listener casts {Started, pid}, conn 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 the future ws/channels introspection point, so build it once here.

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 urus, died when RFC 008 landed at 393cdd0wait_readable_timeout / wait_writable_timeout exist upstream now, so there is nothing for the reaper to be the stopgap for):

read_some parks in wait_readable_timeout instead of wait_readable; Ok(false) means the deadline passed and the conn actor closes. Per-conn semantics, no activity stamping, no sweep ticker. Semantics note: a timed read wait enforces idle-between-readskeep_alive_timeout between requests, and slowloris dies because each stalled read has a deadline. request_timeout as a whole-request wall clock is NOT what this gives; decide in implementation whether to track a per-request deadline across reads (cheap: one Instant in the conn loop, pass the min of both budgets to each wait) or redefine request_timeout as per-read. Leaning the Instant: it keeps the advertised config honest.

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 crud example under load doubles as that confirmation.

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 (debuggability; whereis in tests).
  • README: rewrite Roadmap section to point here; document Handle.
  • smarm-trace feature passthrough already exists — verify it still compiles under --features smarm-trace.

Exit criteria: kill -TERM equivalent drains cleanly in the crud example; an idle keep-alive conn is reaped at keep_alive_timeout; a deliberately panicking listener restarts under load without dropped accepts (test each).


v0.3 — Streaming bodies + SSE

The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection is "a response that never ends").

  1. RespBody::Stream(...) — handler hands back a writer callback or a Receiver<Vec<u8>>; conn actor switches to chunked transfer-encoding (HTTP/1.1) and pumps until the stream closes. Keep-alive after a completed chunked response.
  2. Chunked request bodies (parser.rs currently returns 411) — same cycle, the codec knowledge is fresh.
  3. SSE sugar: Conn::sse() → an EventSender (.send(event, data)), sets content-type: text/event-stream, disables buffering, heartbeats via comment lines on a timer. An SSE conn is just a Stream body with the per-read deadline disabled (or set to the heartbeat interval) — liveness comes from the keep-alive ping, not request_timeout.

v0.4 — WebSocket upgrade path

  1. Handshake: detect Connection: Upgrade + Upgrade: websocket in the pipeline; Conn::upgrade(handler) short-circuits — conn actor validates Sec-WebSocket-Key, emits 101, exits the HTTP loop. (SHA-1 + base64 needed; vendor a tiny SHA-1 rather than pulling a crate — urus has two deps and it should stay that way.)
  2. Frame codec: RFC 6455 framing — fragmentation, masking (client frames are always masked), control frames (ping/pong/close), 125-byte control-payload rule, close-code handshake.
  3. Duplex topology (the design decision from the assessment):
    • (a) Two actors, dup'd fd: reader actor on the dup, writer actor on the original, writer owns a Receiver<Frame>; the user handler talks to both via channels. Proven trick (listener pool), works today.
    • (b) One actor, smarm first-of(fd-readable, channel): fd arms in select landed with RFC 008 (393cdd0) — no longer blocked. Simpler topology, no dup; use the fallible try_select twins (registration errors, incl. AlreadyExists, surface as Err and the wait fully retires). Was "default (a), revisit if (b) lands" — (b) landed, so this is a live choice for the v0.4 design pass. Note RFC 008 is phase 1: single-waiter-per-fd stands, dup remains the documented answer for true simultaneous read+write waits.
  4. Handler API sketch (settle in design pass): trait with on_message/on_close + a WsSender handle — gen_server-shaped, so a ws connection is an actor you can monitor, link, and register.

v0.5 — PubSub

urus::pubsub, phoenix_pubsub-shaped, local node only (no distribution — smarm has none):

  • subscribe(topic), unsubscribe(topic), broadcast(topic, msg), broadcast_from(self, topic, msg).
  • Topic table: gen_server owning HashMap<Topic, Vec<(Pid, Sender<Arc<M>>)>>; shard N ways by topic hash if the single server measures hot (don't pre-shard; bench first — smarm channel sends are cheap).
  • Dead subscriber cleanup via monitor + handle_down (this is exactly what 06-10's gen_server handle_down was built for).
  • Arc<M> payloads: one allocation per broadcast, not per subscriber — the shared-heap advantage over BEAM, take it.

Deliberately independent of HTTP — usable from any smarm app. Separate module now, candidate for crate extraction later.

v0.6 — Channels

Phoenix channels: the join/leave/event protocol over WebSocket transport, PubSub underneath.

  • Channel trait: join(topic, payload, socket), handle_in(event, payload, socket), handle_out, terminate.
  • Topic router ("room:*" patterns) → channel actor per (conn, topic), spawned under the ws connection, linked so conn death reaps channels.
  • Wire format: Phoenix V2 JSON serializer ([join_ref, ref, topic, event, payload]) — free interop with phoenix.js clients is the killer feature; needs a JSON dep or a hand-rolled mini-codec (decision point — this would be dep #3).
  • Presence: explicitly out of scope until distribution exists somewhere.

Later / icebox

  • HTTP/2 — spec §2.2 already designs the stream-actor demux (zero-copy ownership transfer, conn actor owns HPACK + flow control, stream actors run the pipeline). Big: HPACK, flow control, priorities. The Stressgrid constraint (no stream-actor cost on HTTP/1.1) stays law.
  • SSE ergonomics round 2 — auto-reconnect support (Last-Event-ID), event-id bookkeeping, backpressure policy on slow consumers.
  • TLS — still deferred; acceptor design still must not preclude it.
  • Middleware ecosystem — compression, static files, auth plugs.
  • Bench suiteurus-bench-spec.md exists in the artefact store; wire it up once v0.2 lands (supervision changes the hot path not at all, but prove it).