ROADMAP gains a v0.8 entry: the smarm 415effb lifetime change as root cause, the description/instantiation split, the tree shape, the accepted per-call resolution cost, and what stays open (RegistryName newtype, PubSub's unprotectable const, dynamic session actors, hammer.sh's missing feature matrix). The v0.7 "unreproduced test failure" open item is closed out and pointed at it — it was this hang, hidden because hammer.sh builds with default features while the failure needs --all-features load. README and the two module headers still taught the v0.5 rules: in-runtime-only construction, the non-static Arc<OnceLock<..>> cell, and "a relay must never hold a PubSub clone". None of those are true any more; they are replaced by what actually holds now, with a note on what changed for anyone who learnt the old shape. session.rs also gains an honest note that its session actors are the last lifetime in urus implied by a drop rather than stated — they are dynamic, so a fixed ChildSpec list cannot hold them, and the trigger is at least a command now rather than a refcount.
538 lines
30 KiB
Markdown
538 lines
30 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`.
|
||
**Superseded by v0.7 where the app owns its tree:** start the table as a
|
||
supervised sibling of the endpoint and address it by name (what crud now
|
||
does with its store). The `OnceLock` rule still holds under `serve*`,
|
||
which owns the runtime and offers no in-runtime moment beforehand.
|
||
- **`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`.
|
||
|
||
---
|
||
|
||
## v0.7 — Endpoint as a supervised child — DONE (2026-08-20)
|
||
|
||
Crate goes 0.2.x -> **0.3.0** (breaking). Unwinds the last deviation from
|
||
the spec (§2.1/§6): `serve` owning `rt.run`. The app owns the runtime and
|
||
the root supervisor; urus is one ordered child in it.
|
||
|
||
```
|
||
your root sup
|
||
└── ChildSpec(Permanent, urus::endpoint(cfg, pipeline)?) <- Endpoint gen_server
|
||
└── listener_sup OneForOne over N listeners
|
||
└── plain connection actors
|
||
```
|
||
|
||
- `src/conn_registry.rs` -> `src/endpoint.rs`; `ConnRegistry` -> `Endpoint`.
|
||
The registry absorbed the listener pool it registers for and runs inline
|
||
as the `ChildSpec`'s actor (`NamedGenServerBuilder::run`), so supervisor
|
||
shutdown arrives as `handle_shutdown` and a restart re-runs `init` on the
|
||
same still-open fds. `endpoint()` binds eagerly on the caller's thread.
|
||
- **The endpoint spawns its own listener sup rather than being its
|
||
sibling.** smarm's supervisor start *order* is not start *readiness*
|
||
(`start_child` spawns and moves on), so a sibling listener could
|
||
`whereis` the endpoint name before its actor ran. Registrar-spawns-
|
||
consumers makes that program order inside one `init`. Filed in smarm's
|
||
ROADMAP as a readiness-ack item; a blocking `spawn` would only shrink the
|
||
window ("has begun executing" != "has bound its name") at the cost of a
|
||
round-trip per accept.
|
||
- Listener sup is monitored: death outside shutdown = panic (the app's
|
||
supervisor decides) instead of a zombie on a dead port; death during
|
||
shutdown is the "no new connections" barrier.
|
||
- Drain is entirely internal and event-driven: `handle_shutdown` flips
|
||
draining, shuts the listener sup, stops idle conns, arms one
|
||
`drain_timeout` timer; conns that register or go idle mid-drain are
|
||
stopped on the spot; one force sweep at the deadline; the endpoint exits
|
||
when the sup is down and the set is empty. "Endpoint child stopped" ==
|
||
"every connection gone".
|
||
- **Deleted:** the `AtomicBool` listener flag, `LISTENER_TICK` (250ms wake
|
||
per listener per tick -> untimed `wait_readable` park), `SHUTDOWN_POLL`
|
||
(100ms root poll -> real park on the signal channel), `Restart::Transient`
|
||
for listeners (now `Permanent`: they only exit by supervisor action),
|
||
the root-side drain loop, `Cast::{BeginDrain, ForceStopConns}`.
|
||
Re-probed against current smarm: `request_stop` unwinds an untimed
|
||
`wait_readable` park, is no longer lossy against a QUEUED actor, and
|
||
supervisor shutdown joins in ~200us.
|
||
- **Config split**: `scheduler_threads` / `max_actors` are runtime knobs an
|
||
endpoint-as-child cannot honour -> `serve_with(cfg, smarm::Config, pipe)`
|
||
and `serve_with_shutdown(cfg, smarm::Config, pipe, signal)`. New
|
||
`Config.name` (default `"urus"`) is the endpoint's registry name;
|
||
`urus::endpoint::whereis(name)` for introspection. Two endpoints in one
|
||
process need distinct names.
|
||
- `serve*` stay as thin wrappers building a one-child tree with
|
||
`Shutdown::Infinity`. `Handle`/`ShutdownSignal` stay: a `serve*` caller
|
||
never sees the runtime, so it has no `RuntimeHandle` to reach for — but
|
||
the poll behind it is gone.
|
||
- `examples/crud.rs` is the demonstrator (app-owned tree, store as an
|
||
ordered sibling registered under a typed `Name`, shutdown via
|
||
`rt.handle().request_shutdown(root_sup)`); it loses its `OnceLock` store
|
||
cell and its `SHUTTING_DOWN` flag + 250ms poll. Other examples stay short
|
||
on `serve*`.
|
||
|
||
**Open after this cycle:**
|
||
- ~~One unreproduced test failure seen once in ~120 full-suite runs~~
|
||
**DIAGNOSED AND FIXED** in v0.8 below — it was the pubsub/channels drain
|
||
hang, not the `free_port()` race. It hid because `hammer.sh` builds with
|
||
default features while the failure needs `--all-features` load.
|
||
- `endpoint()` returns `impl Fn()`, so calling it twice = two endpoints
|
||
contending for one `Config.name` (second panics on the clash). Honest
|
||
failure, but the type doesn't prevent the mistake; a consume-on-first-use
|
||
newtype would. `ChannelHub::new` returning `(hub, children)` in v0.8 is
|
||
the same lesson applied: make the type refuse the mistake.
|
||
|
||
## v0.8 — Bus, hub and session registries are supervised — DONE (2026-08-20)
|
||
|
||
Same crate version (**0.3.0**); this cycle and v0.7 ship together and are
|
||
both breaking.
|
||
|
||
v0.7 put the endpoint under a supervisor. This puts everything else there
|
||
too, because smarm 0.7 left it no choice: commit `415effb` ("lifetime is
|
||
the actor's — refs are addresses") removed the rule the pubsub table,
|
||
channel hub bus and session registries were built on. A `GenServerRef` no
|
||
longer owns its server; the loop holds its own inbox sender and the inbox
|
||
never closes when the last ref drops. Nothing commanded these three to
|
||
stop, and what still terminated a run was the root-exit sweep racing the
|
||
drain.
|
||
|
||
That is the flake above: `shutdown_with_open_chat_terminates` and
|
||
`channels_wire::shutdown_with_open_channel_terminates`, 4 failures in 25
|
||
`--all-features` integration runs, all "serve did not return: ... outlived
|
||
the drain: Timeout". 0 in 10 full-suite runs after the fix.
|
||
|
||
**Description separated from instantiation.** The gotchas here all
|
||
descended from constructors that spawn — `PubSub::new()`,
|
||
`ChannelHub::new()` and `PrefixRouter::channel_session()` all started
|
||
actors, which forced in-runtime-only construction, which forced the
|
||
`Arc<OnceLock<..>>` lazy-init from the first handler, which forced the
|
||
"cell must not be `static`" and "a relay must never hold a `PubSub` clone"
|
||
rules. Five documented rules propping up one inverted dependency.
|
||
|
||
```
|
||
your root sup (RestForOne)
|
||
├── ChildSpec(Permanent, PubSub::child) <- bus table, named
|
||
├── ChildSpec(Permanent, session registry) <- one per channel_session
|
||
└── ChildSpec(Permanent, urus::endpoint(..)) <- starts last, drains first
|
||
```
|
||
|
||
- **`PubSub<M>` is a name, not a ref.** `const`-constructible, `Copy`,
|
||
spawns nothing, legal outside the runtime and in a `static`. Operations
|
||
resolve through the registry per call, so a supervisor-restarted table is
|
||
reached transparently. `PubSub::new()` is gone: `PubSub::new(name)` +
|
||
`PubSub::child()`.
|
||
- **`ChannelHub::new(bus, router) -> (hub, Vec<ChildSpec>)`**, `#[must_use]`.
|
||
Both halves come back together because a hub whose children were never
|
||
started compiles and fails on the first join. There is no `children()`
|
||
method to forget to call.
|
||
- **`channel_session` takes a registry name**; each registry is separately
|
||
named and supervised.
|
||
- **`serve_with` / `serve_with_shutdown` take a `Vec<ChildSpec>`** of app
|
||
children and build `RestForOne[..app children, endpoint]`. App children
|
||
start before the endpoint and — shutdown being ordered in reverse — stop
|
||
after it has drained, so a request still in flight can still reach the
|
||
bus. `RestForOne` because a bus crash leaves live sockets addressing a
|
||
table that no longer knows them.
|
||
- Deleted: the `Arc<OnceLock>` idiom from both examples and both test
|
||
pipelines, and every module rule that existed only to hand-manage a
|
||
refcount. `ws_chat` lost 21 lines and a struct field; examples net -25.
|
||
|
||
**Cost accepted:** one registry resolution per operation, including per
|
||
broadcast. Caching a `GenServerRef` in the handle would save it and go
|
||
stale across exactly the restart the supervisor exists to perform. Bench
|
||
before optimising.
|
||
|
||
**Open after this cycle:**
|
||
- `channel_session("session:*", "chat-sessions", f)` puts two unrelated
|
||
string literals side by side and nothing catches a transposition. A
|
||
`RegistryName` newtype makes it a type error. Not done.
|
||
- `PubSub` cannot be protected the way `ChannelHub` was: a `const` is
|
||
copied at each use, so it can't be consume-on-first-use. Forgetting
|
||
`BUS.child()` compiles and fails at runtime with `PubSubDown`. Judged
|
||
worth it — the `const` is what makes the handle pleasant.
|
||
- Session actors are still plain-spawned by their registry, not supervised
|
||
(they're dynamic, one per key — OTP would want `simple_one_for_one`).
|
||
Their exit chain is now commanded rather than refcounted: the registry is
|
||
shut down, its state drops, control senders drop, parked sessions wake on
|
||
the closed arm. Sound, but it is the last place a lifetime is implied by
|
||
a drop rather than stated.
|
||
- `hammer.sh` still passes no feature flags, which is why this hid for a
|
||
whole cycle. A feature matrix is the obvious fix.
|
||
|
||
## 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** — partly delivered in v0.7:
|
||
the endpoint is a named gen_server (`Config.name`, default `"urus"`),
|
||
reachable via `urus::endpoint::whereis(name)`, and answers
|
||
`Call::ConnCount`. Still open: per-listener visibility (the pool is
|
||
internal and anonymous), and a richer stats call (ws/channel counts,
|
||
request rates) — the endpoint is the place to hang them.
|