35 Commits
Author SHA1 Message Date
Claude 34730930e7 feat(bench): E1 scheduler-count sweep orchestrator
Discriminates herd-contention vs stranding for the ka low-c latency
floor: sweeps URUS_SCHED_THREADS {1,2,4,8} x CONNS {4,8} over the plain
matrix (PLAIN_ONLY; close rides along as control). Per-cell audit
asserts the knob reached the server via plain_serve's stderr line.
Summary parser (req/s + p50/75/90/99, unit-normalized) validated
against the 96b40ad5 sweep output.
2026-07-23 15:00:29 +00:00
Claude 72064c7a79 feat(bench): URUS_SCHED_THREADS knob in plain_serve
E1 (herd-vs-stranding discrimination) sweeps smarm scheduler count at
fixed low concurrency. Strict parse — a malformed value panics instead
of silently reverting to default, and the effective value is logged to
stderr so every bench cell's server.log records it (same per-cell
verification discipline as mode-verify).
2026-07-23 14:59:45 +00:00
claude 14be21db15 feat(bench): PLAIN_ONLY knob — skip causal cells for concurrency sweeps
The c=64 matrix run (job 7dbc1ede) showed plain-mode parity but a 2.2x ka
latency penalty; the decisive test is a CONNS sweep, which only needs the
plain cells. PLAIN_ONLY=1 skips the two causal cells; the summary already
tolerates their absence.
2026-07-20 20:43:32 +00:00
claude 182f4fe602 feat(bench): ka/close A/B matrix — plain_serve baseline + box orchestrator
The jar's ka-vs-close throughput inversion was observed on the box but its
run configuration died with the job workspaces, so it can't be re-derived —
this commit makes the comparison a committed, controlled experiment instead
of a lost one-off.

- examples/plain_serve: the load_profile request path with zero causal
  machinery (same route/handler/Config), serving until killed. The plain
  half of the A/B; throughput measured externally by wrk.
- scripts/ka-close-matrix.sh: {ka,close} x {plain,causal} on fresh ports,
  byte-identical wrk args per mode pair except the Connection: close
  header, disjoint-core pinning for server vs wrk, per-cell curl
  verification of the negotiated connection behavior, ss/env capture, and
  a parsed summary with a close/ka ratio verdict. The causal cells double
  as the forgiveness-fix box validation (v13 pending item, pull-forward
  agreed): key on forgiven staying ~ injected x parked-actors in
  close-mode churn, not process-lifetime phantoms.
2026-07-20 07:01:14 +00:00
Claude 17cd4a5ceb feat(causal): load_profile example — external-loadgen causal target
Server-only discovery tool: real hot path, no planted bottleneck, no
in-process loadgen. wrk (or any external generator, pinned to other
cores) drives GET /json/:id; the sweep runs the causal_bench plan over
the five lib sites. Trust gates only (traffic flowed, ledger printed) —
no known-answer verdict, this one asks the question instead.
2026-07-17 19:36:04 +00:00
Claude 288b52d89c feat(causal): webserver bench with a planted, known-answer bottleneck
examples/causal_bench.rs (required-features smarm-causal): the Phase 2
RFC 007 test case — a real urus server replacing the synthetic demo as
validation workload.

Shape: 16 plain-OS-thread clients hammer GET /order/:id over blocking
loopback keep-alive TCP (OS threads can't absorb injected virtual delay
— the established trick, so only server code is delayable). The handler
calls a single store actor (crud's once-cell pattern, serialization
structural) burning 400µs of calibrated *work* under
causal_site!("store") — work-shaped, not timed, or injection reads as a
no-op. 50µs handler render lands under the enclosing pipeline site.

Known answer: store serialized + saturated => ceiling 2500 rps; speedup
p => x1/(1-p): +33% @25, +100% @50. The five lib sites are tens of µs
and parallel across conns => ~0. Verdict mirrors the demo: store @25 >
+15%, others < +10%, SKIPPED under 4 cores; magnitudes trusted to ~±15%
per the smarm-side validation notes, ranking is the hard check.

