- gen_server rename (RFC 015, 3e31606): ServerRef/ServerCtx/ServerBuilder
-> GenServerRef/GenServerCtx/GenServerBuilder across conn_actor,
conn_registry, serve, pubsub, channels::session.
- type Timer = () on the three GenServer impls (RFC 015, 57eadb5);
handle_timer/handle_idle/tick_every stay defaulted — opt-in later.
- Watcher and GenServerCtx are now generic over the server type: watcher
fields typed Watcher<Table<M>> / Watcher<Registry<P, K>>; Registry's
struct bounds strengthened to its GenServer impl bounds
(P: Encode + Decode + Send + Sync + 'static, K: SessionKey) so the
Watcher field's G: GenServer bound is satisfiable at the declaration.
- RFC 014 (a866e34) registry: register is (Name<M>, Sender<M>), self-only
— a name is a typed messaging endpoint, not a pid tag. The
introspection-only urus.server / urus.listener.{i} bindings are dropped
rather than faked with unit channels; the whereis integration test is
deleted; a proper messageable-name design is icebox'd in ROADMAP.md.
- Audit vs smarm 6c2b7e9 (queued messages dropped when Receiver drops):
pubsub's prune-on-send-failure retain still holds — send Errs once
receiver_alive is false, so a dropped rx prunes on next broadcast,
exactly what subscriber_count's doc already promised. Freeing stranded
Arc<M> broadcasts is strictly good. No change needed.
- The 7x E0283 in channels/mod.rs were cascade fallout of the generics
changes; dissolved with the port, as discovery predicted.
Suite: 90 lib + 45 integration + 2 doc, green under default, smarm-trace,
phoenix, and all-features. 3 pre-existing clippy lints (conn.rs,
parser.rs, conn_actor.rs; clippy 1.97 strictness) deferred to a follow-up
chore commit to keep this diff pure.
21 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 ✅ (landed 2026-06, chunks 1-3)
The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection is "a response that never ends").
RespBody::Stream(StreamBody)✅ — decided: pull (user call at the v0.3 design fork): the handler hands back asmarm::Receiver<Vec<u8>>and the producer is an actor the handler spawned; the conn actor pumps (pump_stream) and keeps sole ownership of the socket and write deadlines. The recv() park between chunks is stoppable by the draining registry, so infinite streams die at the drain deadline with no polling. HTTP/1.1: chunked TE, keep-alive after the terminator; HTTP/1.0: raw bytes, forced close, EOF-delimited. End of stream = every Sender dropped = 0-chunk. Timeout split (handoff Q3, as proposed):request_timeoutcovers the READ phase only; newConfig.write_timeout(default 30s) bounds every response write — fresh budget per streamed chunk, and the fixed-body + error-response writes went deadline-bounded too (write-side slowloris closed).- Chunked request bodies ✅ —
read_chunked_bodydecodes incrementally on the request deadline; extensions ignored, trailers consumed + discarded, decoded size capped at max_body_bytes (413), size-line/trailer spam capped (400). CL + TE: chunked, or chunked on HTTP/1.0, rejected 400 at parse (smuggling ambiguity). Keep-alive drain accounts RAW framing length, so pipelined requests behind a chunked body land exactly. - SSE sugar ✅ —
Conn::sse()/sse_with_heartbeat→EventSender(send(event, data)/data/comment, Clone,Err(SseClosed)once the stream is gone). Heartbeat lives in the pump as leaned: withStreamBody.heartbeatset, the chunk wait isrecv_timeout(interval)and expiry writes a: keep-alivecomment chunk. Liveness from the ping (dead client = heartbeat write stalls past write_timeout), not from any request clock.
Exit criteria (verified): chunked round-trips both directions with keep-alive and pipelining intact; an infinite SSE stream heartbeats while silent, is killed by write_timeout when the client stops reading, and is force-stopped at the drain deadline on shutdown; full suite green.
v0.4 — WebSocket upgrade path ✅ (landed 2026-06, chunks 1-3)
- Handshake ✅ (
49538cc) —Conn::upgrade()(handler arg comes with chunk 3): §4.2.1 validation, 101 + accept key, marker field onConn, actor leaves the HTTP loop (fd drops until chunk 3 takes over). Rejections stay plain HTTP (400 / 426 + version header) with keep-alive intact. Dep decision changed at the design pass: SHA-1 issha1_smol(zero transitive deps) — dep #3 SPENT, no hand-rolled crypto (user call). The v0.6 JSON question is therefore a dep #4 question now. Base64 stays in-tree, encode-only. - Frame codec ✅ (
2869153) — purews::frame: incrementaldecode(mask-required server mode), minimal-length + RSV + control rules enforced, header-derived cap check before payload buffering, close-code wire validation,Assemblerfor fragmentation with whole-message UTF-8 check,FrameError→close-code map (1002/1009/1007). §5.7 examples pinned verbatim. - Duplex wiring ✅ — topology decision: (b) one actor, RFC 008
fd arms (urus exists to put smarm through its paces; first fd-arm
consumer is the point). As landed:
ws::duplex::run_duplexreplaces the HTTP loop after the 101:try_select(&[&outbound_rx, &FdArm::readable(fd)])per iteration — outbound at index 0 (select is priority-ordered: owed writes drain before reading more). Accepted gap documented: mid-write of a large outbound frame the actor isn't reading.- Handler API (the deferred questions, as answered): in-actor
callbacks —
WsHandler { on_message, on_close }runs inside the select loop; concurrency = spawn your own actor with aWsSenderclone (the SSE-producer pattern).Conn::upgrade(self, handler)flat signature (builder deferred until per-upgrade options exist, e.g. subprotocols).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 ason_close(Some(code), reason); wordless endings (EOF, write failure, handshake timeout) =on_close(None, "").on_ping/on_pongcan land later as default trait methods, non-breaking. - Server-initiated close (WsSender::close or FrameError→1002/1009/ 1007, handler panic→1011): close frame out, then a bounded drain (one write_timeout budget) for the peer's echo, discarding data (§1.4). Handler panics re-raise smarm's stop sentinel before being treated as panics — the pipeline's catch_unwind dance, replicated.
- Caps:
Config.max_frame_payload(1 MiB) /max_message_bytes(4 MiB) — Config fields per the max_body_bytes precedent; header cap checked before payload buffers. - Buf handover: bytes pipelined past the upgrade head carry into the duplex loop (tested).
- Registry: conn stays Busy for the ws lifetime (an open ws is in-flight work); graceful shutdown force-stops it out of the select park at the drain deadline (tested — the in-runtime request_stop wake covers select parks, not just channel recv).
- Validated against a real client (python websocket-client): echo,
ping/pong payload, close handshake.
examples/ws_echo.rsshows both handler shapes (/echo in-actor, /clock producer-spawning).
- Handler API ✅ — folded into chunk 3 above (was: settle in design pass; settled: gen_server-shaped trait + WsSender handle).
v0.5 — PubSub ✅ (landed 2026-06, chunks 1-2)
urus::pubsub, phoenix_pubsub-shaped, local node only (no distribution —
smarm has none). All six design questions ratified by the user pre-code
("push" on the stated leans); as-landed:
- Generic
PubSub<M>per instance (Q1); heterogeneous buses are an app-side enum.Arc<M>payloads: one allocation per broadcast. subscribe(topic) -> Receiver<Arc<M>>(Q2), fresh channel per subscription;subscribe_as(pid, ..)pins lifetime to an explicit pid for relay shapes. Sender-passing fan-in (subscribe_with) deliberately deferred — compatible addition.- Unbounded mailboxes (Q3): broadcast never blocks the table. Cleanup
is monitor
Down(eager, on subscriber death) + prune-on-send-failure (lazy, dropped receivers). Bench before sharding. - User-owned handle (Q4). The ratified register-by-name helper turned
out NOT implementable: smarm's registry maps name → Pid and a Pid can't
be turned back into a
ServerRef. Deferred pending smarm support (or a type-erased global, rejected).pid()exposed. - Unique per
(pid, topic)(Q5): subscribe idempotent (replaces the sender; the stale receiver closes). One monitor per live pid.handle_downis a full table scan as ratified; pid→topics reverse index is the documented optimization if deaths measure hot. - Table: gen_server
HashMap<Topic, HashMap<Pid, Sender<Arc<M>>>>— single server, not sharded (bench first).subscriber_count(topic)added (tests need to observe async cleanup; legitimate API).
Discoveries this cycle:
PubSub::new()is in-runtime only (spawns the table actor) but pipelines are built pre-runtime → the composition pattern is a NON-staticArc<OnceLock<PubSub<M>>>in the route closure. Non-static is load-bearing: the drained pipeline drops the cell in-runtime, the table's inbox closes, the table exits, AllDone is reachable. Astatic(crud's store shape) hangsserve_with_shutdown. Corollary: relays holdReceiveronly, never aPubSubclone (mutual-keepalive cycle). Proven byshutdown_with_open_chat_terminates.WsHandler::on_open(&mut self, sender)added (defaulted, non-breaking): without it a listen-only ws client can never be subscribed (on_messagenever fires). Same panic contract ason_message(1011, noon_close). The 101 reaches the client beforeon_openruns — subscription liveness needs an app-level ack (documented; the integration tests use async/syncedack).- hammer.sh default subset now includes
ws_.
Chunk 2's ws_chat: rooms as topics, on_open subscribes (conn-actor
pid) + spawns the relay, broadcast_from(self_pid(), ..) for no-echo.
v0.6 — Channels — DONE (2026-06-12)
Join/leave/event protocol over WebSocket transport, PubSub underneath. Design decisions ratified pre-code (2026-06-12):
Feature flags. Two additive features:
"channels"— theChanneltrait,TopicRoutertrait,PrefixRouter,ChannelSocket, session machinery. Zero new deps; pubsub is already always-on (it only exists because channels needs it)."phoenix"— implies"channels"+serde/serde_json+ the V2 frame codec. Opt-in phoenix.js interop; users who want protobuf or any other wire format take"channels"only and supply their own codec.
Payload generics. The Channel trait is generic over payload:
P: Encode + Decode where Encode/Decode are urus codec traits (one
method each). The "phoenix" feature provides a JsonCodec blanket impl
via serde. No JSON anywhere in the "channels" surface.
Channel trait shape.
trait Channel<P>: Send + 'static {
fn join(topic: &str, payload: P, socket: &ChannelSocket<P>) -> Result<P, P>;
fn handle_in(&mut self, event: &str, payload: P, socket: &ChannelSocket<P>);
fn terminate(&mut self) {}
}
handle_out deferred — can land as a defaulted method, non-breaking.
ChannelSocket. Newtype over WsSender + a PubSub handle.
Exposes reply(ref, status, payload), push(event, payload),
broadcast(topic, payload). The ref from the incoming frame is threaded
in by the channel actor loop, invisible to the impl. Coupling to PubSub
is intentional: channels owns pubsub, and broadcast is the core use case.
Topic router. TopicRouter trait: fn route(&self, topic: &str) -> Option<Box<dyn ChannelFactory<P>>>. Factory receives the full raw topic
string so wildcard-segment extraction is always possible. urus ships
PrefixRouter as the default impl: scan to '*', discard the rest,
single HashMap lookup on the prefix. No regex dep, no glob machinery —
users who need that implement TopicRouter themselves.
Channel actor lifetime + session persistence.
Default: channel actor linked to the ws connection actor, cold-start on
every reconnect (phoenix-server-compatible behavior). Opt-in persistence
via a ChannelSession trait:
trait ChannelSession: Send + 'static {
type Key: Eq + Hash + Send + 'static;
fn session_key(topic: &str, join_payload: &P) -> Self::Key;
fn buffer_cap() -> usize { 128 }
fn ttl() -> Duration { Duration::from_secs(30) }
}
When implemented, the router consults a session registry (gen_server,
same pattern as the connection registry) before spawning: an existing
actor is reattached, and buffered outbound messages (Vec<Arc<M>>, no
re-serialisation) are drained to the reconnecting transport. Buffer full
or TTL expired → actor tears down; next join is a cold start. This
diverges from Phoenix server conventions (client-re-syncs) but is
invisible to phoenix.js at the wire level. Zero-copy drain is the smarm
motivation: messages are Arc<M> in the buffer and in the PubSub relay
path, one allocation per broadcast regardless of subscriber count or
reconnect cycles.
Presence: explicitly out of scope until distribution exists.
As-landed (2026-06-12), beyond the ratified text — veto by diff:
- chunk 1:
Channel::jointakes&mut self(state from join, and session rejoins need the same instance); the in-flight ref is threaded invisibly through the socket;broadcastgained aneventparameter;TopicRouter::routereturnsOption<Arc<dyn ChannelFactory>>. - chunk 2: the serde blanket lives behind the
Json<T>newtype (a blanket on bareP: Serializeis coherence poison under additive features); encode failure panics into the linked-teardown contract. - chunk 3:
ChannelFactorygained a#[doc(hidden)] deploy()seam (default = the ephemeral linked actor); every attach re-callsjoin()(re-entrant by contract); rejected rejoin / explicit leave / cap / TTL all end the session; second transport evicts the first;Key: Clone(registry's pid→key reverse index); registry spawns eagerly at registration (in-runtime law, same asChannelHub::new); shutdown composes linklessly via the registry-drop chain — proven byshutdown_with_detached_session_terminates.
Known bugs
HTTP/1.0 keep-alive: server honors but never advertisesFIXED (2026-06-12, same session it was found). As-landed decisions: serialise_response now echoesconnection: keep-aliveon a kept-alive 1.0 response (1.1 keep-alive stays implicit); error statuses (>=400) on 1.0 force close — no keep-alive advertised on a 4xx/5xx where reuse is opt-in (conn_actor, alongside the existing 1.0-stream force-close); HEAD needs nothing special (header echo only, framing untouched); parse-error responses already carriedconnection: close. Validated against the original repro:ab -k -n 5000 -c 50on GET /users now reports 5000/5000 keep-alive requests, 0 failed, ~63k req/s (vs ~18.9k no-keep-alive baseline, same box). Watch out benching: ab counts a response toward "Non-2xx" silently — a 404'd route still shows "Failed requests: 0" with great rps; check the route first.
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). - Operator introspection via typed names — the v0.2-era
urus.server/urus.listener.{i}pid tags were dropped in the RFC 014 port (names are messageable endpoints now, self-registered with a realSender<M>). If wanted back, do it properly: register the serve loop's shutdown/control channel under a typedurus.servername instead of faking pid tags with unit channels.