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.
11 KiB
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.5–v0.8 runtime rework unchanged. - Timeouts are decorative.
Config::{request_timeout, keep_alive_timeout}are threaded intoConnLimitsand never read.read_someparks inwait_readablewith 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/ChildSpecand uses barespawn+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 inwait_readablesafe. TheOwnedFd-drops-on-unwind discipline inconn_actor.rsalready composes with this. - No streaming response path.
RespBodyisEmpty | 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 asErrvia thetry_selecttwins) and one direction per parked actor. RFC 008 phase 1 (landed393cdd0) 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):
- Stop listeners (no new accepts; listener fds close).
- Decided: drain-then-stop with a deadline — in-flight requests get
to finish;
request_stopafter the deadline (unwind closes fds cleanly viaOwnedFd::drop). Drain also keeps the door open for an apache-reload-style restart later. - Supervisor winds down;
rt.runreturns;serve_withreturns.
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 393cdd0 — wait_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-reads — keep_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}viasmarm::register(debuggability;whereisin tests). - README: rewrite Roadmap section to point here; document
Handle. smarm-tracefeature 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").
RespBody::Stream(...)— handler hands back a writer callback or aReceiver<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.- Chunked request bodies (
parser.rscurrently returns 411) — same cycle, the codec knowledge is fresh. - SSE sugar:
Conn::sse()→ anEventSender(.send(event, data)), setscontent-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, notrequest_timeout.
v0.4 — WebSocket upgrade path
- Handshake: detect
Connection: Upgrade+Upgrade: websocketin the pipeline;Conn::upgrade(handler)short-circuits — conn actor validatesSec-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.) - 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.
- 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 fallibletry_selecttwins (registration errors, incl.AlreadyExists, surface asErrand 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,dupremains the documented answer for true simultaneous read+write waits.
- (a) Two actors, dup'd fd: reader actor on the dup, writer actor
on the original, writer owns a
- Handler API sketch (settle in design pass): trait with
on_message/on_close+ aWsSenderhandle — 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_serverhandle_downwas 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.
Channeltrait: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 suite —
urus-bench-spec.mdexists in the artefact store; wire it up once v0.2 lands (supervision changes the hot path not at all, but prove it).