Files
urus/ROADMAP.md
T
Claude 64137cccf0 fix(http): advertise keep-alive on HTTP/1.0 responses we keep open
1.0 defaults to close: honoring 'Connection: Keep-Alive' without echoing
it back left spec-following clients waiting for an EOF that never came
(ab -k deadlocked to its poll timeout on response 1).

As agreed:
- serialise_response echoes 'connection: keep-alive' when keep_alive
  is on AND version is 1.0. 1.1 keep-alive stays implicit.
- Error statuses (>=400) on 1.0 force keep_alive off in the conn actor
  (joins the existing 1.0-stream force-close): we don't advertise reuse
  on a 4xx/5xx to a protocol generation where reuse is opt-in.
- HEAD needs nothing: the echo is framing-neutral.
- Parse-error responses already carried 'connection: close'.

Tests: unit pair (1.0 echo / 1.1 implicit) + integration pair (socket
actually reused on 1.0; 404 forces close + EOF). Validated against the
original repro: ab -k -n 5000 -c 50 GET /users -> 5000/5000 keep-alive,
0 failed, ~63k rps (~18.9k without keep-alive).
2026-06-12 09:15:11 +00:00

14 KiB
Raw Blame History

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 393cdd0wait_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-readskeep_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_heartbeatEventSender (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 (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 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.

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 suiteurus-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).