docs(roadmap): state assessment vs smarm v0.8 + v0.2-v0.6 plan

urus builds clean and passes 24/24 against smarm master; the refresh is
OTP adoption, not breakage. v0.2 chunks: supervised listener pool,
graceful shutdown via request_stop, enforce the (currently decorative)
timeouts, registry naming. Then: streaming bodies + SSE (v0.3),
WebSocket upgrade (v0.4), PubSub (v0.5), Channels (v0.6). HTTP/2 stays
iceboxed per spec §2.2. Two decision forks flagged inline: timeout
mechanism (reaper vs smarm timed fd waits) and ws duplex topology
(dup'd fds vs fd-arms-in-select) — both hinge on smarm RFC 008.
This commit is contained in:
Claude
2026-06-11 10:48:50 +00:00
parent a644fec6f5
commit 3da553b9b2
+190
View File
@@ -0,0 +1,190 @@
# 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 (`v0.8`, slot slab + park-epoch + select):
- **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` on the second registration) and one
direction per parked actor. Concurrent read+write on one connection needs
either the dup-fd trick (serve.rs already uses it for the listener pool)
or a new smarm primitive. Decide in v0.2 design, before the frame codec.
---
## v0.2 — OTP refresh ⏳
Adopt the primitives urus predates. No protocol surface changes; `Plug`/`Conn`
untouched. Chunks are independently landable, in order:
### 1. Supervised listener pool
Replace bare spawns in `serve_with` with a supervisor:
`Strategy::OneForOne`, one `ChildSpec` per dup'd listener fd,
`Restart::Permanent`. A listener panic 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).
Open question: should the restarted child re-dup from listener 0's fd, or
own its fd across restarts via the ChildSpec closure? Leaning: closure
captures the `OwnedFd`, re-registers on restart. No re-dup needed.
### 2. Graceful shutdown
`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. Stop connection actors — either drain-then-stop with a deadline, or
immediate `request_stop` (unwind closes fds cleanly via `OwnedFd::drop`).
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. This registry
is also chunk 3's reaper and the future ws/channels introspection point,
so build it once here.
### 3. Enforce the timeouts we advertise
**Decision fork — pick one before implementing:**
- **(a) Reaper in urus.** The chunk-2 registry tracks per-conn
`last_activity`; a ticker (`smarm::sleep` loop) sweeps and
`request_stop`s expired conns. Works today, zero smarm changes.
Granularity = sweep interval; conns must stamp activity (one cast per
request — measurable but small).
- **(b) `wait_readable_timeout` in smarm.** A first-of(fd, timer) wait.
The v0.7 epoch-stamped consuming-unpark machinery exists precisely for
multi-arm waits (`select_timeout` is the channel-flavoured proof), but
fd arms aren't `Selectable` today — this is real smarm work in
`wait_fd`/io thread. Cleaner per-conn semantics, benefits every future
smarm io consumer, no reaper bookkeeping.
Leaning **(a) now, (b) later**: (a) ships the security fix this cycle and
the registry exists anyway; (b) becomes a smarm roadmap item whose landing
lets the reaper's stamps go away. But if you'd rather not build throwaway:
(b) first is defensible — say the word.
### 4. Hygiene
- 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
The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection
is "a response that never ends").
1. `RespBody::Stream(...)` — handler hands back a writer callback or a
`Receiver<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.
2. Chunked **request** bodies (`parser.rs` currently returns 411) — same
cycle, the codec knowledge is fresh.
3. SSE sugar: `Conn::sse()` → an `EventSender` (`.send(event, data)`),
sets `content-type: text/event-stream`, disables buffering, heartbeats
via comment lines on a timer. An SSE conn is just a Stream body that
the reaper exempts from `request_timeout` (keep-alive ping instead).
---
## v0.4 — WebSocket upgrade path
1. **Handshake**: detect `Connection: Upgrade` + `Upgrade: websocket` in
the pipeline; `Conn::upgrade(handler)` short-circuits — conn actor
validates `Sec-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.)
2. **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.
3. **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)**: needs fd
arms in select — the same smarm work as v0.2-3(b). Simpler topology,
no dup, but blocked on smarm.
Default (a); revisit if (b) lands for timeouts anyway.
4. Handler API sketch (settle in design pass): trait with
`on_message/on_close` + a `WsSender` handle — 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_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.
---
## 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.
- **`wait_readable_timeout` / fd-in-select upstreaming** — the smarm-side
item that simplifies v0.2-3 and v0.4-3 retroactively.
- **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).