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.
312 lines
17 KiB
Markdown
312 lines
17 KiB
Markdown
# 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 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).
|