- gen_server rename (RFC 015, 3e31606): ServerRef/ServerCtx/ServerBuilder
-> GenServerRef/GenServerCtx/GenServerBuilder across conn_actor,
conn_registry, serve, pubsub, channels::session.
- type Timer = () on the three GenServer impls (RFC 015, 57eadb5);
handle_timer/handle_idle/tick_every stay defaulted — opt-in later.
- Watcher and GenServerCtx are now generic over the server type: watcher
fields typed Watcher<Table<M>> / Watcher<Registry<P, K>>; Registry's
struct bounds strengthened to its GenServer impl bounds
(P: Encode + Decode + Send + Sync + 'static, K: SessionKey) so the
Watcher field's G: GenServer bound is satisfiable at the declaration.
- RFC 014 (a866e34) registry: register is (Name<M>, Sender<M>), self-only
— a name is a typed messaging endpoint, not a pid tag. The
introspection-only urus.server / urus.listener.{i} bindings are dropped
rather than faked with unit channels; the whereis integration test is
deleted; a proper messageable-name design is icebox'd in ROADMAP.md.
- Audit vs smarm 6c2b7e9 (queued messages dropped when Receiver drops):
pubsub's prune-on-send-failure retain still holds — send Errs once
receiver_alive is false, so a dropped rx prunes on next broadcast,
exactly what subscriber_count's doc already promised. Freeing stranded
Arc<M> broadcasts is strictly good. No change needed.
- The 7x E0283 in channels/mod.rs were cascade fallout of the generics
changes; dissolved with the port, as discovery predicted.
Suite: 90 lib + 45 integration + 2 doc, green under default, smarm-trace,
phoenix, and all-features. 3 pre-existing clippy lints (conn.rs,
parser.rs, conn_actor.rs; clippy 1.97 strictness) deferred to a follow-up
chore commit to keep this diff pure.
387 lines
21 KiB
Markdown
387 lines
21 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 — DONE (2026-06-12)
|
||
|
||
Join/leave/event protocol over WebSocket transport, PubSub underneath.
|
||
Design decisions ratified pre-code (2026-06-12):
|
||
|
||
**Feature flags.** Two additive features:
|
||
- `"channels"` — the `Channel` trait, `TopicRouter` trait, `PrefixRouter`,
|
||
`ChannelSocket`, session machinery. Zero new deps; pubsub is already
|
||
always-on (it only exists because channels needs it).
|
||
- `"phoenix"` — implies `"channels"` + `serde`/`serde_json` + the V2 frame
|
||
codec. Opt-in phoenix.js interop; users who want protobuf or any other
|
||
wire format take `"channels"` only and supply their own codec.
|
||
|
||
**Payload generics.** The `Channel` trait is generic over payload:
|
||
`P: Encode + Decode` where `Encode`/`Decode` are urus codec traits (one
|
||
method each). The `"phoenix"` feature provides a `JsonCodec` blanket impl
|
||
via serde. No JSON anywhere in the `"channels"` surface.
|
||
|
||
**`Channel` trait shape.**
|
||
```
|
||
trait Channel<P>: Send + 'static {
|
||
fn join(topic: &str, payload: P, socket: &ChannelSocket<P>) -> Result<P, P>;
|
||
fn handle_in(&mut self, event: &str, payload: P, socket: &ChannelSocket<P>);
|
||
fn terminate(&mut self) {}
|
||
}
|
||
```
|
||
`handle_out` deferred — can land as a defaulted method, non-breaking.
|
||
|
||
**`ChannelSocket`.** Newtype over `WsSender` + a `PubSub` handle.
|
||
Exposes `reply(ref, status, payload)`, `push(event, payload)`,
|
||
`broadcast(topic, payload)`. The `ref` from the incoming frame is threaded
|
||
in by the channel actor loop, invisible to the impl. Coupling to `PubSub`
|
||
is intentional: channels owns pubsub, and broadcast is the core use case.
|
||
|
||
**Topic router.** `TopicRouter` trait: `fn route(&self, topic: &str) ->
|
||
Option<Box<dyn ChannelFactory<P>>>`. Factory receives the full raw topic
|
||
string so wildcard-segment extraction is always possible. urus ships
|
||
`PrefixRouter` as the default impl: scan to `'*'`, discard the rest,
|
||
single `HashMap` lookup on the prefix. No regex dep, no glob machinery —
|
||
users who need that implement `TopicRouter` themselves.
|
||
|
||
**Channel actor lifetime + session persistence.**
|
||
Default: channel actor linked to the ws connection actor, cold-start on
|
||
every reconnect (phoenix-server-compatible behavior). Opt-in persistence
|
||
via a `ChannelSession` trait:
|
||
```
|
||
trait ChannelSession: Send + 'static {
|
||
type Key: Eq + Hash + Send + 'static;
|
||
fn session_key(topic: &str, join_payload: &P) -> Self::Key;
|
||
fn buffer_cap() -> usize { 128 }
|
||
fn ttl() -> Duration { Duration::from_secs(30) }
|
||
}
|
||
```
|
||
When implemented, the router consults a session registry (gen_server,
|
||
same pattern as the connection registry) before spawning: an existing
|
||
actor is reattached, and buffered outbound messages (`Vec<Arc<M>>`, no
|
||
re-serialisation) are drained to the reconnecting transport. Buffer full
|
||
or TTL expired → actor tears down; next join is a cold start. This
|
||
diverges from Phoenix server conventions (client-re-syncs) but is
|
||
invisible to phoenix.js at the wire level. Zero-copy drain is the smarm
|
||
motivation: messages are `Arc<M>` in the buffer and in the PubSub relay
|
||
path, one allocation per broadcast regardless of subscriber count or
|
||
reconnect cycles.
|
||
|
||
**Presence:** explicitly out of scope until distribution exists.
|
||
|
||
**As-landed (2026-06-12), beyond the ratified text — veto by diff:**
|
||
- chunk 1: `Channel::join` takes `&mut self` (state from join, and
|
||
session rejoins need the same instance); the in-flight ref is
|
||
threaded invisibly through the socket; `broadcast` gained an `event`
|
||
parameter; `TopicRouter::route` returns `Option<Arc<dyn
|
||
ChannelFactory>>`.
|
||
- chunk 2: the serde blanket lives behind the `Json<T>` newtype
|
||
(a blanket on bare `P: Serialize` is coherence poison under additive
|
||
features); encode failure panics into the linked-teardown contract.
|
||
- chunk 3: `ChannelFactory` gained a `#[doc(hidden)] deploy()` seam
|
||
(default = the ephemeral linked actor); every attach re-calls
|
||
`join()` (re-entrant by contract); rejected rejoin / explicit leave /
|
||
cap / TTL all end the session; second transport evicts the first;
|
||
`Key: Clone` (registry's pid→key reverse index); registry spawns
|
||
eagerly at registration (in-runtime law, same as `ChannelHub::new`);
|
||
shutdown composes linklessly via the registry-drop chain — proven by
|
||
`shutdown_with_detached_session_terminates`.
|
||
|
||
---
|
||
|
||
## 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).
|
||
- **Operator introspection via typed names** — the v0.2-era
|
||
`urus.server` / `urus.listener.{i}` pid tags were dropped in the
|
||
RFC 014 port (names are messageable endpoints now, self-registered
|
||
with a real `Sender<M>`). If wanted back, do it properly: register the
|
||
serve loop's shutdown/control channel under a typed `urus.server`
|
||
name instead of faking pid tags with unit channels.
|