Compare commits

..
10 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
17 changed files with 846 additions and 78 deletions
+5
View File
@@ -18,6 +18,7 @@ serde_json = { version = "1", optional = true }
[features]
smarm-trace = ["smarm/smarm-trace"]
smarm-causal = ["smarm/smarm-causal"]
channels = []
phoenix = ["channels", "dep:serde", "dep:serde_json"]
@@ -25,6 +26,10 @@ phoenix = ["channels", "dep:serde", "dep:serde_json"]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[[example]]
name = "causal_bench"
required-features = ["smarm-causal"]
[profile.dev]
panic = "unwind"
+6
View File
@@ -378,3 +378,9 @@ reconnect cycles.
- **Bench suite** — `urus-bench-spec.md` exists in the artefact store;
wire it up once v0.2 lands (supervision changes the hot path not at all,
but prove it).
- **Operator introspection via typed names** — the v0.2-era
`urus.server` / `urus.listener.{i}` pid tags were dropped in the
RFC 014 port (names are messageable endpoints now, self-registered
with a real `Sender<M>`). If wanted back, do it properly: register the
serve loop's shutdown/control channel under a typed `urus.server`
name instead of faking pid tags with unit channels.
+322
View File
@@ -0,0 +1,322 @@
//! Causal-profiling bench (RFC 007): a real urus webserver as the workload,
//! with a planted, known-answer bottleneck.
//!
//! Where smarm's `causal_pipeline` demo is synthetic, this exercises the
//! exact machinery the causal commits touched, for real:
//!
//! - epoll parks (`wait_readable_timeout` / `wait_writable_timeout`)
//! → park-gated resume credit
//! - keep-alive / request / write deadlines → timer-heap virtual time
//! - the experiment controller's fixed windows → wall-anchored timers
//! - mixed CPU (parse/serialize) and IO (socket) → non-trivial ranking
//!
//! Shape: `LOAD_CONNS` plain OS threads hammer `GET /order/:id` over
//! blocking loopback TCP (keep-alive). OS threads on the client side are
//! deliberate: they can't absorb injected virtual delay, so the only
//! delayable code is the server's — the same trick the smarm-side timer
//! tests use. The handler calls a single store actor (crud's pattern:
//! one actor owns the data, serialization is structural) which burns
//! `STORE_US` of calibrated *work* — work-shaped, not timed, because a
//! timed wait absorbs injected delay and flattens every experiment.
//!
//! Known answer: the store is serialized and saturated, so it is the
//! throughput ceiling (1e6/STORE_US rps). A virtual speedup of `store`
//! by p% predicts throughput ×1/(1-p): +33% @25%, +100% @50%. The other
//! five sites (parse, router, pipeline, serialize, socket-write — the
//! urus lib instrumentation) are tens of µs and parallel across
//! connections: predicted impact ≈0. Per the smarm-side validation runs,
//! absolute magnitudes are trustworthy to roughly ±15% while the
//! *ranking* is solid — the verdict thresholds reflect that.
//!
//! Run (needs cores; the verdict is SKIPPED below 4):
//! cargo run --release --example causal_bench --features smarm-causal
//!
//! Prints the summary, writes `profile.coz` next to the CWD, and exits
//! nonzero if the expected separation doesn't hold.
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, Instant};
use smarm::{channel, Sender};
use urus::{serve_with_shutdown, shutdown_handle, Config, Conn, Next, Pipeline, Router};
// ---------------------------------------------------------------------------
// Tunables
// ---------------------------------------------------------------------------
/// Planted bottleneck cost: calibrated work per store request, µs.
const STORE_US: u64 = 400;
/// Handler-side render work per request, µs. Runs under the `pipeline`
/// site (no inner guard), parallel across connection actors.
const RENDER_US: u64 = 50;
/// Client connections = OS load threads. Plenty to keep a 400µs store
/// saturated even at a 50% virtual speedup (see saturation note in main).
const LOAD_CONNS: usize = 16;
// ---------------------------------------------------------------------------
// Calibrated work (lifted from smarm's causal_pipeline demo)
// ---------------------------------------------------------------------------
/// LCG-mix `iters` times in dependent sequence (unvectorizable, un-elidable),
/// staying preemptible — and causal-sampleable/delayable — via `check!()`.
fn work_iters(iters: u64) {
let mut acc = 0x2545_f491_4f6c_dd1du64;
let mut i = 0u64;
while i < iters {
let chunk_end = (i + 256).min(iters);
while i < chunk_end {
acc = acc.wrapping_mul(6364136223846793005).wrapping_add(i);
i += 1;
}
std::hint::black_box(acc);
smarm::check!();
}
}
/// Iterations per microsecond on this machine, so stage costs are
/// meaningful in time while staying work-shaped.
fn calibrate_iters_per_us() -> u64 {
let n = 8_000_000u64;
let t = Instant::now();
work_iters(n);
(n / (t.elapsed().as_micros().max(1) as u64)).max(1)
}
static PER_US: OnceLock<u64> = OnceLock::new();
fn work_us(us: u64) {
work_iters(us * PER_US.get().copied().unwrap_or(1));
}
// ---------------------------------------------------------------------------
// Store actor — the planted bottleneck (crud's once-cell pattern)
// ---------------------------------------------------------------------------
enum Req {
Get { id: u64, reply: Sender<u64> },
}
/// Set at teardown; the store polls it (a cross-thread `Sender::send`
/// can't wake a parked actor, so a bare `recv` would hang AllDone —
/// same limitation crud's store works around).
static STORE_STOP: AtomicBool = AtomicBool::new(false);
static STORE_TX: OnceLock<Sender<Req>> = OnceLock::new();
fn store_loop(rx: smarm::Receiver<Req>) {
loop {
let req = match rx.recv_timeout(Duration::from_millis(250)) {
Ok(r) => r,
Err(smarm::channel::RecvTimeoutError::Timeout) => {
if STORE_STOP.load(Ordering::Relaxed) {
return;
}
continue;
}
Err(smarm::channel::RecvTimeoutError::Disconnected) => return,
};
let Req::Get { id, reply } = req;
{
// The known-answer site: serialized by construction (one
// actor, one request at a time), saturated by the load.
let _g = smarm::causal_site!("store");
work_us(STORE_US);
}
let _ = reply.send(id);
}
}
/// Spawned from the first handler that runs — connection actors are
/// inside the runtime, which `spawn` requires; the static then shares
/// the Sender with every later handler.
fn store() -> &'static Sender<Req> {
STORE_TX.get_or_init(|| {
let (tx, rx) = channel::<Req>();
smarm::spawn(move || store_loop(rx));
tx
})
}
// ---------------------------------------------------------------------------
// Handler
// ---------------------------------------------------------------------------
fn order(conn: Conn, _next: Next) -> Conn {
let id: u64 = conn.params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
let (tx, rx) = channel::<u64>();
store().send(Req::Get { id, reply: tx }).ok();
let got = rx.recv().expect("store dropped");
// Render work: attributed to the enclosing `pipeline` site.
work_us(RENDER_US);
conn.put_status(200).put_body(got.to_string())
}
// ---------------------------------------------------------------------------
// Load generation — plain OS threads, blocking loopback TCP, keep-alive
// ---------------------------------------------------------------------------
/// Read one HTTP/1.1 response (headers + content-length body) into `buf`,
/// draining exactly what was consumed. Errors mean: reconnect.
fn read_response(s: &mut TcpStream, buf: &mut Vec<u8>) -> std::io::Result<()> {
let mut tmp = [0u8; 4096];
let header_end = loop {
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break i + 4;
}
let n = s.read(&mut tmp)?;
if n == 0 {
return Err(std::io::ErrorKind::UnexpectedEof.into());
}
buf.extend_from_slice(&tmp[..n]);
};
let head = String::from_utf8_lossy(&buf[..header_end]).to_ascii_lowercase();
let len: usize = head
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
while buf.len() < header_end + len {
let n = s.read(&mut tmp)?;
if n == 0 {
return Err(std::io::ErrorKind::UnexpectedEof.into());
}
buf.extend_from_slice(&tmp[..n]);
}
buf.drain(..header_end + len);
Ok(())
}
fn load_loop(port: u16, stop: Arc<AtomicBool>) {
let mut n: u64 = 1;
'outer: while !stop.load(Ordering::Relaxed) {
let mut s = match TcpStream::connect(("127.0.0.1", port)) {
Ok(s) => s,
Err(_) => {
std::thread::sleep(Duration::from_millis(10));
continue;
}
};
s.set_read_timeout(Some(Duration::from_secs(5))).ok();
s.set_nodelay(true).ok();
let mut buf = Vec::with_capacity(4096);
while !stop.load(Ordering::Relaxed) {
let req = format!("GET /order/{n} HTTP/1.1\r\nhost: bench\r\n\r\n");
if s.write_all(req.as_bytes()).is_err() {
continue 'outer;
}
if read_response(&mut s, &mut buf).is_err() {
continue 'outer;
}
n += 1;
}
}
}
// ---------------------------------------------------------------------------
// main — boot, load, sweep, verdict
// ---------------------------------------------------------------------------
fn main() {
let per_us = calibrate_iters_per_us();
PER_US.set(per_us).expect("PER_US set twice");
println!("calibration: {per_us} work iters/µs");
println!(
"planted: store {STORE_US}µs serialized (ceiling ≈{} rps), render {RENDER_US}µs, {LOAD_CONNS} conns",
1_000_000 / STORE_US
);
println!("theory: store +33% @25%, +100% @50%; every other site ≈0");
// Ephemeral port via bind-and-release (integration-test pattern; the
// race window before the server rebinds is far below flake threshold).
let port = {
let l = TcpListener::bind("127.0.0.1:0").expect("bind probe");
l.local_addr().expect("local_addr").port()
};
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().expect("addr");
let (handle, signal) = shutdown_handle();
let server = std::thread::spawn(move || {
let pipe = Pipeline::new().plug(Router::new().get("/order/:id", order));
serve_with_shutdown(Config::new(addr), pipe, signal).expect("serve");
});
// Wait until it's accepting.
let up = (0..100).any(|_| {
std::thread::sleep(Duration::from_millis(50));
TcpStream::connect(addr).is_ok()
});
assert!(up, "server didn't come up on {addr}");
let stop = Arc::new(AtomicBool::new(false));
let loaders: Vec<_> = (0..LOAD_CONNS)
.map(|_| {
let stop = stop.clone();
std::thread::spawn(move || load_loop(port, stop))
})
.collect();
// Warm up: queues to steady state, every site + the progress point
// registered by real traffic before the sweep enumerates them.
std::thread::sleep(Duration::from_millis(500));
// The controller runs on this plain OS thread: its windows are wall
// time by construction here; inside a runtime they'd be wall-anchored
// via the efbc254 timer path (the thing the jobrunner sweep confirms).
let results = smarm::causal::run_experiments(&smarm::causal::ExperimentPlan {
speedups_pct: vec![0, 25, 50],
experiment: Duration::from_millis(700),
cooldown: Duration::from_millis(150),
});
stop.store(true, Ordering::Relaxed);
for l in loaders {
l.join().expect("loader panicked");
}
STORE_STOP.store(true, Ordering::Relaxed);
handle.shutdown();
server.join().expect("server panicked");
print!("{}", smarm::causal::render_summary(&results));
match std::fs::write("profile.coz", smarm::causal::render_coz(&results)) {
Ok(()) => println!("\nwrote profile.coz"),
Err(e) => eprintln!("\nfailed to write profile.coz: {e}"),
}
// Verdict — same discipline as the smarm demo: skip where the
// separation physically can't exist.
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
if cores < 4 {
println!("verdict: SKIPPED ({cores} cores; separation needs real parallelism)");
return;
}
let mut failures: Vec<String> = Vec::new();
let impact =
|site: &str| smarm::causal::impact_pct(&results, site, 25, "responses");
let mut expect = |site: &str, ok: &dyn Fn(f64) -> bool, want: &str| match impact(site) {
Some(p) => {
let verdict = if ok(p) { "ok" } else { "FAIL" };
println!("verdict: {site} @25% -> {p:+.1}% (want {want}) {verdict}");
if !ok(p) {
failures.push(format!("{site}: {p:+.1}% (want {want})"));
}
}
None => {
println!("verdict: {site} @25% -> missing cell FAIL");
failures.push(format!("{site}: missing cell"));
}
};
expect("store", &|p| p > 15.0, "> +15%");
for site in ["parse", "router", "pipeline", "serialize", "socket-write"] {
expect(site, &|p| p < 10.0, "< +10%");
}
if failures.is_empty() {
println!("verdict: PASS — planted bottleneck found, cold sites quiet");
} else {
println!("verdict: FAIL — {}", failures.join("; "));
std::process::exit(1);
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Causal load-profiling target (RFC 007): the real urus hot path under
//! EXTERNAL load — no planted bottleneck, no in-process load generation.
//!
//! Where `causal_bench` validates the machinery against a planted,
//! known-answer store, this is the discovery tool: boot a bare server,
//! let an external generator (wrk, pinned to different cores) drive it,
//! sweep virtual speedups over the five lib sites (parse, router,
//! pipeline, serialize, socket-write), and report which one causally
//! limits throughput. External load is deliberate: the generator lives
//! outside the smarm runtime, so it cannot absorb injected virtual
//! delay — the same property `causal_bench` gets from plain OS threads.
//!
//! No pass/fail verdict — there is no known answer here. Trust gates
//! only: traffic actually flowed through the sweep, and the ledger
//! audit (printed) balances.
//!
//! Env:
//! URUS_PORT listen port (default 8080; binds 0.0.0.0)
//! COZ_OUT coz profile output path (default profile.coz)
//! WARMUP_MS settle time after first traffic, ms (default 2000)
//!
//! Orchestration contract (the jobrunner run.sh): start this, wait for
//! it to accept, point wrk at `GET /json/:id` with a duration that
//! outlives the sweep, and stop wrk when this process exits.
//!
//! cargo run --release --example load_profile --features smarm-causal
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use urus::{serve_with_shutdown, shutdown_handle, Config, Conn, Next, Pipeline, Router};
fn env_u64(name: &str, default: u64) -> u64 {
std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}
/// The whole handler: param parse + JSON render. Deliberately thin — the
/// subject is the lib path around it, not application work.
fn json_id(conn: Conn, _next: Next) -> Conn {
let id: u64 = conn.params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
conn.put_status(200)
.put_header("content-type", "application/json")
.put_body(format!("{{\"id\":{id}}}"))
}
fn main() {
let port = env_u64("URUS_PORT", 8080) as u16;
let warmup = Duration::from_millis(env_u64("WARMUP_MS", 2000));
let coz_out = std::env::var("COZ_OUT").unwrap_or_else(|_| "profile.coz".into());
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("addr");
let (handle, signal) = shutdown_handle();
let server = std::thread::spawn(move || {
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
serve_with_shutdown(Config::new(addr), pipe, signal).expect("serve");
});
// Readiness is the orchestrator's job (TCP probe); ours is to not
// sweep before real traffic has registered every site and the
// progress point — `run_experiments` enumerates *registered* sites.
// 100 completed responses guarantees full end-to-end coverage.
let responses = |snap: &[(String, u64)]| {
snap.iter().find(|(n, _)| n == "responses").map(|(_, c)| *c).unwrap_or(0)
};
let t0 = Instant::now();
let seen = loop {
let n = responses(&smarm::causal::progress_snapshot());
if n >= 100 {
break n;
}
assert!(
t0.elapsed() < Duration::from_secs(120),
"no load after 120s ({n} responses) — is the generator running?"
);
std::thread::sleep(Duration::from_millis(100));
};
println!("traffic up: {seen} responses; settling {warmup:?}");
std::thread::sleep(warmup);
// Same plan as causal_bench, for comparability across workloads.
let results = smarm::causal::run_experiments(&smarm::causal::ExperimentPlan {
speedups_pct: vec![0, 25, 50],
experiment: Duration::from_millis(700),
cooldown: Duration::from_millis(150),
});
handle.shutdown();
server.join().expect("server panicked");
print!("{}", smarm::causal::render_summary(&results));
print!("{}", smarm::causal::render_ledger_audit(&results));
match std::fs::write(&coz_out, smarm::causal::render_coz(&results)) {
Ok(()) => println!("\nwrote {coz_out}"),
Err(e) => eprintln!("\nfailed to write {coz_out}: {e}"),
}
// Trust gate: the sweep is meaningless if load didn't flow through it.
let total: u64 = results
.iter()
.flat_map(|r| r.deltas.iter())
.filter(|(n, _)| n == "responses")
.map(|(_, c)| c)
.sum();
println!("total responses across windows: {total}");
assert!(total > 0, "sweep saw zero progress");
}
+48
View File
@@ -0,0 +1,48 @@
//! Plain benchmark server: the `load_profile` request path with zero causal
//! machinery — the baseline half of the ka/close A/B matrix (the
//! throughput-inversion chase).
//!
//! Identical route, handler, and `Config` construction to `load_profile`;
//! differs only in having no `smarm-causal` feature, no sweep, and no
//! shutdown path — it serves until killed. Throughput is measured
//! externally (wrk).
//!
//! Env:
//! URUS_PORT listen port (default 8080; binds 0.0.0.0)
//! URUS_SCHED_THREADS smarm scheduler OS threads (default: smarm's own
//! default). Malformed values panic rather than
//! silently falling back — a benchmark knob that
//! quietly reverts to default poisons the cell.
//!
//! cargo run --release --example plain_serve
use std::net::SocketAddr;
use urus::{serve_with, Config, Conn, Next, Pipeline, Router};
/// Mirrors `load_profile`'s handler byte for byte: param parse + JSON render.
fn json_id(conn: Conn, _next: Next) -> Conn {
let id: u64 = conn.params.get("id").and_then(|s| s.parse().ok()).unwrap_or(0);
conn.put_status(200)
.put_header("content-type", "application/json")
.put_body(format!("{{\"id\":{id}}}"))
}
fn main() {
let port: u16 = std::env::var("URUS_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8080);
let addr: SocketAddr = format!("0.0.0.0:{port}").parse().expect("addr");
let sched_threads: Option<usize> = std::env::var("URUS_SCHED_THREADS").ok().map(|v| {
v.parse()
.unwrap_or_else(|_| panic!("URUS_SCHED_THREADS not a usize: {v:?}"))
});
// Audit line: lands in each bench cell's server.log so the effective
// scheduler count is recorded per cell, same discipline as mode-verify.
eprintln!("plain_serve: scheduler_threads={sched_threads:?}");
let mut cfg = Config::new(addr);
cfg.scheduler_threads = sched_threads;
let pipe = Pipeline::new().plug(Router::new().get("/json/:id", json_id));
serve_with(cfg, pipe).expect("serve");
}
+1 -1
View File
@@ -92,7 +92,7 @@ impl WsHandler for ChatHandler {
// subscribed as.
let _ = self
.bus()
.broadcast_from(smarm::self_pid(), &self.topic(), text);
.broadcast_from(smarm::self_pid(), self.topic(), text);
}
fn on_close(&mut self, _code: Option<u16>, _reason: &str) {
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# E1: does the ka low-concurrency latency floor track idle-scheduler count?
#
# Mechanism under test (smarm): one shared level-triggered wake pipe; every
# completion byte wakes ALL idle schedulers; one drain-lock winner; losers
# stampede the timers/io/queue mutexes and re-sleep; `enqueue` is silent.
# Prediction if right: at c <= 8, ka latency tail shrinks and throughput
# rises as scheduler count drops (fewer idle pollers -> smaller herd);
# close mode (control) stays flat or worsens as threads drop.
#
# Sweeps URUS_SCHED_THREADS x CONNS over the plain ka/close matrix,
# PLAIN_ONLY — causal cells are irrelevant to E1. wrk side is byte-identical
# to the 96b40ad5 conns sweep (THREADS=4, --latency) for comparability.
#
# Knobs: THREADS_SET CONNS_SET DUR REPS OUT (+ everything the matrix takes)
set -euo pipefail
cd "$(dirname "$0")/.."
THREADS_SET="${THREADS_SET:-1 2 4 8}"
CONNS_SET="${CONNS_SET:-4 8}"
DUR="${DUR:-15}"
REPS="${REPS:-2}"
OUT="${OUT:-/workspace/results}"
mkdir -p "$OUT"
say() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$OUT/e1.log"; }
for t in $THREADS_SET; do
for c in $CONNS_SET; do
cell="$OUT/t${t}-c${c}"
say "=== E1 cell: sched_threads=$t conns=$c -> $cell ==="
URUS_SCHED_THREADS="$t" PLAIN_ONLY=1 \
DUR="$DUR" REPS="$REPS" CONNS="$c" OUT="$cell" \
bash scripts/ka-close-matrix.sh
# Cell audit: the knob must have actually reached the server. A cell
# whose server silently ran at default threads poisons the sweep — the
# exact failure class per-cell verification exists to catch.
for m in ka close; do
if ! grep -q "scheduler_threads=Some($t)" "$cell/plain-$m/server.log"; then
say "FATAL: t=$t c=$c mode=$m server.log lacks scheduler_threads=Some($t)"
exit 1
fi
done
say "cell t=$t c=$c audit OK"
done
done
say "=== E1 SUMMARY ==="
python3 - "$OUT" <<'PY' | tee -a "$OUT/e1.log"
import re, sys, pathlib, statistics
out = pathlib.Path(sys.argv[1])
def ms(tok):
m = re.match(r"([\d.]+)(us|ms|s)$", tok)
if not m:
return None
v = float(m.group(1))
return {"us": v / 1000, "ms": v, "s": v * 1000}[m.group(2)]
def cell_stats(d):
reps, pcts = [], {}
for rep in sorted(d.glob("rep*.txt")):
t = rep.read_text()
m = re.search(r"Requests/sec:\s+([\d.]+)", t)
if m:
reps.append(float(m.group(1)))
for p, tok in re.findall(r"^\s+(50|75|90|99)%\s+(\S+)$", t, re.M):
pcts.setdefault(p, []).append(ms(tok))
if not reps:
return None
return (statistics.mean(reps),
{p: statistics.mean([v for v in vs if v is not None])
for p, vs in pcts.items()})
cells = sorted(out.glob("t*-c*"))
hdr = f"{'cell':>10} {'mode':>6} {'req/s':>9} {'p50ms':>7} {'p75ms':>7} {'p90ms':>7} {'p99ms':>7}"
print(hdr); print("-" * len(hdr))
for cell in cells:
for mode in ("ka", "close"):
s = cell_stats(cell / f"plain-{mode}")
if s is None:
continue
rps, p = s
print(f"{cell.name:>10} {mode:>6} {rps:>9.0f} "
f"{p.get('50', float('nan')):>7.3f} {p.get('75', float('nan')):>7.3f} "
f"{p.get('90', float('nan')):>7.3f} {p.get('99', float('nan')):>7.3f}")
PY
say "=== E1 DONE ==="
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env bash
# ka-close-matrix.sh — controlled ka/close A/B for the throughput-inversion
# chase (handoff jar item), with the forgiveness-fix box validation folded
# into the causal cells (handoff v13 PENDING, pull-forward agreed 2026-07-20).
#
# Cells, run in order on fresh ports:
# plain-ka, plain-close examples/plain_serve (no causal feature).
# Metric: wrk Requests/sec, REPS reps after a
# discarded warmup.
# causal-ka, causal-close examples/load_profile (smarm-causal). Metric:
# the sweep summary + ledger audit (forgiveness
# column, books balance); wrk is backdrop load
# whose own numbers are injection-contaminated.
#
# Controls: byte-identical wrk invocation per mode pair except the
# `Connection: close` header; server and wrk pinned to disjoint core sets
# (SMT siblings left idle by default on the 5900X); the negotiated
# connection behavior is verified via curl per cell and logged, so a
# loadgen-config asymmetry can never silently explain a result again.
#
# Knobs (env, defaults for the 5900X box):
# DUR=30 REPS=2 CONNS=64 THREADS=4 PIN=1
# SERVER_CPUS=0-7 WRK_CPUS=8-11
# CAUSAL_WRK_DUR=300 BASE_PORT=8080
# OUT=/workspace/results
# PLAIN_BIN, CAUSAL_BIN binary paths (default: target/release/examples/*)
set -euo pipefail
cd "$(dirname "$0")/.."
DUR="${DUR:-30}"
REPS="${REPS:-2}"
CONNS="${CONNS:-64}"
THREADS="${THREADS:-4}"
PIN="${PIN:-1}"
SERVER_CPUS="${SERVER_CPUS:-0-7}"
WRK_CPUS="${WRK_CPUS:-8-11}"
CAUSAL_WRK_DUR="${CAUSAL_WRK_DUR:-300}"
BASE_PORT="${BASE_PORT:-8080}"
OUT="${OUT:-/workspace/results}"
PLAIN_BIN="${PLAIN_BIN:-target/release/examples/plain_serve}"
CAUSAL_BIN="${CAUSAL_BIN:-target/release/examples/load_profile}"
mkdir -p "$OUT"
say() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$OUT/matrix.log"; }
pin_server=(); pin_wrk=()
if [ "$PIN" = 1 ]; then
pin_server=(taskset -c "$SERVER_CPUS")
pin_wrk=(taskset -c "$WRK_CPUS")
fi
# ---- environment record --------------------------------------------------
{
date -u
uname -a
echo "nproc: $(nproc)"
lscpu -e 2>/dev/null || true
echo "port range: $(cat /proc/sys/net/ipv4/ip_local_port_range 2>/dev/null)"
echo "somaxconn: $(cat /proc/sys/net/core/somaxconn 2>/dev/null)"
echo "wrk: $(wrk --version 2>&1 | head -1 || true)"
echo "PIN=$PIN SERVER_CPUS=$SERVER_CPUS WRK_CPUS=$WRK_CPUS"
echo "DUR=$DUR REPS=$REPS CONNS=$CONNS THREADS=$THREADS CAUSAL_WRK_DUR=$CAUSAL_WRK_DUR"
} > "$OUT/env.txt"
say "env recorded -> env.txt"
# ---- helpers -------------------------------------------------------------
run_wrk() { # $1=mode $2=duration_s $3=port
if [ "$1" = close ]; then
"${pin_wrk[@]}" wrk -t "$THREADS" -c "$CONNS" -d "${2}s" --latency \
-H "Connection: close" "http://127.0.0.1:$3/json/7"
else
"${pin_wrk[@]}" wrk -t "$THREADS" -c "$CONNS" -d "${2}s" --latency \
"http://127.0.0.1:$3/json/7"
fi
}
wait_port() { # $1=port
for _ in $(seq 1 150); do
curl -s -o /dev/null "http://127.0.0.1:$1/json/1" && return 0
sleep 0.2
done
return 1
}
verify_mode() { # $1=mode $2=port — record what actually goes over the wire
echo "--- single request, mode=$1 ---"
if [ "$1" = close ]; then
curl -sv --http1.1 -H "Connection: close" -o /dev/null \
"http://127.0.0.1:$2/json/7" 2>&1 | grep -iE "^(> |< )(GET|HTTP|connection)" || true
else
curl -sv --http1.1 -o /dev/null "http://127.0.0.1:$2/json/7" 2>&1 \
| grep -iE "^(> |< )(GET|HTTP|connection)" || true
fi
echo "--- reuse probe (two requests, one curl) ---"
curl -sv --http1.1 -o /dev/null -o /dev/null \
"http://127.0.0.1:$2/json/1" "http://127.0.0.1:$2/json/2" 2>&1 \
| grep -icE "re-us(ed|ing)" || true
}
# ---- plain cells ---------------------------------------------------------
run_plain() { # $1=mode $2=port
local mode="$1" port="$2" d="$OUT/plain-$1"
mkdir -p "$d"
say "=== plain / $mode (port $port) ==="
URUS_PORT="$port" "${pin_server[@]}" "$PLAIN_BIN" > "$d/server.log" 2>&1 &
local spid=$!
wait_port "$port" || { say "FATAL: plain server never came up"; cat "$d/server.log"; exit 1; }
verify_mode "$mode" "$port" > "$d/mode-verify.txt" 2>&1
ss -s > "$d/ss-before.txt" 2>/dev/null || true
run_wrk "$mode" 5 "$port" > "$d/warmup.txt" 2>&1
for r in $(seq 1 "$REPS"); do
run_wrk "$mode" "$DUR" "$port" > "$d/rep$r.txt" 2>&1
grep -E "Requests/sec|Latency |requests in|Socket errors|Non-2xx" "$d/rep$r.txt" \
| sed "s/^/ [plain-$mode r$r] /" | tee -a "$OUT/matrix.log" || true
done
ss -s > "$d/ss-after.txt" 2>/dev/null || true
awk '{printf "server cpu jiffies (utime+stime): %d\n", $14+$15}' \
"/proc/$spid/stat" > "$d/server-cpu.txt" 2>/dev/null || true
kill "$spid" 2>/dev/null || true
wait "$spid" 2>/dev/null || true
}
# ---- causal cells --------------------------------------------------------
run_causal() { # $1=mode $2=port
local mode="$1" port="$2" d="$OUT/causal-$1"
mkdir -p "$d"
say "=== causal / $mode (port $port) ==="
URUS_PORT="$port" COZ_OUT="$d/profile.coz" WARMUP_MS=2000 \
"${pin_server[@]}" "$CAUSAL_BIN" > "$d/sweep.log" 2>&1 &
local spid=$!
wait_port "$port" || { say "FATAL: causal server never came up"; cat "$d/sweep.log"; exit 1; }
verify_mode "$mode" "$port" > "$d/mode-verify.txt" 2>&1
run_wrk "$mode" "$CAUSAL_WRK_DUR" "$port" > "$d/wrk-backdrop.txt" 2>&1 &
local wpid=$!
local rc=0
wait "$spid" || rc=$?
kill "$wpid" 2>/dev/null || true
wait "$wpid" 2>/dev/null || true
say "causal/$mode server exit=$rc (sweep + audit in sweep.log)"
if [ "$rc" -ne 0 ]; then say "WARNING: causal/$mode exited nonzero"; fi
}
# ---- matrix --------------------------------------------------------------
run_plain ka "$BASE_PORT"
run_plain close "$((BASE_PORT + 1))"
if [ "${PLAIN_ONLY:-0}" != 1 ]; then
run_causal ka "$((BASE_PORT + 2))"
run_causal close "$((BASE_PORT + 3))"
fi
# ---- summary -------------------------------------------------------------
say "=== SUMMARY ==="
python3 - "$OUT" <<'PY' | tee -a "$OUT/matrix.log"
import re, sys, pathlib, statistics
out = pathlib.Path(sys.argv[1])
means = {}
for mode in ("ka", "close"):
vals = []
for rep in sorted((out / f"plain-{mode}").glob("rep*.txt")):
m = re.search(r"Requests/sec:\s+([\d.]+)", rep.read_text())
if m:
vals.append(float(m.group(1)))
if vals:
means[mode] = statistics.mean(vals)
print(f"plain-{mode}: reps={[f'{v:.0f}' for v in vals]} mean={means[mode]:.0f} req/s")
if len(means) == 2:
r = means["close"] / means["ka"]
print(f"close/ka ratio: {r:.3f}", "-> INVERSION PRESENT (close beats ka)" if r > 1.05
else "-> no inversion (ka >= close)" if r < 0.95 else "-> parity")
for mode in ("ka", "close"):
log = out / f"causal-{mode}" / "sweep.log"
if log.exists():
t = log.read_text()
keep = [l for l in t.splitlines()
if re.search(r"forgiv|books|audit|balance|total responses", l, re.I)]
print(f"-- causal-{mode} audit lines --")
for l in keep[:14]:
print(" " + l)
PY
say "=== MATRIX DONE ==="
+7 -6
View File
@@ -48,8 +48,8 @@
use super::*;
use smarm::gen_server::{self, GenServer, ServerCtx};
use smarm::{Down, ServerRef, Watcher};
use smarm::gen_server::{self, GenServer, GenServerCtx};
use smarm::{Down, GenServerRef, Watcher};
use std::collections::VecDeque;
use std::hash::Hash;
@@ -87,7 +87,7 @@ pub trait ChannelSession<P>: Send + 'static {
pub(super) struct SessionFactory<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> {
inner: Arc<dyn ChannelFactory<P>>,
keyfn: fn(&str, &P) -> K,
registry: ServerRef<Registry<P, K>>,
registry: GenServerRef<Registry<P, K>>,
}
/// The registry's working bounds for a session key.
@@ -140,14 +140,14 @@ pub(super) struct Join<P: Send + Sync + 'static, K> {
inbound: Receiver<In<P>>,
}
struct Registry<P: Send + Sync + 'static, K> {
struct Registry<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> {
factory: Arc<dyn ChannelFactory<P>>,
cap: usize,
ttl: Duration,
sessions: HashMap<K, (Pid, Sender<Ctl<P>>)>,
/// Reverse index for `handle_down` — the reason `Key: Clone`.
pid_key: HashMap<Pid, K>,
watcher: Option<Watcher>,
watcher: Option<Watcher<Registry<P, K>>>,
}
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> GenServer for Registry<P, K> {
@@ -155,8 +155,9 @@ impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> GenServer for Re
type Reply = ();
type Cast = Join<P, K>;
type Info = ();
type Timer = ();
fn init(&mut self, ctx: &ServerCtx) {
fn init(&mut self, ctx: &GenServerCtx<Self>) {
self.watcher = Some(ctx.watcher());
}
+2 -4
View File
@@ -158,7 +158,9 @@ impl Body {
// Empty `Vec`s are skipped by the pump (a zero-length chunk would
// terminate chunked framing early), so they are safe to send but useless.
#[derive(Default)]
pub enum RespBody {
#[default]
Empty,
Bytes(Vec<u8>),
Stream(StreamBody),
@@ -217,10 +219,6 @@ impl RespBody {
}
}
impl Default for RespBody {
fn default() -> Self { RespBody::Empty }
}
// ---------------------------------------------------------------------------
// Params — path parameters extracted by the router.
// ---------------------------------------------------------------------------
+28 -7
View File
@@ -19,7 +19,7 @@ use crate::net::OwnedFd;
use crate::parser::{self, ParseError};
use crate::plug::Pipeline;
use smarm::ServerRef;
use smarm::GenServerRef;
use std::io::{self, ErrorKind};
use std::os::fd::RawFd;
@@ -92,7 +92,7 @@ pub fn run_connection(
fd: OwnedFd,
pipeline: Pipeline,
limits: ConnLimits,
registry: ServerRef<ConnRegistry>,
registry: GenServerRef<ConnRegistry>,
) {
// The OwnedFd cleans up via Drop on any exit path (panic, error, or
// normal close). No explicit close calls below.
@@ -157,11 +157,11 @@ pub fn run_connection(
// If client sent `Expect: 100-continue`, emit it before reading the
// body. RFC 7231 §5.1.1. We don't gate on app logic here; v1 always
// accepts.
if parsed.expect_100 {
if write_all(raw, b"HTTP/1.1 100 Continue\r\n\r\n", Instant::now() + limits.write_timeout).is_err() {
if parsed.expect_100
&& write_all(raw, b"HTTP/1.1 100 Continue\r\n\r\n", Instant::now() + limits.write_timeout).is_err()
{
return;
}
}
// `consumed_past_head`: how many RAW bytes of `buf` past the head
// this request's body occupied — for chunked bodies that is framing
@@ -209,6 +209,8 @@ pub fn run_connection(
// Catch panics at the actor boundary — a panicking handler should
// not take down the whole connection silently with no response.
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
#[cfg(feature = "smarm-causal")]
let _g = smarm::causal_site!("pipeline");
pipeline.run(conn)
}));
@@ -273,7 +275,11 @@ pub fn run_connection(
let keep_alive = keep_alive
&& !(version == HttpVersion::Http10 && (is_stream || is_error));
let head_bytes = parser::serialise_response(&response_conn, keep_alive);
let head_bytes = {
#[cfg(feature = "smarm-causal")]
let _g = smarm::causal_site!("serialize");
parser::serialise_response(&response_conn, keep_alive)
};
if write_all(raw, &head_bytes, Instant::now() + limits.write_timeout).is_err() {
return;
}
@@ -285,10 +291,15 @@ pub fn run_connection(
}
if !chunked {
// EOF delimits the HTTP/1.0 stream body.
smarm::progress!("responses");
return;
}
}
// A full response (head + body, streamed or not) is on the wire:
// the unit of useful work for causal profiling (RFC 007).
smarm::progress!("responses");
// ----- 5. Loop or close. -----
if !keep_alive {
return;
@@ -358,7 +369,12 @@ fn read_head(
// Try to parse what we already have. On the first iteration of a
// fresh keep-alive cycle, `buf` may already hold the next request.
if !buf.is_empty() {
match parser::parse_head(buf, limits.max_headers) {
let head = {
#[cfg(feature = "smarm-causal")]
let _g = smarm::causal_site!("parse");
parser::parse_head(buf, limits.max_headers)
};
match head {
Ok(h) => {
let deadline = request_deadline
.unwrap_or_else(|| Instant::now() + limits.request_timeout);
@@ -634,6 +650,11 @@ fn try_write_once(fd: RawFd, buf: &[u8]) {
// wait_writable forever (the write-side twin of slowloris).
pub(crate) fn write_all(fd: RawFd, mut buf: &[u8], deadline: Instant) -> io::Result<()> {
// The whole loop — writability parks included — runs under the
// `socket-write` causal site: a park inside a site is exactly what
// RFC 007's park-gated resume credit exists to attribute.
#[cfg(feature = "smarm-causal")]
let _g = smarm::causal_site!("socket-write");
while !buf.is_empty() {
// Park on writability before each syscall, bounded by the budget.
let remaining = deadline.saturating_duration_since(Instant::now());
+6 -5
View File
@@ -33,7 +33,7 @@
//! (roadmap v0.4+), which is why it exists as its own module rather than
//! being inlined into `serve`.
use smarm::{GenServer, Pid, ServerBuilder, ServerRef};
use smarm::{GenServer, Pid, GenServerBuilder, GenServerRef};
use std::collections::HashMap;
@@ -86,6 +86,7 @@ impl GenServer for ConnRegistry {
type Reply = Reply;
type Cast = Cast;
type Info = ();
type Timer = ();
fn handle_call(&mut self, request: Call) -> Reply {
match request {
@@ -137,8 +138,8 @@ impl GenServer for ConnRegistry {
}
}
pub fn start() -> ServerRef<ConnRegistry> {
ServerBuilder::new(ConnRegistry::default()).start()
pub fn start() -> GenServerRef<ConnRegistry> {
GenServerBuilder::new(ConnRegistry::default()).start()
}
// ---------------------------------------------------------------------------
@@ -149,13 +150,13 @@ pub fn start() -> ServerRef<ConnRegistry> {
/// unwind, and panic unwind alike; the cast is infallible from the
/// guard's perspective (a dead registry just returns an ignored Err).
pub struct DeregisterGuard {
registry: ServerRef<ConnRegistry>,
registry: GenServerRef<ConnRegistry>,
pid: Pid,
make: fn(Pid) -> Cast,
}
impl DeregisterGuard {
pub fn new(registry: ServerRef<ConnRegistry>, pid: Pid, make: fn(Pid) -> Cast) -> Self {
pub fn new(registry: GenServerRef<ConnRegistry>, pid: Pid, make: fn(Pid) -> Cast) -> Self {
Self { registry, pid, make }
}
}
+1 -3
View File
@@ -123,11 +123,9 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
"connection" => {
connection_hdr = Some(value.to_ascii_lowercase());
}
"expect" => {
if value.eq_ignore_ascii_case("100-continue") {
"expect" if value.eq_ignore_ascii_case("100-continue") => {
expect_100 = true;
}
}
_ => {}
}
headers.append(&name_lower, value.to_string());
+7 -6
View File
@@ -58,7 +58,7 @@
//! # Why there is no `register(name)` helper (yet)
//!
//! smarm's registry maps `name → Pid`, but a `Pid` cannot be turned back
//! into a `ServerRef` (the ref *is* the inbox sender). A useful named
//! into a `GenServerRef` (the ref *is* the inbox sender). A useful named
//! lookup therefore needs either smarm support (registry-held senders)
//! or a process-global type-erased map here — both against the grain of
//! the ratified design. Deferred; pass the handle.
@@ -67,8 +67,8 @@ use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
use smarm::gen_server::{self, GenServer, ServerCtx};
use smarm::{channel, Down, Pid, Receiver, Sender, ServerRef, Watcher};
use smarm::gen_server::{self, GenServer, GenServerCtx};
use smarm::{channel, Down, Pid, Receiver, Sender, GenServerRef, Watcher};
// ---------------------------------------------------------------------------
// Public handle
@@ -92,7 +92,7 @@ impl std::error::Error for PubSubDown {}
/// Payloads are broadcast as `Arc<M>`: one allocation per broadcast, not
/// per subscriber.
pub struct PubSub<M: Send + Sync + 'static> {
server: ServerRef<Table<M>>,
server: GenServerRef<Table<M>>,
}
impl<M: Send + Sync + 'static> Clone for PubSub<M> {
@@ -226,7 +226,7 @@ struct Table<M: Send + Sync + 'static> {
/// retires the entry so a reused-slot pid (fresh generation) gets a
/// fresh monitor.
monitored: HashSet<Pid>,
watcher: Option<Watcher>,
watcher: Option<Watcher<Table<M>>>,
}
impl<M: Send + Sync + 'static> Table<M> {
@@ -240,8 +240,9 @@ impl<M: Send + Sync + 'static> GenServer for Table<M> {
type Reply = Reply;
type Cast = Cast<M>;
type Info = ();
type Timer = ();
fn init(&mut self, ctx: &ServerCtx) {
fn init(&mut self, ctx: &GenServerCtx<Self>) {
self.watcher = Some(ctx.watcher());
}
+16 -3
View File
@@ -135,16 +135,29 @@ impl Plug for Router {
// path matches but a same-path-different-method does, return 405.
// Otherwise pass through to `next` so outer pipelines can layer a
// 404 handler (or skip and let the connection actor emit a default).
//
// Matching runs under the `router` causal site (RFC 007); the
// winning handler and the `next` fall-through run outside it, so
// the site measures dispatch, not what it dispatches to.
let mut path_seen = false;
for route in &self.routes {
let mut hit = None;
{
#[cfg(feature = "smarm-causal")]
let _g = smarm::causal_site!("router");
for (i, route) in self.routes.iter().enumerate() {
if let Some(params) = route.pattern.match_path(&conn.path) {
if route.method == conn.method {
let conn = conn.put_params(params);
return route.handler.call(conn, next);
hit = Some((i, params));
break;
}
path_seen = true;
}
}
}
if let Some((i, params)) = hit {
let conn = conn.put_params(params);
return self.routes[i].handler.call(conn, next);
}
if path_seen {
// Path is known, method isn't — RFC 7231 §6.5.5.
conn.put_status(405)
+10 -17
View File
@@ -17,7 +17,7 @@ use crate::conn_registry::{self, Call, Cast, ConnRegistry, Reply};
use crate::net::{accept_nonblocking, bind_and_listen, OwnedFd};
use crate::plug::Pipeline;
use smarm::{ChildSpec, OneForOne, Restart, ServerRef, Strategy};
use smarm::{ChildSpec, OneForOne, Restart, GenServerRef, Strategy};
use std::io::{self, ErrorKind};
use std::net::{SocketAddr, ToSocketAddrs};
@@ -125,7 +125,7 @@ fn listener_loop(
listener: Arc<OwnedFd>,
pipeline: Pipeline,
limits: ConnLimits,
registry: ServerRef<ConnRegistry>,
registry: GenServerRef<ConnRegistry>,
shutdown: Arc<AtomicBool>,
) {
let fd = listener.as_raw();
@@ -327,15 +327,6 @@ pub fn serve_with_shutdown(
let sf = shutdown_flag.clone();
sup = sup.child(ChildSpec::new(Restart::Transient, move || {
println!("urus: listener {} starting", i);
// Named for whereis-style introspection. On a restart the
// old binding points at a dead pid; smarm's registry
// evicts stale bindings lazily, so re-registering the
// same name is fine. Ignore the result — a registry
// hiccup must not take the listener down.
let _ = smarm::register(
format!("urus.listener.{i}"),
smarm::self_pid(),
);
listener_loop(lfd.clone(), p.clone(), limits, r.clone(), sf.clone());
}));
}
@@ -343,11 +334,13 @@ pub fn serve_with_shutdown(
// faster than that trips the cap and tears the pool down — loud
// failure over a zombie server.
let sup_h = smarm::spawn(move || sup.run());
// Register via the JoinHandle's pid rather than inside the
// closure: the binding exists before the supervisor body runs a
// single instruction, so an early `whereis("urus.server")` can't
// race a None. Result ignored for the same reason as listeners.
let _ = smarm::register("urus.server", sup_h.pid());
// The old `urus.server` / `urus.listener.{i}` name registrations
// are gone with smarm's RFC 014 registry rework: `register` is now
// `(Name<M>, Sender<M>)`, self-only — a name is a typed messaging
// endpoint, not a pid tag. urus's bindings were introspection-only
// with no channel behind them, so they were dropped rather than
// faked with a unit channel. A real messageable `urus.server`
// name is in the icebox (ROADMAP.md).
// Block until told to shut down. We poll `try_recv` + `sleep`
// rather than parking in `recv`: a smarm `Sender::send` from a
@@ -399,7 +392,7 @@ pub fn serve_with_shutdown(
}
}
// 5. Our ServerRef drops here. The registry's inbox closes once
// 5. Our GenServerRef drops here. The registry's inbox closes once
// the last conn's clone drops with it, and the runtime winds
// down when the last actor exits.
});
+3 -18
View File
@@ -89,24 +89,9 @@ fn hello_world() {
assert_eq!(http_body(&resp), b"hello urus");
}
#[test]
fn server_and_listeners_are_registered() {
// `whereis` must run inside the runtime (the test itself is a foreign
// OS thread with no runtime in its TLS), so probe from a handler —
// connection actors live in the runtime by construction.
let pipe = Pipeline::new().plug(
Router::new().get("/whereis", |c: Conn, _n: Next| {
let ok = smarm::whereis("urus.server").is_some()
&& smarm::whereis("urus.listener.0").is_some()
&& smarm::whereis("urus.listener.1").is_some(); // pool of 2
c.put_status(200).put_body(if ok { "registered" } else { "missing" })
})
);
let port = spawn_server(pipe);
let resp = send_request(port, b"GET /whereis HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n");
assert_eq!(http_status(&resp), 200);
assert_eq!(http_body(&resp), b"registered");
}
// `server_and_listeners_are_registered` was deleted with the RFC 014 port:
// urus no longer binds `urus.server` / `urus.listener.{i}` names (see the
// note in serve.rs and the icebox entry in ROADMAP.md).
#[test]
fn echo_body() {