Files
urus/ROADMAP.md
T
Claude ad19848db3 feat(ws+pubsub): on_open hook + ws_chat — v0.5 chunk 2
WsHandler::on_open(&mut self, sender) — defaulted, non-breaking,
added mid-chunk for veto-by-diff (the v0.3 "push" precedent for
defaults-level decisions): without it a listen-only ws client can
never be subscribed, since on_message never fires for a client that
doesn't send. Runs exactly once, first, in the conn actor, before any
buffered pipelined frame is decoded. Panic contract identical to
on_message: check_cancelled re-raise dance, else 1011 and no
on_close.

DISCOVERY 1 (documented, not fixed — inherent): the 101 reaches the
client BEFORE on_open runs, so "is my subscription live yet" is a
race client-side. App-level ack is the answer (any reply proves
on_open completed; callbacks are sequential). The integration tests
hit this immediately (first read of a join notice flaked into a 5s
read-timeout) and use a sync/synced ack; same reason Phoenix joins
reply.

DISCOVERY 2 (the structural one): PubSub::new() is in-runtime only,
but pipelines are built pre-runtime. crud's static-OnceLock bootstrap
DOES NOT COMPOSE with serve_with_shutdown here: a static pins the
table actor's last ServerRef forever, its inbox never closes, the
gen_server never exits, AllDone is unreachable — serve hangs (the
cross-thread-unpark limitation closes the workaround routes). The
pattern that works: NON-static Arc<OnceLock<PubSub<M>>> captured by
the route closure. Drain stops conns+listeners -> last Arc<Pipeline>
drops IN-runtime -> cell+handle drop -> inbox closes -> table exits.
Corollary: relays hold the Receiver only, NEVER a PubSub clone
(relay holds table's inbox open, table holds relay's receiver open:
mutual keepalive, shutdown hangs). Both rules in module docs, README,
and enforced by the new shutdown_with_open_chat_terminates test:
two open chat sockets + live relays + live table, shutdown must
return within 5s.

examples/ws_chat.rs: rooms as topics (room:{name}), on_open
subscribes (conn-actor pid: the monitor scopes cleanup to the
session) + spawns the relay (rx -> WsSender clone; exits on prune or
WsClosed), on_message broadcast_from(self_pid()) so the speaker is
never echoed, on_close broadcasts the leave notice and relies on the
monitor for unsubscribe.

Integration: ws_chat_broadcast_reaches_other_client_not_sender
(no-echo proven orderingly, no timeout reads: B speaks after A, A's
next frame must be B's) + shutdown_with_open_chat_terminates.

hammer.sh default subset now includes ws_ (v0.4 ran it ad hoc; ws IS
conn-lifecycle). Hammered: 35x subset (incl. both new tests) + 3x
full + 1x full under smarm-trace, all green. dbg!-grep clean. smarm
PRISTINE. README pub/sub + on_open sections; ROADMAP v0.5 as-landed.

Suite: 67 unit + 42 integration + 2 doc.
2026-06-12 10:30:08 +00:00

312 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `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}` 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 ✅ (landed 2026-06, chunks 1-3)
The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection
is "a response that never ends").
1. `RespBody::Stream(StreamBody)` ✅ — **decided: pull** (user call at the
v0.3 design fork): the handler hands back a `smarm::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_timeout` covers the READ phase only; new
`Config.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).
2. Chunked **request** bodies ✅ — `read_chunked_body` decodes
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.
3. 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: with
`StreamBody.heartbeat` set, the chunk wait is `recv_timeout(interval)`
and expiry writes a `: keep-alive` comment 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)
1. **Handshake** ✅ (49538cc) — `Conn::upgrade()` (handler arg comes
with chunk 3): §4.2.1 validation, 101 + accept key, marker field on
`Conn`, 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
is `sha1_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.
2. **Frame codec** ✅ (2869153) — pure `ws::frame`: incremental
`decode` (mask-required server mode), minimal-length + RSV + control
rules enforced, header-derived cap check before payload buffering,
close-code wire validation, `Assembler` for fragmentation with
whole-message UTF-8 check, `FrameError`→close-code map
(1002/1009/1007). §5.7 examples pinned verbatim.
3. **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_duplex` replaces 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 a `WsSender`
clone (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 as `on_close(Some(code), reason)`;
wordless endings (EOF, write failure, handshake timeout) =
`on_close(None, "")`. `on_ping`/`on_pong` can 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.rs` shows
both handler shapes (/echo in-actor, /clock producer-spawning).
4. **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_down` is 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-static `Arc<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. A `static`
(crud's store shape) hangs `serve_with_shutdown`. Corollary: relays
hold `Receiver` only, never a `PubSub` clone (mutual-keepalive cycle).
Proven by `shutdown_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_message` never fires). Same panic contract as
`on_message` (1011, no `on_close`). The 101 reaches the client before
`on_open` runs — subscription liveness needs an app-level ack
(documented; the integration tests use a `sync`/`synced` ack).
- 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
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.
---
## Known bugs
- ~~**HTTP/1.0 keep-alive: server honors but never advertises**~~ FIXED
(2026-06-12, same session it was found). As-landed decisions:
serialise_response now echoes `connection: keep-alive` on 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 carried `connection: close`. Validated
against the original repro: `ab -k -n 5000 -c 50` on 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.md` exists in the artefact store;
wire it up once v0.2 lands (supervision changes the hot path not at all,
but prove it).