1-core sandbox smoke (verdict SKIPPED but numbers indicative): store
+26.2% @25 / +74.0% @50, all other sites within ±2%. Unlike the demo
this workload keeps its bottleneck structure on one core — everything
but the store is IO-parked — and the theory shortfall there is plain
core contention (render/parse share the store's CPU), which the 24-core
sweep should close.
2026-07-13 08:59:49 +00:00
Claude 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.
2026-07-13 08:30:53 +00:00
Claude 072ee126f9 feat: smarm-causal passthrough feature
Mirrors the smarm-trace precedent: urus itself gains no causal code
paths yet — this just lets downstream binaries (the Phase 2 causal
bench lives in examples/) flip smarm's instrumentation on through the
urus dep. Whole suite is green with the feature enabled: the causal
runtime is behaviorally inert while no experiment is active.
2026-07-13 08:25:49 +00:00
Claude 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.
2026-07-13 08:24:31 +00:00
Claude 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.
2026-07-13 08:22:40 +00:00
Claude 86c8d31b93 docs(channels): example, README section, ROADMAP v0.6 mark-done — chunk 4
examples/channels_chat.rs (required-features phoenix): ephemeral
room:* and persistent session:* side by side over phoenix.js V2 JSON,
stdin-driven graceful shutdown (the ws_chat pattern). Smoke-tested
live: rejoin after transport drop lands on the same instance (state
counter persists across three transports), broadcasts carry the new
join generation's ref.

README: channels section (feature split, core trait walkthrough,
session persistence semantics incl. the re-entrant-join contract).
ROADMAP: v0.6 marked done with the full as-landed decision list,
chunked as committed.
2026-06-12 15:26:21 +00:00
Claude 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.
2026-06-12 15:22:54 +00:00
Claude 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.
2026-06-12 14:56:26 +00:00
Claude 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.
2026-06-12 13:53:52 +00:00
Claude a676c01adc docs(roadmap): ratify v0.6 Channels design
User-ratified 2026-06-12. Resolves the v0.6 fork left by the v0.5
handoff: dep #4 is serde/serde_json, but ONLY behind an opt-in
'phoenix' feature (V2 codec + JsonCodec). The 'channels' core takes
zero new deps — payload generic over urus Encode/Decode traits.
Channel trait shape, ChannelSocket, TopicRouter/PrefixRouter, and
opt-in ChannelSession persistence (zero-copy Arc<M> buffer drain)
all specified in the v0.6 section.
2026-06-12 13:42:35 +00:00
Claude 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.
2026-06-12 10:30:08 +00:00
Claude 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.
2026-06-12 10:17:50 +00:00
Claude 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.
2026-06-12 09:44:35 +00:00
Claude 64137cccf0 fix(http): advertise keep-alive on HTTP/1.0 responses we keep open
1.0 defaults to close: honoring 'Connection: Keep-Alive' without echoing
it back left spec-following clients waiting for an EOF that never came
(ab -k deadlocked to its poll timeout on response 1).

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

Tests: unit pair (1.0 echo / 1.1 implicit) + integration pair (socket
actually reused on 1.0; 404 forces close + EOF). Validated against the
original repro: ab -k -n 5000 -c 50 GET /users -> 5000/5000 keep-alive,
0 failed, ~63k rps (~18.9k without keep-alive).
2026-06-12 09:15:11 +00:00
Claude c171be7ddc chore: scripts/hammer.sh — flake-hammer for conn-lifecycle discipline
Default run encodes the pre-commit ritual: 35x the lifecycle-sensitive
integration subset (shutdown timeout reaped slowloris streaming chunked
sse stalled), fail-fast, then 3x the full suite. -n/-f override counts,
'-- <words>' overrides the filter. Failing run's output lands in
target/hammer-fail.log.
2026-06-12 09:10:17 +00:00
Claude f5ccd640fb docs(roadmap): known bug — HTTP/1.0 keep-alive honored but not advertised
Found benching with ab -k: a 1.0 request with Connection: Keep-Alive is
kept alive (second pipelined request answered on the same socket) but
the response never echoes the header. 1.0 clients only reuse on explicit
server opt-in, so they wait for EOF that never comes; ab -k deadlocks to
apr_pollset_poll timeout on the first response. Recorded with repro,
fix direction (echo header in serialise_response on live 1.0 conns, or
force-close 1.0), and the no-keep-alive baseline: GET /users, release,
loopback, logging silenced, c=50 -> ~18.9k req/s, p50 3ms / p99 5ms,
0 failed. Fix deliberately not started: scope (error responses, HEAD)
is a user call.
2026-06-12 06:58:31 +00:00
Claude d96ba88ec8 docs(roadmap): v0.4 chunks 1-2 as-landed, dep #3 spent on sha1_smol 2026-06-12 05:41:44 +00:00
Claude 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.
2026-06-12 05:40:54 +00:00
Claude 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.
2026-06-12 05:38:14 +00:00
Claude 43bd5a91a4 docs: README streaming/SSE sections, ROADMAP v0.3 done, crud SSE ticker (v0.3 chunk 4)
README: write_timeout in the Config sample, read/write timeout-split
semantics spelled out, new Streaming Responses and Server-Sent Events
sections (producer contract, 1.0 fallback, heartbeat liveness), roadmap
summary bumped. ROADMAP: v0.3 marked done with the as-landed decisions
(pull shape per the user's fork answer; Q3 timeout split as proposed)
and verified exit criteria. crud example: GET /ticker SSE route — one
event/second, producer exits on SseClosed or the example's shutdown
flag (so an open ticker doesn't block AllDone past the drain).
2026-06-12 04:59:39 +00:00
Claude 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).
2026-06-12 04:58:13 +00:00
Claude 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).
2026-06-12 04:56:09 +00:00
Claude 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).
2026-06-12 04:53:27 +00:00
Claude 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.
2026-06-11 22:24:02 +00:00
Claude 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.
2026-06-11 22:07:18 +00:00
Claude 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.
2026-06-11 21:45:11 +00:00
Claude 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.
2026-06-11 12:07:19 +00:00
Claude 3da553b9b2 docs(roadmap): state assessment vs smarm v0.8 + v0.2-v0.6 plan
urus builds clean and passes 24/24 against smarm master; the refresh is
OTP adoption, not breakage. v0.2 chunks: supervised listener pool,
graceful shutdown via request_stop, enforce the (currently decorative)
timeouts, registry naming. Then: streaming bodies + SSE (v0.3),
WebSocket upgrade (v0.4), PubSub (v0.5), Channels (v0.6). HTTP/2 stays
iceboxed per spec §2.2. Two decision forks flagged inline: timeout
mechanism (reaper vs smarm timed fd waits) and ws duplex topology
(dup'd fds vs fd-arms-in-select) — both hinge on smarm RFC 008.
2026-06-11 10:48:50 +00:00
Markk116 a644fec6f5 doc: add readme 2026-05-26 23:23:11 +02:00
Markk116 3b6c466210 Initial commit 2026-05-26 23:16:45 +02:00