Topology (b) as agreed: ONE actor per connection via smarm RFC 008 fd
arms. After the 101, ws::duplex::run_duplex replaces the HTTP loop:
try_select(&[&outbound_rx, &FdArm::readable(fd)]) per iteration,
outbound at index 0 (priority order = owed writes drain before reads;
honest backpressure). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.
Handler API (deferred questions, as answered this session):
- WsHandler { on_message, on_close } runs IN the conn actor's select
loop; concurrency = spawn an actor with a WsSender clone (the SSE
producer pattern). Conn::upgrade(self, handler) flat signature;
WsUpgrade grows from marker to Box<dyn WsHandler> payload.
- 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, drain timeout) = on_close(None, ""). on_ping/on_pong can
land later as default methods, non-breaking.
- Server-initiated close (WsSender::close, FrameError->1002/1009/1007,
handler panic->1011): close out, bounded drain (one write_timeout
budget) for the peer echo, data discarded (1.4). Handler panics
re-raise smarm's stop sentinel first (the pipeline catch_unwind
dance, replicated).
- Caps: Config.max_frame_payload (1 MiB) / max_message_bytes (4 MiB).
Conn actor: 101 branch now hands fd + leftover buf (pipelined first
frame carries over; tested) + boxed handler to run_duplex; registry
entry stays Busy for the ws lifetime, so graceful shutdown force-stops
the conn out of the select park at the drain deadline (tested — the
in-runtime request_stop wake covers select parks too).
Tests: chunk-1 EOF test rewritten into a 9-test duplex suite (echo,
pipelined first frame, ping/pong, both close directions, 1002 unmasked,
1009 header-cap, fragmentation with interleaved ping, shutdown
force-stop). Suite 59u+40i+2d. Hammer: 35x lifecycle+ws subset + 3 full
+ 1 full under smarm-trace, all green; no AlreadyExists out of
try_select (the fresh eager-cleanup path held). Validated against
python websocket-client (echo, pong payload, close 1000).
examples/ws_echo.rs: /echo in-actor + /clock producer-spawning.
283 lines
15 KiB
Markdown
283 lines
15 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
|
||
|
||
`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.
|
||
|
||
---
|
||
|
||
## 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).
|