v0.2.2
21
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
535f7bcc68 |
feat(serve): give connection actors a 256 KiB stack via smarm SpawnOpts
Connection actors were still spawned with a bare smarm::spawn(), which
gets the runtime's fixed 64 KiB default stack regardless of smarm
v0.6.0's RFC 019 SpawnOpts/stack_reserve work landing one crate down.
Any handler that leans on app code with real stack needs (DB drivers,
(de)compression, ...) blows the guard page and the connection just
dies with no response - reproduced with a CCC handler that decompresses
gzip on the identity-encoding path.
Add Config::conn_stack_reserve (default DEFAULT_CONN_STACK_RESERVE =
256 KiB) and thread it through listener_loop into a
smarm::spawn_with(SpawnOpts { stack_reserve: Some(_), .. }, ...) call
for every accepted connection. Existing Config { ..Config::new(addr) }
call sites (tests/integration.rs) pick up the new field automatically
via struct-update syntax; no call-site churn beyond that.
Bump to 0.2.2.
|
||
|
|
ecaddc579a |
feat(causal): instrument the request hot path — five sites and a progress point
RFC 007 causal-site coverage for the Phase 2 bench (and any downstream
profiling): parse (head parsing attempts in read_head), router (dispatch
matching only — the winning handler and the next fall-through run outside
the guard, so the site measures dispatch, not what it dispatches to;
call() restructured match-then-dispatch for that, semantics preserved
incl. first-path+method-match-wins and the 405 two-pass), pipeline (the
plug chain inside catch_unwind; nested sites like router take over
attribution during their span, so this reads as chain overhead + handler
code outside inner sites), serialize (response head+bytes-body
serialisation; chunked stream framing is not covered — it interleaves
with writes in pump_stream and the bench doesn't stream), and
socket-write (all of write_all, writability parks included: a park
inside a site is exactly what the park-gated resume credit attributes).
progress!("responses") fires once per response fully on the wire, at
both completion points (the common path and the HTTP/1.0 EOF-stream
early return).
Site guards are #[cfg(feature = "smarm-causal")]-gated: the macros are
no-ops featureless, but binding the unit expansion would trip clippy's
let_unit_value. progress! is bare — its featureless expansion is an
empty block, free and lint-clean. Featureless build byte-behavior is
unchanged; both configs clippy-clean, full suite green both ways.
|
||
|
|
0f824635d1 |
chore(clippy): appease 1.97 lints — derive Default, collapse ifs, drop needless borrow
Pre-existing, surfaced by the toolchain bump (rust-version is 1.95; the sandbox gates with stable 1.97). All four are cargo clippy --fix output with the mechanical-collapse indentation hand-tidied to house style; the parser change is semantics-preserving (a non-100-continue Expect value now falls to the _ arm instead of an empty if — headers.append still runs after the match either way). No fmt pass: rustfmt would clobber the aligned-assignment style, so only the touched lines moved. |
||
|
|
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.
|
||
|
|
0531390613 |
feat(channels): core protocol machinery — v0.6 chunk 1
Feature 'channels' (zero new deps): wire-neutral ChannelFrame/FrameRef + Encode/Decode codec traits (one method each, whole-envelope — the codec owns the wire format end to end); Channel trait; ChannelFactory + TopicRouter + shipped PrefixRouter (exact map + head:* prefix map); ChannelSocket (reply/push/broadcast/broadcast_from/broadcast_to); ChannelHub (router + PubSub<Broadcast<P>>, in-runtime + non-static per the v0.5 pattern, hub.upgrade(conn) entry, server-side hub.broadcast). Topology as ratified: one channel actor per (socket, topic), linked to the conn actor, select(&[&inbound, &bus_rx]) with inbound at index 0; subscription pinned to the channel actor pid. Subscribe-before-ok-reply makes the join ack first-class (the v0.5 on_open race, answered structurally). Cleanup is the sender-drop chain (conn handler drops -> inbound closes -> closed arm wakes -> terminate); bus arm dropped from the select set once observed closed (closed arms stay ready forever). Heartbeat answered conn-side on topic 'phoenix'; rejoin replaces the old generation with a phx_close to the old join_ref; lazy prune of dead channel entries on first failed forward (the pubsub discipline). As-landed deviations from the ratified sketch (module docs, veto by diff): join takes &mut self (trait-object callable; construction moved to ChannelFactory which still gets the full raw topic); reply(status, payload) with the ref threaded invisibly (the sketch listed both); broadcast gained the event parameter (unencodable without it); route() returns Arc not Box. Outbound payloads encode from borrows (FrameRef) so bus fan-out never clones P; payload-less control frames (phx_close, error/heartbeat replies) encode as the codec's empty payload rather than conjuring a P. 11 runtime-backed unit tests with a toy pipe codec (core provably JSON-free). Suites: 78 w/ feature, 67 featureless, both green. |
||
|
|
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.
|
||
|
|
341d20ab45 |
feat(pubsub): topic table gen_server — v0.5 chunk 1
urus::pubsub, phoenix_pubsub-shaped, local node only. Independent of HTTP (imports smarm only); crate-extraction candidate. Design as ratified (handoff Q1-Q5, user said "push" on the leans): - Q1 generic: PubSub<M> per instance, zero-cost, no downcasts. Heterogeneous events = app-side enum M. v0.6 channels build on it. - Q2 Receiver: subscribe(topic) -> Receiver<Arc<M>>, fresh channel per subscription, subscriber owns its loop. Fan-in via a Sender-passing subscribe_with is a compatible later addition, deliberately not v1. subscribe_as(pid, topic) pins the subscription lifetime to an explicit pid for relay patterns (session actor subscribes, spawned relay receives) — without it the monitor would watch the wrong actor. - Q3 unbounded: broadcast never blocks the table (bounded-block = head-of-line across ALL topics; bounded-drop = silent loss). Memory risk documented; TWO cleanup paths: monitor Down on subscriber death (eager) + prune-on-send-failure for dropped receivers (lazy, on next broadcast). Bench before sharding. - Q4 user-owned ServerRef wrapper. DISCOVERY: the ratified optional register-by-name helper is NOT implementable against smarm's registry — it maps name -> Pid, and a Pid cannot be turned back into a ServerRef (the ref is the inbox sender). Needs smarm support or a global type-erased map; deferred, noted in module docs. pid() exposed for introspection. - Q5 unique per (pid, topic): HashMap<Topic, HashMap<Pid, Sender>>, subscribe idempotent (replaces sender; stale receiver's channel closes). One monitor per live pid (monitored: HashSet<Pid>, retired in handle_down so slot-reuse pids re-monitor). handle_down does the full-scan cleanup as ratified; pid->topics reverse index is the documented optimization if deaths ever measure hot. Subscribe is a call (table updated before return: a broadcast issued right after by the same caller is seen); unsubscribe/broadcast are casts. subscriber_count(topic) added as a call — needed by the tests to observe async cleanup, legitimate API anyway. 8 runtime-backed unit tests (smarm::run + collect-outside-assert-after pattern from smarm's own gen_server tests), including: Arc payload ptr-equality across subscribers, monitor-path pruning with NO broadcast issued (isolates it from the lazy path), broadcast_from self-skip, resubscribe replacement. Suite: 67 unit + 40 integration + 2 doc, green. smarm PRISTINE. |
||
|
|
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). |
||
|
|
286915329a |
feat(ws): RFC 6455 frame codec — decode/encode, close payloads, assembler (v0.4 chunk 2)
Pure module (src/ws/frame.rs): bytes in, frames out, zero io/actor machinery, so the whole RFC 6455 §5 edge surface lives in fast unit tests ahead of the chunk-3 conn-lifecycle wiring. - decode(buf, require_masked, max_payload) -> Ok(Some((frame, consumed))) | Ok(None: incomplete) | Err(fatal). Server mode requires client masking (§5.1); the flag keeps the codec reusable for a client mode. Enforced: RSV=0, reserved opcodes, control frames FIN+<=125, MINIMAL length encodings (16/64-bit), 64-bit MSB clear. The payload cap is checked from the header BEFORE the payload is buffered — a hostile 8-byte length can't make the conn actor allocate toward it. - Frame::encode is unmask-only (server MUST NOT mask); encode_masked exists for the client side of tests + future client mode. - Close payloads: parse_close_payload validates the §7.4 wire-code ranges (1004/1005/1006/1015 and <1000/1012-2999/>=5000 rejected, 3000-4999 app space allowed), 1-byte payload rejected, UTF-8 reason required; close_payload(code, reason) truncates the reason to the 125-byte control cap on a char boundary. - Assembler (data frames only; control frames interleave at the caller per §5.4): orphan continuation and mid-fragmentation data frames are protocol errors, message cap spans fragments (and the unfragmented fast path), text UTF-8 validated once on completion (whole-message delivery makes incremental validation pointless), reusable after every message/error. - FrameError -> close_code mapping: Protocol=1002, TooLarge=1009, BadUtf8=1007. Tests pin all five §5.7 worked examples (masked Hello bytes verbatim), round-trip the 125/126 and 65535/65536 boundaries, and prove decode returns None at EVERY proper prefix of a frame. 57 unit tests total. |
||
|
|
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 |