544227122974ce445747b8b7206cc9b639e168b4
20
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9eaa85b9df |
style(tests): byte-string literal in the stalled-writer probe
clippy::byte_char_slices, pre-existing and unrelated to the refactor in the previous commit — split out so that diff stays scoped. Restores a clean `cargo clippy --all-features --all-targets`. |
||
|
|
8f0da2a806 |
feat(pubsub,channels)!: handles are addresses — bus, hub and session registries are supervised children
smarm 0.7 (415effb, "lifetime is the actor's — refs are addresses") removed the
rule these three actors were built on: a GenServerRef no longer owns the server,
the loop holds its own inbox sender, and the inbox never closes when the last ref
drops. urus's pubsub table, channel hub bus and session registries were still
governed by that deleted rule — PubSub::new() spawned the table and the handle
owned its life — so nothing commanded them to stop. What still terminated a run
was the root-exit sweep, racing the drain: shutdown_with_open_chat_terminates and
channels_wire::shutdown_with_open_channel_terminates failed 4 times in 25
--all-features runs with "serve did not return: ... outlived the drain: Timeout".
Zero in 10 full-suite runs after this change.
The fix is not a supervisor wrapped around the old shape. Every gotcha in this
area 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. So:
description is separated from instantiation.
- PubSub<M> is a name, not a GenServerRef: const-constructible, Copy, spawns
nothing, valid outside the runtime and in a static. Operations resolve through
the registry per call, so a table restarted by its supervisor is reached
transparently (one lookup per broadcast — bench before caching a ref, which
would go stale across exactly the restart the supervisor exists to perform).
PubSub::new() is gone; PubSub::new(name) + PubSub::child() replace it.
- ChannelHub::new(bus, router) returns (hub, Vec<ChildSpec>) — the bus table plus
one registry per session route. Returning both is the point: a hub whose
children were never started compiles and fails on the first join, so the vec is
not left behind a method you can forget to call. #[must_use].
- channel_session gains a registry name; each session registry is separately
named and separately supervised.
- serve_with/serve_with_shutdown take a Vec<ChildSpec> of app children and build
the root as RestForOne[..app children, endpoint]. They start before the
endpoint and, shutdown being ordered in reverse, stop after it has drained, so
a request still in flight can 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 the module rules that existed only to hand-manage a refcount.
Known cost, not fixed here: channel_session("session:*", "chat-sessions", f) puts
two unrelated string literals side by side and nothing catches a transposition —
a RegistryName newtype is the obvious follow-up.
Tests: 111 lib + 50 integration + 2 doc green, clippy clean, 10/10 full-suite
runs. Unit tests poll for name binding before use — smarm's start-order-is-not-
start-readiness gap; real apps don't hit it, since a handler only runs once a
connection has been accepted.
|
||
|
|
099b6bc320 |
feat(endpoint): urus is a supervisable child — endpoint gen_server owns listeners + conns
The v0.3 shape from the spec: an app owns its runtime and root supervisor
and places urus in it as one ordered child among its own.
your root sup
└── ChildSpec(Permanent, urus::endpoint(cfg, pipeline)?) <- Endpoint
└── listener_sup OneForOne over N listeners
└── plain connection actors
- src/conn_registry.rs -> src/endpoint.rs. The registry gains the listener
pool it registers for and becomes the Endpoint gen_server; ConnRegistry
-> Endpoint. It 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 spawns its OWN listener sup in init rather than being its
sibling: smarm's supervisor start order is not start *readiness* (spawn
is fire-and-forget), so a sibling listener could whereis the name before
the registry actor ran. Registrar-spawns-consumers makes it program
order inside one init. Gap filed in smarm ROADMAP (readiness ack);
making spawn blocking would only shrink the window, not close it —
'has begun executing' is not 'has bound its name'.
- Listener sup is monitored: death outside shutdown = panic (loud, the
app's supervisor decides) instead of a zombie on a dead port. Death
during shutdown is the 'no new connections' barrier.
- DELETED: the shutdown AtomicBool, LISTENER_TICK (250ms wake per listener
per tick, now an untimed wait_readable park), SHUTDOWN_POLL (100ms root
poll — the root parks on the signal channel now), Restart::Transient
(listeners are Permanent: they only exit by supervisor action, so a
self-exit always means breakage). Verified 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: scheduler_threads/max_actors removed (runtime knobs an
endpoint-as-child cannot honour) -> serve_with(cfg, smarm::Config, pipe)
and serve_with_shutdown(cfg, smarm::Config, pipe, signal). Added
Config.name (default 'urus'): the endpoint's registry name, unique per
endpoint, and the introspection handle via endpoint::whereis(name).
- serve* keep their meaning as the batteries-included path: they build a
one-child tree around endpoint() with Shutdown::Infinity. Handle stays
(a serve* caller has no RuntimeHandle to reach for) and now backs a real
park instead of a poll.
Tests: 4 drain tests ported onto a real endpoint (bound socket, supervised
child, request_shutdown driven); new integration test boots an app tree
with an ordered sibling and asserts serve-then-drain, reverse-order
teardown and a closed port. 106 lib + 50 integration green.
|
||
|
|
f3ccb6e468 |
feat(serve): burst-gated body stall eviction
The body budget from the prior commit is a generous absolute cap; alone it just hands a body-phase slowloris a bigger window. Add a stall gate under that cap that distinguishes a slowloris trickle from a slow-but-legit client by requiring BURSTS, not a mere average rate: - BodyStallGate: each body read is bounded by min(body cap, mark + stall). The stall mark advances only when body_burst_bytes accumulate since the last advance, so a steady sub-burst trickle never moves it and is evicted at ~body_stall_timeout, while a bursty slow client keeps resetting it. - Two words of state; one add + one compare per read. Raw socket bytes are counted, so chunked framing counts and an MSS-fragmented burst still accumulates. Reuses the existing read_some deadline plumbing. - Wired into read_body (fixed CL) and read_chunked_body (via fill_to, the single choke point all chunked reads pass through). - New knobs body_burst_bytes (4 KiB) + body_stall_timeout (20s); effective floor ~205 B/s enforced in bursts. Tests: body_smooth_trickle_evicted_at_stall_timeout (chunked; active sub-burst trickle evicted at ~stall while the cap is far away) and bursty_slow_body_survives_stall_gate (fixed CL; real bursts with sub-stall gaps complete intact). 79 lib + 45 integration green; clippy --lib clean. |
||
|
|
4f06265338 |
feat(serve): split request read budget into head_timeout + body_timeout
The single request_timeout covered head + body under one wall clock, so a slow-but-legit body upload (e.g. a trickling cellular IoT client) was judged by the short head deadline and killed mid-body. Split into: - head_timeout (default 30s): first byte -> full head parse; the classic slowloris surface, kept short. - body_timeout (default 300s): head parse -> full body; an absolute cap sized for slow links, anchored independently once the head has parsed. read_head no longer returns a shared deadline; run_connection anchors the body deadline itself. ReadHeadErr::RequestTimeout -> HeadTimeout. Config and ConnLimits gain body_timeout; request_timeout renamed to head_timeout (breaking, but this axis is unreleased). Tests: slow_body_outlives_head_timeout (positive: body survives past the head clock), fixed_/chunked_body_stall_killed_at_body_timeout (body cap still bites), slowloris_partial_head_killed_at_head_timeout (head clock unchanged). 79 lib + 43 integration green; clippy --lib clean. |
||
|
|
078072b527 |
port(smarm): track HEAD efbc254 — RFC 014/015 API sync
- 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.
|
||
|
|
0de2baa72d |
feat(channels): opt-in session persistence — v0.6 chunk 3
ChannelSession<P> (src/channels/session.rs): a channel actor that outlives its transport, buffering outbound broadcasts as the same Arc<Broadcast<P>>s the relay path carries (zero re-allocation) until rejoin, buffer cap, or TTL. Registered per pattern via PrefixRouter::channel_session::<S>(pattern, factory) / channel_session_default::<C>(pattern); one registry gen_server per registration, monomorphic over S::Key (no type erasure). Machinery: deploy() computes the key on the conn actor and casts the join handshake to the registry; the registry maps key -> (pid, control sender), forwards reattaches, spawns-and-monitors fresh actors, and prunes on Down (pid-guarded against replaced entries). The session actor selects [inbound, bus, ctl] attached and select_timeout([ctl, bus], ttl-remaining) detached; detach triggers are the closed inbound arm and ws send failure (the failed broadcast is the buffer's first entry — nothing lost). Drain re-encodes under the new join generation. As-landed decisions (each in module docs): - ChannelFactory gained a #[doc(hidden)] deploy() seam (default = ephemeral linked actor, the chunk-1 behavior verbatim); JoinHandshake and ChannelInbox are opaque pub structs. Channel construction moved from run_channel into deploy — same linked blast radius. - Every attach calls ch.join() again (rejoin acks need a payload and channels get to re-auth); session channels must treat join as re-entrant. - A rejected rejoin ENDS the session (no zombie sessions held open for unauthorized clients); next join is a cold start. - A second transport EVICTS the first (best-effort phx_close); a session is single-transport. - Explicit leave ends the session, not just the attachment. - TTL per detach episode; subscribe once, on first successful join; cap.max(1) semantics (cap 0 = first buffered message tears down, but an empty buffer still waits out the TTL). - Key: Clone beyond the ratified Eq+Hash+Send — the registry keeps a pid -> key reverse index for Down pruning. - The registry spawns eagerly at registration (channel_session is in-runtime-only, same law as ChannelHub::new): a lazy OnceLock would block std-sync under green threads on first-join races. - Shutdown composes WITHOUT links: conns die -> router Arcs drop -> registry inbox closes -> registry exits -> ctl senders drop -> detached sessions wake on the closed ctl arm, terminate, exit. shutdown_with_detached_session_terminates is the proof (ttl 30s vs 5s budget — only the chain can reap it). Tests: 6 session unit tests (buffer-and-drain ordering across reconnect, TTL expiry, cap teardown, leave, eviction incl. stale conn-entry self-prune, rejected rejoin) + the shutdown integration test. Shared toy codec + conn harness extracted to channels/testkit.rs (mod.rs tests now import it; no behavior change). Hammer: 35x lifecycle subset (+channels +session) + 3x full (phoenix) + 1x full (phoenix+smarm-trace) + featureless + channels-only, all green; dbg-grep clean. Suite: 90 unit + 46 integration + 2 doc under --features phoenix. |
||
|
|
39e4f70e9d |
feat(channels): phoenix V2 JSON codec — v0.6 chunk 2
Json<T> newtype codec (src/channels/phoenix.rs), feature "phoenix":
speaks the Phoenix V2 wire format [join_ref, ref, topic, event, payload]
for any T: Serialize + DeserializeOwned. Replies encode as phx_reply
with the {"status", "response"} wrapper; payload-less control frames
encode payload as {}.
Deviation from roadmap sketch (documented in module docs): the roadmap
sketched a blanket Encode/Decode impl on bare P: Serialize. That is
coherence poison — with additive features, enabling "phoenix" anywhere
in the crate graph would forbid every user-written Encode impl. The
blanket therefore lives behind the Json<T> newtype: zero-boilerplate
(Deref/DerefMut/From), but opt-in per payload type.
Encode failure panics and rides the linked-teardown contract (channel
actor is linked to the conn; a payload that cannot serialize is a
programming error, not a protocol event). Decode strictness is exactly
T's Deserialize — use serde defaults or Json<serde_json::Value> for
lenient channels.
Integration (tests/integration.rs, cfg(feature = "phoenix")):
- channels_join_heartbeat_event_broadcast_leave: join ack ordering,
conn-side heartbeat, unrouted-topic error, join rejection, broadcast
incl. sender, correlated echo reply, leave -> ok + phx_close, and
post-leave isolation.
- channels_codec_garbage_closes_1002: undecodable text frame closes
the socket with protocol-error status.
- shutdown_with_open_channel_terminates: drain force-stop tears down
the linked channel actor and the runtime reaches AllDone in budget.
Hammer: 35x lifecycle subset (+channels filter) + 3x full (phoenix) +
1x full (phoenix+smarm-trace) + featureless + channels-only, all green.
Suite: 84 unit + 45 integration + 2 doc under --features phoenix.
|
||
|
|
ad19848db3 |
feat(ws+pubsub): on_open hook + ws_chat — v0.5 chunk 2
WsHandler::on_open(&mut self, sender) — defaulted, non-breaking,
added mid-chunk for veto-by-diff (the v0.3 "push" precedent for
defaults-level decisions): without it a listen-only ws client can
never be subscribed, since on_message never fires for a client that
doesn't send. Runs exactly once, first, in the conn actor, before any
buffered pipelined frame is decoded. Panic contract identical to
on_message: check_cancelled re-raise dance, else 1011 and no
on_close.
DISCOVERY 1 (documented, not fixed — inherent): the 101 reaches the
client BEFORE on_open runs, so "is my subscription live yet" is a
race client-side. App-level ack is the answer (any reply proves
on_open completed; callbacks are sequential). The integration tests
hit this immediately (first read of a join notice flaked into a 5s
read-timeout) and use a sync/synced ack; same reason Phoenix joins
reply.
DISCOVERY 2 (the structural one): PubSub::new() is in-runtime only,
but pipelines are built pre-runtime. crud's static-OnceLock bootstrap
DOES NOT COMPOSE with serve_with_shutdown here: a static pins the
table actor's last ServerRef forever, its inbox never closes, the
gen_server never exits, AllDone is unreachable — serve hangs (the
cross-thread-unpark limitation closes the workaround routes). The
pattern that works: NON-static Arc<OnceLock<PubSub<M>>> captured by
the route closure. Drain stops conns+listeners -> last Arc<Pipeline>
drops IN-runtime -> cell+handle drop -> inbox closes -> table exits.
Corollary: relays hold the Receiver only, NEVER a PubSub clone
(relay holds table's inbox open, table holds relay's receiver open:
mutual keepalive, shutdown hangs). Both rules in module docs, README,
and enforced by the new shutdown_with_open_chat_terminates test:
two open chat sockets + live relays + live table, shutdown must
return within 5s.
examples/ws_chat.rs: rooms as topics (room:{name}), on_open
subscribes (conn-actor pid: the monitor scopes cleanup to the
session) + spawns the relay (rx -> WsSender clone; exits on prune or
WsClosed), on_message broadcast_from(self_pid()) so the speaker is
never echoed, on_close broadcasts the leave notice and relies on the
monitor for unsubscribe.
Integration: ws_chat_broadcast_reaches_other_client_not_sender
(no-echo proven orderingly, no timeout reads: B speaks after A, A's
next frame must be B's) + shutdown_with_open_chat_terminates.
hammer.sh default subset now includes ws_ (v0.4 ran it ad hoc; ws IS
conn-lifecycle). Hammered: 35x subset (incl. both new tests) + 3x
full + 1x full under smarm-trace, all green. dbg!-grep clean. smarm
PRISTINE. README pub/sub + on_open sections; ROADMAP v0.5 as-landed.
Suite: 67 unit + 42 integration + 2 doc.
|
||
|
|
ffc579c500 |
feat(ws): duplex wiring + handler API + echo example (v0.4 chunk 3)
Topology (b) as agreed: ONE actor per connection via smarm RFC 008 fd
arms. After the 101, ws::duplex::run_duplex replaces the HTTP loop:
try_select(&[&outbound_rx, &FdArm::readable(fd)]) per iteration,
outbound at index 0 (priority order = owed writes drain before reads;
honest backpressure). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.
Handler API (deferred questions, as answered this session):
- WsHandler { on_message, on_close } runs IN the conn actor's select
loop; concurrency = spawn an actor with a WsSender clone (the SSE
producer pattern). Conn::upgrade(self, handler) flat signature;
WsUpgrade grows from marker to Box<dyn WsHandler> payload.
- 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, drain timeout) = on_close(None, ""). on_ping/on_pong can
land later as default methods, non-breaking.
- Server-initiated close (WsSender::close, FrameError->1002/1009/1007,
handler panic->1011): close out, bounded drain (one write_timeout
budget) for the peer echo, data discarded (1.4). Handler panics
re-raise smarm's stop sentinel first (the pipeline catch_unwind
dance, replicated).
- Caps: Config.max_frame_payload (1 MiB) / max_message_bytes (4 MiB).
Conn actor: 101 branch now hands fd + leftover buf (pipelined first
frame carries over; tested) + boxed handler to run_duplex; registry
entry stays Busy for the ws lifetime, so graceful shutdown force-stops
the conn out of the select park at the drain deadline (tested — the
in-runtime request_stop wake covers select parks too).
Tests: chunk-1 EOF test rewritten into a 9-test duplex suite (echo,
pipelined first frame, ping/pong, both close directions, 1002 unmasked,
1009 header-cap, fragmentation with interleaved ping, shutdown
force-stop). Suite 59u+40i+2d. Hammer: 35x lifecycle+ws subset + 3 full
+ 1 full under smarm-trace, all green; no AlreadyExists out of
try_select (the fresh eager-cleanup path held). Validated against
python websocket-client (echo, pong payload, close 1000).
examples/ws_echo.rs: /echo in-actor + /clock producer-spawning.
|
||
|
|
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). |
||
|
|
49538ccc83 |
feat(ws): RFC 6455 opening handshake — Conn::upgrade, 101 short-circuit (v0.4 chunk 1)
Design (as agreed in the v0.4 pass): - Dep #3 spent: sha1_smol 1.0.1 (zero transitive deps) instead of a vendored SHA-1 — user call: no hand-rolled crypto. Consequence noted in ROADMAP: the v0.6 wire-format JSON question now costs dep #4. - Base64 stays in-tree but ENCODE-ONLY (RFC 4648 vectors tested): it is an encoding, not crypto, and the decode direction (where parsing bugs live) is deliberately not implemented. - src/ws/{mod,handshake}.rs: validate() implements §4.2.1 (GET, 1.1, Host, upgrade/connection token lists case-insensitively, key shape = base64 of exactly 16 bytes, version 13). Version mismatch -> 426 + sec-websocket-version: 13 (§4.2.2); everything else -> 400. Rejected upgrades stay plain HTTP with keep-alive intact (tested: 400 then 101 on the same connection). - Conn grows pub upgrade: Option<WsUpgrade> (opaque marker; chunk 3 turns it into the duplex/handler handoff payload — shape deliberately uncommitted while the handler API is open). Conn::upgrade(self) is the commit point: 101 + accept key + marker + halt, or the rejection. - conn_actor: upgrade marker + status 101 -> write head, leave the HTTP loop. Until chunk 3 that drops the fd (clients see 101 then EOF); status check is defensive against a post-handler plug clobbering 101. - serialise_response: 1xx no longer get content-length injected (RFC 7230 §3.3.2). 204 left alone on purpose — separate conversation. Accept-key path pinned to the §1.3 worked example end-to-end (unit + wire). Suite: 34 unit + 33 integration + 2 doc. |
||
|
|
d14fb7c331 |
feat(sse): Conn::sse() sugar — EventSender + pump heartbeats (v0.3 chunk 3)
New src/sse.rs. Conn::sse() (and sse_with_heartbeat) turns the response
into a text/event-stream: status 200 if unset, content-type +
cache-control: no-cache, and a RespBody::Stream whose heartbeat is wired
to the new StreamBody.heartbeat field. Returns (Conn, EventSender);
EventSender is Clone (stream ends when the LAST clone drops) with
send(event, data) / data(data) / comment(text), all returning
Err(SseClosed) once the conn actor has dropped the stream — the producer
exit signal. Multi-line data becomes one data: line per line (pure
format_event, unit-tested).
Heartbeat lives in the pump, as the roadmap leaned: with
StreamBody.heartbeat set, pump_stream waits recv_timeout(interval) and on
expiry writes the payload (": keep-alive" comment chunk) and keeps
waiting. Liveness inversion is deliberate: no request clock governs an
SSE response (request_timeout is read-phase only since chunk 1); a dead
client is detected when an event or heartbeat write stalls past
write_timeout, and a draining shutdown force-stops the recv park like any
stream.
Tests: 4 unit (3 framing, 1 sse() wiring) + 2 integration (event round
trip over decoded chunked stream incl. named + unnamed events and clean
0-chunk EOS on sender drop; >=2 heartbeats observed across 450ms of
producer silence at a 100ms interval).
|
||
|
|
4482f47265 |
feat(parser,conn): chunked request bodies (v0.3 chunk 2)
parser: Transfer-Encoding: chunked no longer 411s — it sets ParsedHead.chunked and the conn actor decodes. Two hard rejections at parse time, both Malformed/400: chunked together with Content-Length (the request-smuggling ambiguity; RFC 7230 §3.3.3 permits rejection) and chunked on HTTP/1.0 (TE is a 1.1 mechanism). ParseError::Unsupported is now unconstructed; kept for future framings. conn_actor: read_chunked_body decodes incrementally from buf[head_len..], reading on the SAME request deadline the head came in under (a stalled chunked body dies at request_timeout exactly like a stalled CL body). Chunk extensions are ignored; trailers are consumed and discarded. Bounds: decoded size capped at max_body_bytes -> 413 the moment the cap would be crossed (the CL pre-check can't see a chunked body's size up front); size lines capped at 128 bytes and the trailer section at 8 KiB -> 400, so framing spam can't grow the read buffer unboundedly. Returns (decoded, raw_consumed_past_head): the keep-alive drain at the loop bottom now drops head_len + RAW framing length (not decoded length), so pipelined requests behind a chunked body land exactly — covered by the trailers+pipelining test. Tests: 3 parser unit (flagging, CL+TE reject, 1.0 reject; the old chunked-is-Unsupported test replaced) + 6 integration (decode with extensions, trailers + pipelined next request, 413 over decoded cap, 400 malformed size line, 400 CL+TE on the wire, stalled chunked body killed at request_timeout with silent close). |
||
|
|
42a2464743 |
feat(conn): streaming response bodies — RespBody::Stream + chunked TE (v0.3 chunk 1)
Design decisions (per handoff Q1 user answer + Q3 proposal): - Producer shape is PULL: the handler returns a smarm Receiver<Vec<u8>> (RespBody::Stream / StreamBody, From<Receiver<Vec<u8>>> for ergonomics); the producer is an actor the handler spawned. The conn actor pumps in pump_stream(): it keeps sole ownership of the socket and of write deadlines, and the recv() park between chunks is stoppable by the draining registry's request_stop (stop sentinel unwinds out of park_current; fd + registry guards clean up) — so an infinite stream is force-stoppable at the drain deadline like any in-flight request, with no polling. End of stream = every Sender dropped = terminating 0-chunk. Producer contract: a send() error means the conn died; exit. - Framing: HTTP/1.1 gets transfer-encoding: chunked and the connection stays reusable after the terminator (keep-alive after chunked). HTTP/1.0 has no chunked TE: bytes go raw, keep-alive is forced off, EOF delimits. User-set content-length/transfer-encoding headers are DROPPED for stream bodies — we own the framing, and CL+chunked is a smuggling vector. Empty producer chunks are skipped (a 0-length chunk would terminate the framing early). - request_timeout stays READ-phase only (unchanged). NEW Config/ConnLimits field write_timeout (default 30s) gives every response write a per-write budget: the fixed head+body write, each streamed chunk, and the error paths (413/100-continue/4xx) all go through the now deadline-bounded write_all (wait_writable_timeout, mirroring read_some). Each chunk gets a FRESH budget — streams may outlive any whole-response clock; a single stalled write may not (write-side slowloris). Naming/default were flagged as a user call in the handoff: veto here if write_timeout(30s) isn't it. - RespBody loses derive(Clone) (Receiver isn't Clone; Clone was unused) and gets a manual Debug. Tests: 3 serialiser unit tests (chunked head shape, user-framing-header stripping, 1.0 fallback) + 5 integration (chunked round-trip with decoder, keep-alive after chunked, 1.0 EOF-delimited, shutdown force-stops an infinite stream at drain deadline, stalled reader killed at write_timeout with producer observing the closed channel). |
||
|
|
8065ea561e |
feat(serve): registry names + docs (v0.2 chunk 4)
Registration:
- Each listener registers "urus.listener.{i}" from inside its ChildSpec
factory. On a restart the stale binding points at a dead pid; smarm's
registry evicts those lazily on register/whereis, so re-registering
the same name is safe. Result ignored — a registry hiccup must not
take the listener down.
- "urus.server" -> supervisor pid, registered via the JoinHandle's
.pid() immediately after spawn rather than inside the closure: the
binding exists before the supervisor runs an instruction, so an early
whereis can't observe None.
Test: server_and_listeners_are_registered probes whereis from a route
handler — whereis must run inside the runtime, and the test thread is a
foreign OS thread with no runtime in its TLS.
Docs:
- README: Config documented in full (timeout semantics: keep_alive =
idle-before-first-byte, request = Instant deadline first-byte ->
head+body, slowloris-proof), Handle/shutdown_handle/serve_with_shutdown
section with the 4-step shutdown sequence, named-actors note, test
coverage list refreshed, Roadmap section now points at ROADMAP.md.
- ROADMAP: v0.2 chunks 1-4 marked landed; design detail stays in the
chunk commit bodies.
Verified: cargo build --features smarm-trace clean; full suite (32
tests) green 3x; debug-grep clean.
This closes v0.2. Exit criteria all verified across chunks 1-3: crud
drains on stdin-Enter (~100ms), idle keep-alive reaped at
keep_alive_timeout, panicking listener restarts under load.
|
||
|
|
a60687bfec |
feat(conn): keep-alive and per-request read timeouts (v0.2 chunk 3)
Two wall-clock budgets in the conn read path, both via smarm::wait_readable_timeout: - keep_alive_timeout: idle budget between requests — how long we park waiting for the FIRST byte of a request (including the first request on a fresh connection). Expiry closes silently; nothing is owed to a client that isn't talking. - request_timeout: per-request budget as an Instant deadline from the first byte of a request until the request (head + body) is fully read. Pipelined leftovers in the buffer at cycle start count as a started request. The deadline is returned from read_head and threaded into read_body so head and body share one budget. Pipeline run time is NOT covered. Each read wait uses whichever budget is currently active; deadlines are Instants, so EAGAIN retries don't reset the clock. Expiry mid-head gets a best-effort 408 via try_write_once: a single non-parking write syscall — a client that stalls its read side must not defeat the timeout by making the 408 write park forever. Expiry mid-body just closes. examples/crud.rs gains stdin-Enter graceful shutdown (plain OS thread on read_line -> handle.shutdown(); no signal crate). Doing so surfaced a pattern worth knowing: crud's lazily-spawned store actor parked forever in recv(), which blocks smarm's AllDone, so serve_with_shutdown never returned (gdb: scheduler idle in poll_wake, store the only live actor). It can't be messaged awake from the stdin thread either — a cross-thread send's unpark is a no-op without runtime TLS, the same limitation behind SHUTDOWN_POLL. Fix: store_loop recv_timeout(250ms) + a SHUTTING_DOWN AtomicBool set by the stdin thread before handle.shutdown(). This poll dies with the cross-thread-unpark limitation; recorded in smarm docs. Tests: idle keep-alive conn reaped at a small keep_alive_timeout; slowloris partial-head stall killed at request_timeout with the 408 observed. Timeout+shutdown subset hammered 30x clean; full suite 3x; smarm-trace build and suite clean. |
||
|
|
c658ac06b2 |
feat(serve): graceful shutdown + connection registry (v0.2 chunk 2)
Shutdown is drain-then-force-stop: flag.store(true) -> sup_h.join() (the no-more-accepts barrier) -> cast BeginDrain -> poll ConnCount every 50ms until 0 or drain_timeout; past the deadline each tick casts ForceStopConns and re-sweeps, so conns that registered after the deadline are still caught. Listener shutdown is redesigned vs chunk 1: Restart::Permanent -> Transient, wait-failure return -> panic (Transient restarts it), and a shared Arc<AtomicBool> flag checked at loop top with a 250ms wait_readable_timeout tick instead of request_stop — no request_stop on the supervisor or listeners anywhere. Historical note: the redesign was originally forced by smarm's then-lossy stop against QUEUED actors (fixed upstream in 7bab4d2); it is kept because flag-based listener shutdown is simpler and stop-semantics-free. Conns self-register with the registry (ConnStarted from the conn actor, not a listener cast): per-sender FIFO ordering is the only thing that prevents ConnEnded overtaking ConnStarted; see conn_registry module docs. conn_actor: in the catch_unwind(pipeline.run) Err branch, smarm::preempt::check_cancelled() runs BEFORE composing the 500 — smarm's stop is an undowncastable panic_any(StopSentinel), so the catch_unwind would otherwise swallow a stop and 500-and-keep-running. The stop flag is persistent, so check_cancelled re-raises cleanly. Handle.shutdown() wakes the root via a 100ms try_recv poll (SHUTDOWN_POLL): a cross-thread Sender::send's unpark is a try_with_runtime no-op without runtime TLS on the sending thread (still true on smarm 8e5b754; cross-thread unpark is a recorded smarm roadmap candidate — this poll dies with it). Two smarm bugs were found during this chunk and fixed upstream: lossy stop against QUEUED actors (7bab4d2) and the terminal-wake shutdown stall/hang (eddf3fe); post-mortem in artefact smarm-bug-terminal-wake.md. |
||
|
|
5fe696992a |
feat(serve): supervised listener pool (v0.2 chunk 1)
OneForOne supervisor on the root actor, Restart::Permanent per listener. ChildSpec factory owns its fd via Arc<OwnedFd> (OwnedFd gains Sync); restarts reuse the fd — no re-dup, no fd-less window. wait_readable failure now exits-and-restarts instead of being fatal. Conn actors stay unsupervised bare spawns. Test hook INJECT_LISTENER_PANICS panics before accept so a pending connection must survive into the restarted listener. Roadmap: chunk 1 marked done; chunk 3 fork resolved to smarm-native timed waits (RFC 008 landed at 393cdd0, reaper option removed); chunk 2 drain-then-stop decided; v0.4-3(b) unblocked. |
||
|
|
3b6c466210 | Initial commit |