README: write_timeout in the Config sample, read/write timeout-split semantics spelled out, new Streaming Responses and Server-Sent Events sections (producer contract, 1.0 fallback, heartbeat liveness), roadmap summary bumped. ROADMAP: v0.3 marked done with the as-landed decisions (pull shape per the user's fork answer; Q3 timeout split as proposed) and verified exit criteria. crud example: GET /ticker SSE route — one event/second, producer exits on SseClosed or the example's shutdown flag (so an open ticker doesn't block AllDone past the drain).
237 lines
12 KiB
Markdown
237 lines
12 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
|
||
|
||
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)**: fd arms in
|
||
select landed with RFC 008 (`393cdd0`) — no longer blocked. Simpler
|
||
topology, no dup; use the fallible `try_select` twins (registration
|
||
errors, incl. `AlreadyExists`, surface as `Err` and the wait fully
|
||
retires).
|
||
Was "default (a), revisit if (b) lands" — (b) landed, so this is a
|
||
live choice for the v0.4 design pass. Note RFC 008 is phase 1:
|
||
single-waiter-per-fd stands, `dup` remains the documented answer for
|
||
true simultaneous read+write waits.
|
||
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.
|
||
- **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).
|