Compare commits
22
Commits
3da553b9b2
...
86c8d31b93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86c8d31b93 | ||
|
|
0de2baa72d | ||
|
|
39e4f70e9d | ||
|
|
0531390613 | ||
|
|
a676c01adc | ||
|
|
ad19848db3 | ||
|
|
341d20ab45 | ||
|
|
ffc579c500 | ||
|
|
64137cccf0 | ||
|
|
c171be7ddc | ||
|
|
f5ccd640fb | ||
|
|
d96ba88ec8 | ||
|
|
286915329a | ||
|
|
49538ccc83 | ||
|
|
43bd5a91a4 | ||
|
|
d14fb7c331 | ||
|
|
4482f47265 | ||
|
|
42a2464743 | ||
|
|
8065ea561e | ||
|
|
a60687bfec | ||
|
|
c658ac06b2 | ||
|
|
5fe696992a |
@@ -1,2 +1,3 @@
|
||||
target
|
||||
Cargo.lock
|
||||
smarm_trace.json
|
||||
|
||||
+13
@@ -9,9 +9,17 @@ description = "Cowboy/bandit-style HTTP library for the smarm actor runtime"
|
||||
smarm = { path = "../smarm" }
|
||||
httparse = "1.9"
|
||||
libc = "0.2"
|
||||
sha1_smol = "1"
|
||||
|
||||
# dep #4, ratified 2026-06-12: serde/serde_json behind the opt-in
|
||||
# "phoenix" feature only — the "channels" core stays dependency-free.
|
||||
serde = { version = "1", optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
|
||||
[features]
|
||||
smarm-trace = ["smarm/smarm-trace"]
|
||||
channels = []
|
||||
phoenix = ["channels", "dep:serde", "dep:serde_json"]
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -28,3 +36,8 @@ codegen-units = 1
|
||||
[[example]]
|
||||
name = "crud"
|
||||
path = "examples/crud.rs"
|
||||
|
||||
[[example]]
|
||||
name = "channels_chat"
|
||||
path = "examples/channels_chat.rs"
|
||||
required-features = ["phoenix"]
|
||||
|
||||
@@ -126,17 +126,304 @@ Handlers are closures taking `(Conn, Next) -> Conn`. Call `Next::call(c)` to con
|
||||
|
||||
```rust
|
||||
use urus::{serve_with, Config};
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
let cfg = Config {
|
||||
listener_pool: 2, // Number of OS threads handling accept()
|
||||
scheduler_threads: Some(2), // Number of smarm worker threads
|
||||
listener_pool: 2, // Supervised accept-loop actors
|
||||
scheduler_threads: Some(2), // smarm worker threads (None = one per CPU)
|
||||
keep_alive_timeout: Duration::from_secs(60), // Idle budget between requests
|
||||
request_timeout: Duration::from_secs(30), // Whole-request READ deadline
|
||||
write_timeout: Duration::from_secs(30), // Per-write response budget
|
||||
max_header_count: 64,
|
||||
read_buf_size: 8 * 1024,
|
||||
max_body_bytes: 1 << 20,
|
||||
drain_timeout: Duration::from_secs(30), // Graceful-shutdown drain budget
|
||||
..Config::new("127.0.0.1:8080".parse().unwrap())
|
||||
};
|
||||
|
||||
serve_with(cfg, pipeline).unwrap();
|
||||
```
|
||||
|
||||
**Timeout semantics:**
|
||||
|
||||
- `keep_alive_timeout` — how long a connection may sit idle waiting for the
|
||||
*first byte* of a request. Expiry closes the socket silently (nothing was
|
||||
in flight). Pipelined leftover bytes count as a started request, not idle.
|
||||
- `request_timeout` — a wall-clock deadline from a request's first byte
|
||||
until its head and body are fully read. Expiry mid-head gets a
|
||||
best-effort `408 Request Timeout`; expiry mid-body just closes. Deadlines
|
||||
are absolute instants, so a trickling (slowloris-style) client can't
|
||||
reset its budget by sending one byte at a time. Covers the **read phase
|
||||
only** — a streaming response may legitimately outlive any whole-request
|
||||
clock.
|
||||
- `write_timeout` — a per-write budget for response bytes: the fixed
|
||||
head+body write, and *each* streamed chunk, must complete within it. A
|
||||
client that stops reading is dropped once its socket buffer fills and a
|
||||
write stalls past the budget (the write-side twin of slowloris). Streamed
|
||||
chunks each get a fresh budget; a stream as a whole has no deadline.
|
||||
|
||||
### Graceful Shutdown
|
||||
|
||||
`serve_with_shutdown` takes a `ShutdownSignal`; the paired `Handle` can be
|
||||
triggered from anywhere (another thread, a signal handler):
|
||||
|
||||
```rust
|
||||
use urus::{serve_with_shutdown, shutdown_handle, Config};
|
||||
|
||||
let (handle, signal) = shutdown_handle();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// e.g. wait for SIGTERM / stdin / an admin endpoint...
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
||||
// Returns once the runtime has fully wound down.
|
||||
```
|
||||
|
||||
`Handle::shutdown()` is idempotent and performs, in order:
|
||||
|
||||
1. Stop accepting — every listener exits; no new connections.
|
||||
2. Close idle keep-alive connections immediately.
|
||||
3. Drain in-flight requests for up to `Config.drain_timeout`.
|
||||
4. Force-stop any stragglers past the deadline (sockets close cleanly on
|
||||
unwind via `OwnedFd::drop`).
|
||||
|
||||
If every `Handle` is dropped, shutdown can never be signalled and the
|
||||
server runs forever — exactly `serve_with`'s semantics (it does this
|
||||
internally).
|
||||
|
||||
### Streaming Responses
|
||||
|
||||
A handler can return a body it doesn't have yet: hand urus the read end of
|
||||
a channel and feed it from a producer actor (the conn actor owns the
|
||||
socket and all write deadlines; the producer never touches the fd):
|
||||
|
||||
```rust
|
||||
use urus::{Conn, Next};
|
||||
|
||||
|c: Conn, _n: Next| {
|
||||
let (tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
smarm::spawn(move || {
|
||||
for part in ["chunk one", "chunk two"] {
|
||||
if tx.send(part.as_bytes().to_vec()).is_err() {
|
||||
return; // connection died — stop producing
|
||||
}
|
||||
}
|
||||
// tx drops: end of stream.
|
||||
});
|
||||
c.put_status(200).put_body(rx)
|
||||
}
|
||||
```
|
||||
|
||||
On HTTP/1.1 the response uses `Transfer-Encoding: chunked` and the
|
||||
connection is reusable afterwards; on HTTP/1.0 (no chunked framing) bytes
|
||||
go out raw and the connection closes to delimit the body. End of stream is
|
||||
signalled by dropping every `Sender`. The producer contract: a `send`
|
||||
error means the connection is gone — exit. Chunked **request** bodies are
|
||||
decoded transparently; handlers see `conn.body` either way.
|
||||
|
||||
### Server-Sent Events
|
||||
|
||||
`Conn::sse()` is the streaming body with the SSE headers and a heartbeat
|
||||
wired up:
|
||||
|
||||
```rust
|
||||
|c: Conn, _n: Next| {
|
||||
let (c, events) = c.sse(); // or c.sse_with_heartbeat(interval)
|
||||
smarm::spawn(move || {
|
||||
loop {
|
||||
if events.send("tick", "data").is_err() {
|
||||
return; // SseClosed: client gone or server draining
|
||||
}
|
||||
smarm::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
c
|
||||
}
|
||||
```
|
||||
|
||||
While the producer is silent the connection actor emits `: keep-alive`
|
||||
comment chunks every `HEARTBEAT_INTERVAL` (15s default). Liveness comes
|
||||
from that ping: a vanished client is detected when a write stalls past
|
||||
`write_timeout`. No request clock applies to an open SSE stream, and a
|
||||
graceful shutdown force-stops it at the drain deadline.
|
||||
|
||||
### Named Actors
|
||||
|
||||
For introspection and debugging, the server registers itself in smarm's
|
||||
process registry: `urus.server` (the listener-pool supervisor) and
|
||||
`urus.listener.{i}` (each accept loop). `smarm::whereis(name)` resolves
|
||||
them from any actor inside the runtime.
|
||||
|
||||
### WebSocket (v0.4)
|
||||
|
||||
A route handler accepts the upgrade by handing `Conn::upgrade` a
|
||||
`WsHandler`; after the `101` the connection actor leaves HTTP and drives
|
||||
the handler from a duplex select loop (smarm RFC 008 fd arms: first-of
|
||||
outbound-channel / fd-readable, one actor, no fd dup):
|
||||
|
||||
```rust
|
||||
struct Echo;
|
||||
|
||||
impl urus::WsHandler for Echo {
|
||||
fn on_message(&mut self, msg: urus::Message, sender: &urus::WsSender) {
|
||||
let _ = sender.send(msg); // or .text(..) / .binary(..) / .ping() / .close(code, reason)
|
||||
}
|
||||
fn on_close(&mut self, code: Option<u16>, reason: &str) {
|
||||
// what the PEER said: Some(code) iff its close frame arrived;
|
||||
// None for EOF / write failure / handshake timeout.
|
||||
let _ = (code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
Router::new().get("/echo", |c: Conn, _n: Next| c.upgrade(Echo))
|
||||
```
|
||||
|
||||
Callbacks run **inside the connection actor** — a slow `on_message`
|
||||
stops reads, which is backpressure, not a bug. A handler that wants
|
||||
concurrency spawns its own actor and hands it a `WsSender` clone (the
|
||||
SSE-producer pattern); every `WsSender` method returns `Err(WsClosed)`
|
||||
once the connection ends. While a large outbound frame is mid-write the
|
||||
actor isn't reading — accepted v1 trade-off of the one-actor topology.
|
||||
|
||||
Control frames are invisible to the handler in v1: pings are
|
||||
auto-ponged, a peer close is auto-echoed and surfaces as `on_close`.
|
||||
Protocol violations close with the mapped RFC 6455 code (1002/1009/1007;
|
||||
handler panic → 1011). Caps: `Config.max_frame_payload` (1 MiB default,
|
||||
enforced from the frame header before payload buffers) and
|
||||
`Config.max_message_bytes` (4 MiB default, spans fragments).
|
||||
|
||||
Like SSE, an open WebSocket has no request clock: idle is fine, a dead
|
||||
client is caught when a write stalls past `write_timeout`, and graceful
|
||||
shutdown force-stops the connection at the drain deadline. See
|
||||
`examples/ws_echo.rs`.
|
||||
|
||||
The `WsHandler` trait also has a defaulted `on_open(&mut self, sender)`,
|
||||
called once before the frame loop — the place to subscribe or spawn
|
||||
producers for clients that may never send. Note the client sees the 101
|
||||
*before* `on_open` runs; a client that must know its subscriptions are
|
||||
live uses an application-level ack (any reply proves `on_open` completed,
|
||||
callbacks being sequential).
|
||||
|
||||
## Pub/Sub (v0.5)
|
||||
|
||||
`urus::pubsub` is a local-node, phoenix_pubsub-shaped topic broadcaster —
|
||||
independent of HTTP (it imports only smarm) and built for the WebSocket
|
||||
relay pattern:
|
||||
|
||||
```rust
|
||||
let bus: PubSub<String> = PubSub::new(); // in-runtime only!
|
||||
let rx = bus.subscribe("room:lobby")?; // Receiver<Arc<String>>
|
||||
bus.broadcast("room:lobby", "hi".to_string())?;
|
||||
bus.broadcast_from(smarm::self_pid(), "room:lobby", "no echo".into())?;
|
||||
```
|
||||
|
||||
One generic instance per message domain; payloads broadcast as `Arc<M>`
|
||||
(one allocation per broadcast). `subscribe` is idempotent per
|
||||
`(pid, topic)` and pins the subscription to the **calling actor** — its
|
||||
death prunes the entry via a monitor; a dropped `Receiver` is pruned
|
||||
lazily on the next broadcast. `subscribe_as(pid, topic)` pins to an
|
||||
explicit pid for relay patterns. Mailboxes are unbounded: `broadcast`
|
||||
never blocks the table, and a slow subscriber's memory bill is bounded
|
||||
by the two cleanup paths above.
|
||||
|
||||
Two composition rules that matter (both enforced by
|
||||
`shutdown_with_open_chat_terminates` in the integration suite):
|
||||
|
||||
1. `PubSub::new()` spawns an actor, so it must run in-runtime — build it
|
||||
lazily from a handler via a **non-static** `Arc<OnceLock<PubSub<M>>>`
|
||||
captured by the route closure. A `static` cell pins the table actor
|
||||
forever and graceful shutdown never returns.
|
||||
2. Relay/producer actors hold the `Receiver` (plus e.g. a `WsSender`
|
||||
clone) — **never a `PubSub` clone**, or relay and table keep each
|
||||
other alive past shutdown.
|
||||
|
||||
See [`examples/ws_chat.rs`](examples/ws_chat.rs): rooms as topics,
|
||||
`on_open` subscribes + spawns the relay, `on_message` uses
|
||||
`broadcast_from` so the speaker isn't echoed.
|
||||
|
||||
## Channels (v0.6)
|
||||
|
||||
Phoenix-style join/leave/event multiplexing over the WebSocket duplex,
|
||||
pubsub underneath. Two additive features:
|
||||
|
||||
- `channels` — the wire-neutral core (`Channel`, `ChannelHub`,
|
||||
`PrefixRouter`, `ChannelSocket`, sessions). Zero new dependencies;
|
||||
bring your own codec by implementing `Encode`/`Decode` for your
|
||||
payload type.
|
||||
- `phoenix` — implies `channels`, adds serde/serde_json and the
|
||||
phoenix.js V2 wire format via the `Json<T>` newtype.
|
||||
|
||||
```rust
|
||||
use urus::{Channel, ChannelHub, ChannelSocket, PrefixRouter, Status};
|
||||
use urus::channels::phoenix::Json;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
type P = Json<Value>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Room;
|
||||
|
||||
impl Channel<P> for Room {
|
||||
fn join(&mut self, topic: &str, payload: P, _s: &ChannelSocket<P>) -> Result<P, P> {
|
||||
Ok(Json(json!({ "joined": topic }))) // Err(..) rejects
|
||||
}
|
||||
fn handle_in(&mut self, event: &str, payload: P, s: &ChannelSocket<P>) {
|
||||
match event {
|
||||
"shout" => s.broadcast("shouted", payload),
|
||||
_ => s.reply(Status::Ok, Json(json!({ "echo": event }))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// in-runtime, non-static — the ws_chat OnceLock pattern applies
|
||||
let hub = ChannelHub::new(PrefixRouter::new().channel_default::<Room>("room:*"));
|
||||
// route handler: hub.upgrade(conn)
|
||||
// from anywhere with a hub handle: hub.broadcast("room:lobby", "news", payload)
|
||||
```
|
||||
|
||||
One channel actor per joined topic per socket, linked to the
|
||||
connection: callbacks are sequential, a channel panic is a socket
|
||||
event, and the conn-side handler answers transport heartbeats and
|
||||
routes joins — `Channel` impls never see frames or refs (`reply`
|
||||
correlates to the in-flight event invisibly).
|
||||
|
||||
**Session persistence (opt-in).** By default channels cold-start per
|
||||
join, Phoenix-server style. Implementing `ChannelSession` and
|
||||
registering with `channel_session` makes the channel actor outlive its
|
||||
transport: broadcasts that arrive while detached are buffered (the same
|
||||
`Arc`s the relay path carries — zero re-serialisation) and drained, in
|
||||
order, under the rejoin's new generation. Buffer cap or TTL exceeded,
|
||||
explicit leave, or a rejected rejoin tears the session down; the next
|
||||
join is a cold start. A second transport claiming the same key evicts
|
||||
the first.
|
||||
|
||||
```rust
|
||||
struct ByToken;
|
||||
impl urus::ChannelSession<P> for ByToken {
|
||||
type Key = String;
|
||||
fn session_key(topic: &str, join_payload: &P) -> String {
|
||||
format!("{topic}/{}", join_payload.0["token"].as_str().unwrap_or(""))
|
||||
}
|
||||
// fn buffer_cap() -> usize { 128 } // defaults
|
||||
// fn ttl() -> Duration { Duration::from_secs(30) }
|
||||
}
|
||||
|
||||
PrefixRouter::new().channel_session::<ByToken>("session:*", |_: &str| {
|
||||
Box::new(Room::default()) as Box<dyn Channel<P>>
|
||||
})
|
||||
```
|
||||
|
||||
Session channels must treat `join` as re-entrant: every reattach calls
|
||||
it again on the same instance (the rejoin ack needs a payload, and the
|
||||
channel gets to re-auth).
|
||||
|
||||
See [`examples/channels_chat.rs`](examples/channels_chat.rs)
|
||||
(`cargo run --example channels_chat --features phoenix`): ephemeral
|
||||
`room:*` and persistent `session:*` side by side over phoenix.js V2
|
||||
JSON.
|
||||
|
||||
## Examples
|
||||
|
||||
### CRUD with Actor Ownership
|
||||
@@ -165,11 +452,12 @@ cargo test
|
||||
```
|
||||
|
||||
Tests in `tests/integration.rs` cover:
|
||||
- Basic routing (GET, POST, PUT, DELETE)
|
||||
- Request body echoing
|
||||
- HTTP header parsing
|
||||
- Path parameter extraction
|
||||
- Status code responses
|
||||
- Basic routing, request bodies, headers, path parameters, status codes
|
||||
- Keep-alive and pipelining
|
||||
- Listener supervision (a panicking listener restarts without dropping a pending accept)
|
||||
- Graceful shutdown (idle close, in-flight drain, force-stop at the drain deadline)
|
||||
- Timeouts (idle keep-alive reaping, slowloris-style slow headers/body)
|
||||
- Registry names (`urus.server`, `urus.listener.{i}` resolve via `whereis`)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -205,11 +493,11 @@ This avoids the complexity of async ecosystems while maintaining full concurrenc
|
||||
|
||||
## Roadmap
|
||||
|
||||
v1 covers HTTP/1.1, the plug pipeline, and a built-in router. Future versions may include:
|
||||
- Middleware ecosystem (auth, logging, compression, etc.).
|
||||
- WebSocket support.
|
||||
- Benchmarking suite (see `urus-bench-spec.md`).
|
||||
- Additional utilities and examples.
|
||||
See [`ROADMAP.md`](ROADMAP.md). Summary: v0.2 (supervised listener pool,
|
||||
graceful shutdown, enforced timeouts, registry names), v0.3 (streaming
|
||||
bodies, chunked requests, SSE), v0.4 (WebSocket: handshake, frame
|
||||
codec, duplex handler API), v0.5 (PubSub) and v0.6 (Phoenix-style
|
||||
channels with opt-in session persistence) are done.
|
||||
|
||||
Refer to `urus-spec.md` and `urus-v1-build-notes.md` in the artifact persistence for the original design and implementation notes.
|
||||
|
||||
|
||||
+286
-96
@@ -7,7 +7,8 @@
|
||||
|
||||
## State assessment (2026-06-11)
|
||||
|
||||
Verified against smarm master (`v0.8`, slot slab + park-epoch + select):
|
||||
Verified against smarm master (`393cdd0`: v0.8 slot slab + park-epoch +
|
||||
select, plus RFC 008 phase 1 fd arms / timed fd waits):
|
||||
|
||||
- **Builds clean, 24/24 tests pass.** The scheduler surface urus consumes
|
||||
(`spawn`, `wait_readable`, `wait_writable`, `sleep`, `init`, `Config`,
|
||||
@@ -27,65 +28,85 @@ Verified against smarm master (`v0.8`, slot slab + park-epoch + select):
|
||||
- **No streaming response path.** `RespBody` is `Empty | Bytes`. Blocks SSE
|
||||
(a spec v1 goal that slipped) and chunked responses.
|
||||
- **Full-duplex constraint (matters for WebSocket).** smarm's epoll model is
|
||||
one waiter per RawFd (`AlreadyExists` on the second registration) and one
|
||||
direction per parked actor. Concurrent read+write on one connection needs
|
||||
either the dup-fd trick (serve.rs already uses it for the listener pool)
|
||||
or a new smarm primitive. Decide in v0.2 design, before the frame codec.
|
||||
one waiter per RawFd (`AlreadyExists`, now surfaced as `Err` via the
|
||||
`try_select` twins) and one direction per parked actor. RFC 008 phase 1
|
||||
(landed `393cdd0`) gives first-of(fd, channel) via fd arms in select —
|
||||
the duplex options in v0.4-3 are both open now. Decide in v0.4 design,
|
||||
before the frame codec.
|
||||
|
||||
---
|
||||
|
||||
## v0.2 — OTP refresh ⏳
|
||||
## v0.2 — OTP refresh ✅ (landed 2026-06, chunks 1-4 / commits 5fe6969..HEAD)
|
||||
|
||||
Adopt the primitives urus predates. No protocol surface changes; `Plug`/`Conn`
|
||||
untouched. Chunks are independently landable, in order:
|
||||
|
||||
### 1. Supervised listener pool
|
||||
Replace bare spawns in `serve_with` with a supervisor:
|
||||
`Strategy::OneForOne`, one `ChildSpec` per dup'd listener fd,
|
||||
`Restart::Permanent`. A listener panic restarts that listener; the pool
|
||||
### 1. Supervised listener pool ✅
|
||||
Landed: `serve_with` runs a `Strategy::OneForOne` supervisor on the root
|
||||
actor, one `ChildSpec` per dup'd listener fd, `Restart::Permanent`. A
|
||||
listener panic (or a transient `wait_readable` failure, which now exits
|
||||
and restarts instead of being fatal) restarts that listener; the pool
|
||||
never silently shrinks. Connection actors stay unsupervised bare spawns
|
||||
(per spec: a connection is cheap and its failure is local — a 500 path,
|
||||
not a restart path).
|
||||
not a restart path); `spawn` parents them under the listener, which has no
|
||||
supervisor channel, so their deaths are invisible to the pool supervisor
|
||||
by construction.
|
||||
|
||||
Open question: should the restarted child re-dup from listener 0's fd, or
|
||||
own its fd across restarts via the ChildSpec closure? Leaning: closure
|
||||
captures the `OwnedFd`, re-registers on restart. No re-dup needed.
|
||||
Open question resolved as leaned: the ChildSpec factory owns the fd via
|
||||
`Arc<OwnedFd>` (`OwnedFd` gained `Sync` for this) and every (re)start
|
||||
reuses it. No re-dup, no window where a pool slot has no fd. Covered by
|
||||
`tests/integration.rs::panicking_listener_restarts` (pool of 1, panic
|
||||
injected before `accept` so the pending connection must survive into the
|
||||
restarted listener).
|
||||
|
||||
### 2. Graceful shutdown
|
||||
### 2. Graceful shutdown ✅
|
||||
Landed (c658ac0): `serve_with_shutdown(config, pipeline, signal)` +
|
||||
`shutdown_handle()`. Drain-then-stop as decided below; design details in
|
||||
the commit body. `serve_with` keeps v1 run-forever semantics by dropping
|
||||
its own Handle.
|
||||
`serve_with` gains a shutdown signal (an `urus::Handle` with `.shutdown()`,
|
||||
backed by a channel + `request_stop` fan-out):
|
||||
1. Stop listeners (no new accepts; listener fds close).
|
||||
2. Stop connection actors — either drain-then-stop with a deadline, or
|
||||
immediate `request_stop` (unwind closes fds cleanly via `OwnedFd::drop`).
|
||||
2. **Decided: drain-then-stop with a deadline** — in-flight requests get
|
||||
to finish; `request_stop` after the deadline (unwind closes fds cleanly
|
||||
via `OwnedFd::drop`). Drain also keeps the door open for an
|
||||
apache-reload-style restart later.
|
||||
3. Supervisor winds down; `rt.run` returns; `serve_with` returns.
|
||||
|
||||
Requires tracking live connection pids. Cheapest honest version: a
|
||||
`gen_server` connection registry — listener casts `{Started, pid}`, conn
|
||||
actor monitors-free self-deregisters via a drop guard cast. This registry
|
||||
is also chunk 3's reaper and the future ws/channels introspection point,
|
||||
so build it once here.
|
||||
actor monitors-free self-deregisters via a drop guard cast. Pid set only
|
||||
(no activity stamps — chunk 3 went per-conn timed waits, no reaper); still
|
||||
the future ws/channels introspection point, so build it once here.
|
||||
|
||||
### 3. Enforce the timeouts we advertise
|
||||
**Decision fork — pick one before implementing:**
|
||||
### 3. Enforce the timeouts we advertise ✅
|
||||
Landed (a60687b): timed waits as decided below; the leaning held — a
|
||||
per-request `Instant` deadline keeps `request_timeout` an honest
|
||||
whole-request wall clock (mid-head expiry: best-effort non-parking 408;
|
||||
mid-body: close). Details in the commit body.
|
||||
**Decided: smarm-native timed waits** (the fork's option (a), a reaper in
|
||||
urus, died when RFC 008 landed at `393cdd0` — `wait_readable_timeout` /
|
||||
`wait_writable_timeout` exist upstream now, so there is nothing for the
|
||||
reaper to be the stopgap for):
|
||||
|
||||
- **(a) Reaper in urus.** The chunk-2 registry tracks per-conn
|
||||
`last_activity`; a ticker (`smarm::sleep` loop) sweeps and
|
||||
`request_stop`s expired conns. Works today, zero smarm changes.
|
||||
Granularity = sweep interval; conns must stamp activity (one cast per
|
||||
request — measurable but small).
|
||||
- **(b) `wait_readable_timeout` in smarm.** A first-of(fd, timer) wait.
|
||||
The v0.7 epoch-stamped consuming-unpark machinery exists precisely for
|
||||
multi-arm waits (`select_timeout` is the channel-flavoured proof), but
|
||||
fd arms aren't `Selectable` today — this is real smarm work in
|
||||
`wait_fd`/io thread. Cleaner per-conn semantics, benefits every future
|
||||
smarm io consumer, no reaper bookkeeping.
|
||||
`read_some` parks in `wait_readable_timeout` instead of `wait_readable`;
|
||||
`Ok(false)` means the deadline passed and the conn actor closes. Per-conn
|
||||
semantics, no activity stamping, no sweep ticker. Semantics note: a timed
|
||||
read wait enforces *idle-between-reads* — `keep_alive_timeout` between
|
||||
requests, and slowloris dies because each stalled read has a deadline.
|
||||
`request_timeout` as a whole-request wall clock is NOT what this gives;
|
||||
decide in implementation whether to track a per-request deadline across
|
||||
reads (cheap: one `Instant` in the conn loop, pass the min of both
|
||||
budgets to each wait) or redefine `request_timeout` as per-read. Leaning
|
||||
the `Instant`: it keeps the advertised config honest.
|
||||
|
||||
Leaning **(a) now, (b) later**: (a) ships the security fix this cycle and
|
||||
the registry exists anyway; (b) becomes a smarm roadmap item whose landing
|
||||
lets the reaper's stamps go away. But if you'd rather not build throwaway:
|
||||
(b) first is defensible — say the word.
|
||||
urus is the first real consumer of the timed wrappers — note the RFC 008
|
||||
reviewer flag (`wait_fd` wraps in `NoPreempt`, select arms don't); the
|
||||
crud example under load doubles as that confirmation.
|
||||
|
||||
### 4. Hygiene
|
||||
### 4. Hygiene ✅
|
||||
Landed: registry names + whereis test (in-runtime probe via a handler —
|
||||
tests are foreign threads), README rewrite, smarm-trace compile verified.
|
||||
- Registry names: `urus.server`, `urus.listener.{n}` via `smarm::register`
|
||||
(debuggability; `whereis` in tests).
|
||||
- README: rewrite Roadmap section to point here; document `Handle`.
|
||||
@@ -98,81 +119,252 @@ panicking listener restarts under load without dropped accepts (test each).
|
||||
|
||||
---
|
||||
|
||||
## v0.3 — Streaming bodies + SSE
|
||||
## v0.3 — Streaming bodies + SSE ✅ (landed 2026-06, chunks 1-3)
|
||||
|
||||
The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection
|
||||
is "a response that never ends").
|
||||
|
||||
1. `RespBody::Stream(...)` — handler hands back a writer callback or a
|
||||
`Receiver<Vec<u8>>`; conn actor switches to chunked transfer-encoding
|
||||
(HTTP/1.1) and pumps until the stream closes. Keep-alive after a
|
||||
completed chunked response.
|
||||
2. Chunked **request** bodies (`parser.rs` currently returns 411) — same
|
||||
cycle, the codec knowledge is fresh.
|
||||
3. SSE sugar: `Conn::sse()` → an `EventSender` (`.send(event, data)`),
|
||||
sets `content-type: text/event-stream`, disables buffering, heartbeats
|
||||
via comment lines on a timer. An SSE conn is just a Stream body that
|
||||
the reaper exempts from `request_timeout` (keep-alive ping instead).
|
||||
1. `RespBody::Stream(StreamBody)` ✅ — **decided: pull** (user call at the
|
||||
v0.3 design fork): the handler hands back a `smarm::Receiver<Vec<u8>>`
|
||||
and the producer is an actor the handler spawned; the conn actor pumps
|
||||
(`pump_stream`) and keeps sole ownership of the socket and write
|
||||
deadlines. The recv() park between chunks is stoppable by the draining
|
||||
registry, so infinite streams die at the drain deadline with no
|
||||
polling. HTTP/1.1: chunked TE, keep-alive after the terminator;
|
||||
HTTP/1.0: raw bytes, forced close, EOF-delimited. End of stream =
|
||||
every Sender dropped = 0-chunk. Timeout split (handoff Q3, as
|
||||
proposed): `request_timeout` covers the READ phase only; new
|
||||
`Config.write_timeout` (default 30s) bounds every response write —
|
||||
fresh budget per streamed chunk, and the fixed-body + error-response
|
||||
writes went deadline-bounded too (write-side slowloris closed).
|
||||
2. Chunked **request** bodies ✅ — `read_chunked_body` decodes
|
||||
incrementally on the request deadline; extensions ignored, trailers
|
||||
consumed + discarded, decoded size capped at max_body_bytes (413),
|
||||
size-line/trailer spam capped (400). CL + TE: chunked, or chunked on
|
||||
HTTP/1.0, rejected 400 at parse (smuggling ambiguity). Keep-alive
|
||||
drain accounts RAW framing length, so pipelined requests behind a
|
||||
chunked body land exactly.
|
||||
3. SSE sugar ✅ — `Conn::sse()` / `sse_with_heartbeat` → `EventSender`
|
||||
(`send(event, data)` / `data` / `comment`, Clone, `Err(SseClosed)`
|
||||
once the stream is gone). Heartbeat lives in the pump as leaned: with
|
||||
`StreamBody.heartbeat` set, the chunk wait is `recv_timeout(interval)`
|
||||
and expiry writes a `: keep-alive` comment chunk. Liveness from the
|
||||
ping (dead client = heartbeat write stalls past write_timeout), not
|
||||
from any request clock.
|
||||
|
||||
**Exit criteria (verified):** chunked round-trips both directions with
|
||||
keep-alive and pipelining intact; an infinite SSE stream heartbeats while
|
||||
silent, is killed by write_timeout when the client stops reading, and is
|
||||
force-stopped at the drain deadline on shutdown; full suite green.
|
||||
|
||||
---
|
||||
|
||||
## v0.4 — WebSocket upgrade path
|
||||
## v0.4 — WebSocket upgrade path ✅ (landed 2026-06, chunks 1-3)
|
||||
|
||||
1. **Handshake**: detect `Connection: Upgrade` + `Upgrade: websocket` in
|
||||
the pipeline; `Conn::upgrade(handler)` short-circuits — conn actor
|
||||
validates `Sec-WebSocket-Key`, emits 101, exits the HTTP loop.
|
||||
(SHA-1 + base64 needed; vendor a tiny SHA-1 rather than pulling a
|
||||
crate — urus has two deps and it should stay that way.)
|
||||
2. **Frame codec**: RFC 6455 framing — fragmentation, masking (client
|
||||
frames are always masked), control frames (ping/pong/close), 125-byte
|
||||
control-payload rule, close-code handshake.
|
||||
3. **Duplex topology** (the design decision from the assessment):
|
||||
- **(a) Two actors, dup'd fd**: reader actor on the dup, writer actor
|
||||
on the original, writer owns a `Receiver<Frame>`; the user handler
|
||||
talks to both via channels. Proven trick (listener pool), works today.
|
||||
- **(b) One actor, smarm first-of(fd-readable, channel)**: needs fd
|
||||
arms in select — the same smarm work as v0.2-3(b). Simpler topology,
|
||||
no dup, but blocked on smarm.
|
||||
Default (a); revisit if (b) lands for timeouts anyway.
|
||||
4. Handler API sketch (settle in design pass): trait with
|
||||
`on_message/on_close` + a `WsSender` handle — gen_server-shaped, so a
|
||||
ws connection *is* an actor you can monitor, link, and register.
|
||||
1. **Handshake** ✅ (49538cc) — `Conn::upgrade()` (handler arg comes
|
||||
with chunk 3): §4.2.1 validation, 101 + accept key, marker field on
|
||||
`Conn`, actor leaves the HTTP loop (fd drops until chunk 3 takes
|
||||
over). Rejections stay plain HTTP (400 / 426 + version header) with
|
||||
keep-alive intact. **Dep decision changed at the design pass**: SHA-1
|
||||
is `sha1_smol` (zero transitive deps) — dep #3 SPENT, no hand-rolled
|
||||
crypto (user call). The v0.6 JSON question is therefore a dep #4
|
||||
question now. Base64 stays in-tree, encode-only.
|
||||
2. **Frame codec** ✅ (2869153) — pure `ws::frame`: incremental
|
||||
`decode` (mask-required server mode), minimal-length + RSV + control
|
||||
rules enforced, header-derived cap check before payload buffering,
|
||||
close-code wire validation, `Assembler` for fragmentation with
|
||||
whole-message UTF-8 check, `FrameError`→close-code map
|
||||
(1002/1009/1007). §5.7 examples pinned verbatim.
|
||||
3. **Duplex wiring** ✅ — topology decision: **(b) one actor, RFC 008
|
||||
fd arms** (urus exists to put smarm through its paces; first fd-arm
|
||||
consumer is the point). As landed:
|
||||
- `ws::duplex::run_duplex` replaces the HTTP loop after the 101:
|
||||
`try_select(&[&outbound_rx, &FdArm::readable(fd)])` per iteration —
|
||||
outbound at index 0 (select is priority-ordered: owed writes drain
|
||||
before reading more). Accepted gap documented: mid-write of a large
|
||||
outbound frame the actor isn't reading.
|
||||
- Handler API (the deferred questions, as answered): in-actor
|
||||
callbacks — `WsHandler { on_message, on_close }` runs inside the
|
||||
select loop; concurrency = spawn your own actor with a `WsSender`
|
||||
clone (the SSE-producer pattern). `Conn::upgrade(self, handler)`
|
||||
flat signature (builder deferred until per-upgrade options exist,
|
||||
e.g. subprotocols). `WsSender`: Clone, `send/text/binary/ping/
|
||||
close → Err(WsClosed)`, unbounded channel like SSE (slow client
|
||||
bounded by write_timeout). Control frames invisible v1: auto-pong
|
||||
inline (§5.5.2), peer close auto-echoed (§5.5.1, server closes TCP
|
||||
first per §7.1.1) surfacing only as `on_close(Some(code), reason)`;
|
||||
wordless endings (EOF, write failure, handshake timeout) =
|
||||
`on_close(None, "")`. `on_ping`/`on_pong` can land later as default
|
||||
trait methods, non-breaking.
|
||||
- Server-initiated close (WsSender::close or FrameError→1002/1009/
|
||||
1007, handler panic→1011): close frame out, then a bounded drain
|
||||
(one write_timeout budget) for the peer's echo, discarding data
|
||||
(§1.4). Handler panics re-raise smarm's stop sentinel before being
|
||||
treated as panics — the pipeline's catch_unwind dance, replicated.
|
||||
- Caps: `Config.max_frame_payload` (1 MiB) / `max_message_bytes`
|
||||
(4 MiB) — Config fields per the max_body_bytes precedent; header
|
||||
cap checked before payload buffers.
|
||||
- Buf handover: bytes pipelined past the upgrade head carry into the
|
||||
duplex loop (tested).
|
||||
- Registry: conn stays **Busy** for the ws lifetime (an open ws is
|
||||
in-flight work); graceful shutdown force-stops it out of the select
|
||||
park at the drain deadline (tested — the in-runtime request_stop
|
||||
wake covers select parks, not just channel recv).
|
||||
- Validated against a real client (python websocket-client): echo,
|
||||
ping/pong payload, close handshake. `examples/ws_echo.rs` shows
|
||||
both handler shapes (/echo in-actor, /clock producer-spawning).
|
||||
4. **Handler API** ✅ — folded into chunk 3 above (was: settle in design
|
||||
pass; settled: gen_server-shaped trait + WsSender handle).
|
||||
|
||||
## v0.5 — PubSub
|
||||
## v0.5 — PubSub ✅ (landed 2026-06, chunks 1-2)
|
||||
|
||||
`urus::pubsub`, phoenix_pubsub-shaped, local node only (no distribution —
|
||||
smarm has none):
|
||||
smarm has none). All six design questions ratified by the user pre-code
|
||||
("push" on the stated leans); as-landed:
|
||||
|
||||
- `subscribe(topic)`, `unsubscribe(topic)`, `broadcast(topic, msg)`,
|
||||
`broadcast_from(self, topic, msg)`.
|
||||
- Topic table: gen_server owning `HashMap<Topic, Vec<(Pid, Sender<Arc<M>>)>>`;
|
||||
shard N ways by topic hash if the single server measures hot (don't
|
||||
pre-shard; bench first — smarm channel sends are cheap).
|
||||
- Dead subscriber cleanup via `monitor` + `handle_down` (this is exactly
|
||||
what 06-10's gen_server `handle_down` was built for).
|
||||
- `Arc<M>` payloads: one allocation per broadcast, not per subscriber —
|
||||
the shared-heap advantage over BEAM, take it.
|
||||
- **Generic `PubSub<M>`** per instance (Q1); heterogeneous buses are an
|
||||
app-side enum. `Arc<M>` payloads: one allocation per broadcast.
|
||||
- **`subscribe(topic) -> Receiver<Arc<M>>`** (Q2), fresh channel per
|
||||
subscription; `subscribe_as(pid, ..)` pins lifetime to an explicit pid
|
||||
for relay shapes. Sender-passing fan-in (`subscribe_with`) deliberately
|
||||
deferred — compatible addition.
|
||||
- **Unbounded mailboxes** (Q3): broadcast never blocks the table. Cleanup
|
||||
is monitor `Down` (eager, on subscriber death) + prune-on-send-failure
|
||||
(lazy, dropped receivers). Bench before sharding.
|
||||
- **User-owned handle** (Q4). The ratified register-by-name helper turned
|
||||
out NOT implementable: smarm's registry maps name → Pid and a Pid can't
|
||||
be turned back into a `ServerRef`. Deferred pending smarm support (or a
|
||||
type-erased global, rejected). `pid()` exposed.
|
||||
- **Unique per `(pid, topic)`** (Q5): subscribe idempotent (replaces the
|
||||
sender; the stale receiver closes). One monitor per live pid.
|
||||
`handle_down` is a full table scan as ratified; pid→topics reverse
|
||||
index is the documented optimization if deaths measure hot.
|
||||
- Table: gen_server `HashMap<Topic, HashMap<Pid, Sender<Arc<M>>>>` —
|
||||
single server, not sharded (bench first). `subscriber_count(topic)`
|
||||
added (tests need to observe async cleanup; legitimate API).
|
||||
|
||||
Deliberately independent of HTTP — usable from any smarm app. Separate
|
||||
module now, candidate for crate extraction later.
|
||||
**Discoveries this cycle:**
|
||||
- `PubSub::new()` is in-runtime only (spawns the table actor) but
|
||||
pipelines are built pre-runtime → the composition pattern is a
|
||||
NON-static `Arc<OnceLock<PubSub<M>>>` in the route closure. Non-static
|
||||
is load-bearing: the drained pipeline drops the cell in-runtime, the
|
||||
table's inbox closes, the table exits, AllDone is reachable. A `static`
|
||||
(crud's store shape) hangs `serve_with_shutdown`. Corollary: relays
|
||||
hold `Receiver` only, never a `PubSub` clone (mutual-keepalive cycle).
|
||||
Proven by `shutdown_with_open_chat_terminates`.
|
||||
- **`WsHandler::on_open(&mut self, sender)`** added (defaulted,
|
||||
non-breaking): without it a listen-only ws client can never be
|
||||
subscribed (`on_message` never fires). Same panic contract as
|
||||
`on_message` (1011, no `on_close`). The 101 reaches the client before
|
||||
`on_open` runs — subscription liveness needs an app-level ack
|
||||
(documented; the integration tests use a `sync`/`synced` ack).
|
||||
- hammer.sh default subset now includes `ws_`.
|
||||
|
||||
## v0.6 — Channels
|
||||
Chunk 2's ws_chat: rooms as topics, `on_open` subscribes (conn-actor
|
||||
pid) + spawns the relay, `broadcast_from(self_pid(), ..)` for no-echo.
|
||||
|
||||
Phoenix channels: the join/leave/event protocol over WebSocket transport,
|
||||
PubSub underneath.
|
||||
## v0.6 — Channels — DONE (2026-06-12)
|
||||
|
||||
- `Channel` trait: `join(topic, payload, socket)`, `handle_in(event,
|
||||
payload, socket)`, `handle_out`, `terminate`.
|
||||
- Topic router (`"room:*"` patterns) → channel actor per (conn, topic),
|
||||
spawned under the ws connection, linked so conn death reaps channels.
|
||||
- Wire format: Phoenix V2 JSON serializer
|
||||
(`[join_ref, ref, topic, event, payload]`) — free interop with
|
||||
phoenix.js clients is the killer feature; needs a JSON dep or a
|
||||
hand-rolled mini-codec (decision point — this would be dep #3).
|
||||
- Presence: explicitly out of scope until distribution exists somewhere.
|
||||
Join/leave/event protocol over WebSocket transport, PubSub underneath.
|
||||
Design decisions ratified pre-code (2026-06-12):
|
||||
|
||||
**Feature flags.** Two additive features:
|
||||
- `"channels"` — the `Channel` trait, `TopicRouter` trait, `PrefixRouter`,
|
||||
`ChannelSocket`, session machinery. Zero new deps; pubsub is already
|
||||
always-on (it only exists because channels needs it).
|
||||
- `"phoenix"` — implies `"channels"` + `serde`/`serde_json` + the V2 frame
|
||||
codec. Opt-in phoenix.js interop; users who want protobuf or any other
|
||||
wire format take `"channels"` only and supply their own codec.
|
||||
|
||||
**Payload generics.** The `Channel` trait is generic over payload:
|
||||
`P: Encode + Decode` where `Encode`/`Decode` are urus codec traits (one
|
||||
method each). The `"phoenix"` feature provides a `JsonCodec` blanket impl
|
||||
via serde. No JSON anywhere in the `"channels"` surface.
|
||||
|
||||
**`Channel` trait shape.**
|
||||
```
|
||||
trait Channel<P>: Send + 'static {
|
||||
fn join(topic: &str, payload: P, socket: &ChannelSocket<P>) -> Result<P, P>;
|
||||
fn handle_in(&mut self, event: &str, payload: P, socket: &ChannelSocket<P>);
|
||||
fn terminate(&mut self) {}
|
||||
}
|
||||
```
|
||||
`handle_out` deferred — can land as a defaulted method, non-breaking.
|
||||
|
||||
**`ChannelSocket`.** Newtype over `WsSender` + a `PubSub` handle.
|
||||
Exposes `reply(ref, status, payload)`, `push(event, payload)`,
|
||||
`broadcast(topic, payload)`. The `ref` from the incoming frame is threaded
|
||||
in by the channel actor loop, invisible to the impl. Coupling to `PubSub`
|
||||
is intentional: channels owns pubsub, and broadcast is the core use case.
|
||||
|
||||
**Topic router.** `TopicRouter` trait: `fn route(&self, topic: &str) ->
|
||||
Option<Box<dyn ChannelFactory<P>>>`. Factory receives the full raw topic
|
||||
string so wildcard-segment extraction is always possible. urus ships
|
||||
`PrefixRouter` as the default impl: scan to `'*'`, discard the rest,
|
||||
single `HashMap` lookup on the prefix. No regex dep, no glob machinery —
|
||||
users who need that implement `TopicRouter` themselves.
|
||||
|
||||
**Channel actor lifetime + session persistence.**
|
||||
Default: channel actor linked to the ws connection actor, cold-start on
|
||||
every reconnect (phoenix-server-compatible behavior). Opt-in persistence
|
||||
via a `ChannelSession` trait:
|
||||
```
|
||||
trait ChannelSession: Send + 'static {
|
||||
type Key: Eq + Hash + Send + 'static;
|
||||
fn session_key(topic: &str, join_payload: &P) -> Self::Key;
|
||||
fn buffer_cap() -> usize { 128 }
|
||||
fn ttl() -> Duration { Duration::from_secs(30) }
|
||||
}
|
||||
```
|
||||
When implemented, the router consults a session registry (gen_server,
|
||||
same pattern as the connection registry) before spawning: an existing
|
||||
actor is reattached, and buffered outbound messages (`Vec<Arc<M>>`, no
|
||||
re-serialisation) are drained to the reconnecting transport. Buffer full
|
||||
or TTL expired → actor tears down; next join is a cold start. This
|
||||
diverges from Phoenix server conventions (client-re-syncs) but is
|
||||
invisible to phoenix.js at the wire level. Zero-copy drain is the smarm
|
||||
motivation: messages are `Arc<M>` in the buffer and in the PubSub relay
|
||||
path, one allocation per broadcast regardless of subscriber count or
|
||||
reconnect cycles.
|
||||
|
||||
**Presence:** explicitly out of scope until distribution exists.
|
||||
|
||||
**As-landed (2026-06-12), beyond the ratified text — veto by diff:**
|
||||
- chunk 1: `Channel::join` takes `&mut self` (state from join, and
|
||||
session rejoins need the same instance); the in-flight ref is
|
||||
threaded invisibly through the socket; `broadcast` gained an `event`
|
||||
parameter; `TopicRouter::route` returns `Option<Arc<dyn
|
||||
ChannelFactory>>`.
|
||||
- chunk 2: the serde blanket lives behind the `Json<T>` newtype
|
||||
(a blanket on bare `P: Serialize` is coherence poison under additive
|
||||
features); encode failure panics into the linked-teardown contract.
|
||||
- chunk 3: `ChannelFactory` gained a `#[doc(hidden)] deploy()` seam
|
||||
(default = the ephemeral linked actor); every attach re-calls
|
||||
`join()` (re-entrant by contract); rejected rejoin / explicit leave /
|
||||
cap / TTL all end the session; second transport evicts the first;
|
||||
`Key: Clone` (registry's pid→key reverse index); registry spawns
|
||||
eagerly at registration (in-runtime law, same as `ChannelHub::new`);
|
||||
shutdown composes linklessly via the registry-drop chain — proven by
|
||||
`shutdown_with_detached_session_terminates`.
|
||||
|
||||
---
|
||||
|
||||
## Known bugs
|
||||
|
||||
- ~~**HTTP/1.0 keep-alive: server honors but never advertises**~~ FIXED
|
||||
(2026-06-12, same session it was found). As-landed decisions:
|
||||
serialise_response now echoes `connection: keep-alive` on a kept-alive
|
||||
1.0 response (1.1 keep-alive stays implicit); error statuses (>=400)
|
||||
on 1.0 force close — no keep-alive advertised on a 4xx/5xx where reuse
|
||||
is opt-in (conn_actor, alongside the existing 1.0-stream force-close);
|
||||
HEAD needs nothing special (header echo only, framing untouched);
|
||||
parse-error responses already carried `connection: close`. Validated
|
||||
against the original repro: `ab -k -n 5000 -c 50` on GET /users now
|
||||
reports 5000/5000 keep-alive requests, 0 failed, ~63k req/s
|
||||
(vs ~18.9k no-keep-alive baseline, same box). Watch out benching:
|
||||
ab counts a response toward "Non-2xx" silently — a 404'd route still
|
||||
shows "Failed requests: 0" with great rps; check the route first.
|
||||
|
||||
## Later / icebox
|
||||
|
||||
- **HTTP/2** — spec §2.2 already designs the stream-actor demux (zero-copy
|
||||
@@ -182,8 +374,6 @@ PubSub underneath.
|
||||
- **SSE ergonomics round 2** — auto-reconnect support (`Last-Event-ID`),
|
||||
event-id bookkeeping, backpressure policy on slow consumers.
|
||||
- **TLS** — still deferred; acceptor design still must not preclude it.
|
||||
- **`wait_readable_timeout` / fd-in-select upstreaming** — the smarm-side
|
||||
item that simplifies v0.2-3 and v0.4-3 retroactively.
|
||||
- **Middleware ecosystem** — compression, static files, auth plugs.
|
||||
- **Bench suite** — `urus-bench-spec.md` exists in the artefact store;
|
||||
wire it up once v0.2 lands (supervision changes the hot path not at all,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Phoenix-style channels (v0.6): join/leave/event protocol over the
|
||||
//! v0.4 WebSocket duplex, pubsub underneath, phoenix V2 JSON on the
|
||||
//! wire.
|
||||
//!
|
||||
//! cargo run --example channels_chat --features phoenix
|
||||
//! websocat ws://127.0.0.1:8080/socket (run two of these)
|
||||
//! ["1","1","room:lobby","phx_join",{"name":"alice"}]
|
||||
//! ["1","2","room:lobby","shout",{"text":"hi"}]
|
||||
//! ["1","3","room:lobby","phx_leave",{}]
|
||||
//!
|
||||
//! The shapes this demonstrates:
|
||||
//!
|
||||
//! - **One `ChannelHub` for the whole app**, built lazily on the first
|
||||
//! connection via the NON-static `Arc<OnceLock<...>>` pattern (the
|
||||
//! hub spawns the pubsub table; same in-runtime + shutdown laws as
|
||||
//! `examples/ws_chat.rs`, see the docs there).
|
||||
//!
|
||||
//! - **A channel per joined topic, not per socket.** `Room` never sees
|
||||
//! frames, refs, or the transport heartbeat — the conn-side handler
|
||||
//! answers heartbeats, routes `phx_join`, and the per-join channel
|
||||
//! actor (linked to the connection) runs the callbacks sequentially.
|
||||
//!
|
||||
//! - **`Json<T>` is the phoenix codec's opt-in newtype.** The
|
||||
//! `"channels"` core is wire-neutral (`Encode`/`Decode`, zero deps);
|
||||
//! `Json<Value>` here buys phoenix.js V2 interop. Typed payloads work
|
||||
//! the same way: `Json<MyPayload>` for any serde type.
|
||||
//!
|
||||
//! - **`session::*` topics persist across reconnects** (opt-in via
|
||||
//! `channel_session`): drop a websocat mid-room, reconnect and rejoin
|
||||
//! within 30s, and the broadcasts you missed drain to you in order —
|
||||
//! the buffer holds the relay path's own `Arc`s, nothing is copied.
|
||||
//! The session is keyed by the `"name"` in the join payload.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use urus::channels::phoenix::Json;
|
||||
use urus::{
|
||||
serve_with_shutdown, shutdown_handle, Channel, ChannelHub, ChannelSession, ChannelSocket,
|
||||
Config, Conn, Next, Pipeline, PrefixRouter, Router, Status,
|
||||
};
|
||||
|
||||
type P = Json<Value>;
|
||||
|
||||
/// One chat room. State lives across callbacks (and, for `session:*`
|
||||
/// topics, across transports).
|
||||
#[derive(Default)]
|
||||
struct Room {
|
||||
name: String,
|
||||
msgs_seen: u64,
|
||||
}
|
||||
|
||||
impl Channel<P> for Room {
|
||||
fn join(&mut self, topic: &str, payload: P, _s: &ChannelSocket<P>) -> Result<P, P> {
|
||||
// Re-entrant by contract for session topics: a rejoin lands
|
||||
// here again on the same instance — re-auth, then ack.
|
||||
self.name = payload.0["name"].as_str().unwrap_or("anon").to_string();
|
||||
Ok(Json(json!({ "joined": topic, "as": self.name, "seen": self.msgs_seen })))
|
||||
}
|
||||
|
||||
fn handle_in(&mut self, event: &str, payload: P, s: &ChannelSocket<P>) {
|
||||
match event {
|
||||
"shout" => {
|
||||
self.msgs_seen += 1;
|
||||
s.broadcast("shouted", Json(json!({ "from": self.name, "msg": payload.0 })));
|
||||
}
|
||||
_ => s.reply(Status::Ok, Json(json!({ "echo": event }))),
|
||||
}
|
||||
}
|
||||
|
||||
fn terminate(&mut self) {
|
||||
println!("room channel for {:?} is over", self.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Session config for `session:*`: the join payload's `"name"` is the
|
||||
/// session token. Defaults: 128 buffered broadcasts, 30s TTL.
|
||||
struct BySessionName;
|
||||
|
||||
impl ChannelSession<P> for BySessionName {
|
||||
type Key = String;
|
||||
fn session_key(topic: &str, join_payload: &P) -> String {
|
||||
format!("{topic}/{}", join_payload.0["name"].as_str().unwrap_or("anon"))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let hub: Arc<OnceLock<ChannelHub<P>>> = Arc::new(OnceLock::new());
|
||||
|
||||
let router = Router::new().get("/socket", move |c: Conn, _n: Next| {
|
||||
// Hub construction is in-runtime only (it spawns the pubsub
|
||||
// table and, for session patterns, their registries); the
|
||||
// non-static cell is what lets shutdown drain them all.
|
||||
let hub = hub.get_or_init(|| {
|
||||
ChannelHub::new(
|
||||
PrefixRouter::new()
|
||||
.channel_default::<Room>("room:*")
|
||||
.channel_session::<BySessionName>("session:*", |_: &str| {
|
||||
Box::new(Room::default()) as Box<dyn Channel<P>>
|
||||
}),
|
||||
)
|
||||
});
|
||||
hub.upgrade(c)
|
||||
});
|
||||
|
||||
let (handle, signal) = shutdown_handle();
|
||||
std::thread::spawn(move || {
|
||||
println!("channels_chat on ws://127.0.0.1:8080/socket — press Enter to shut down");
|
||||
let mut line = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut line);
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
serve_with_shutdown(Config::new("0.0.0.0:8080".parse().unwrap()), Pipeline::new().plug(router), signal)
|
||||
.unwrap();
|
||||
println!("channels_chat: drained, bye");
|
||||
}
|
||||
+58
-4
@@ -25,7 +25,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use smarm::{channel, Sender};
|
||||
use std::sync::OnceLock;
|
||||
use urus::{serve_with, Config, Conn, Next, Pipeline, Router};
|
||||
use urus::{serve_with_shutdown, shutdown_handle, Config, Conn, Next, Pipeline, Router};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain
|
||||
@@ -78,8 +78,19 @@ fn store_loop(rx: smarm::Receiver<Request>) {
|
||||
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
||||
|
||||
loop {
|
||||
let req = match rx.recv() {
|
||||
// recv with a timeout rather than a bare recv: the store must be
|
||||
// stoppable at shutdown, but a cross-thread Sender::send (from the
|
||||
// stdin thread) can't wake a parked actor — same smarm limitation
|
||||
// that motivates urus's SHUTDOWN_POLL. So we wake on our own timer
|
||||
// and poll the flag. This poll dies with that limitation too.
|
||||
let req = match rx.recv_timeout(std::time::Duration::from_millis(250)) {
|
||||
Ok(r) => r,
|
||||
Err(smarm::channel::RecvTimeoutError::Timeout) => {
|
||||
if SHUTTING_DOWN.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(_) => return, // all senders dropped
|
||||
};
|
||||
match req {
|
||||
@@ -165,6 +176,12 @@ fn persist(users: &[User]) {
|
||||
|
||||
static STORE_TX: OnceLock<Sender<Request>> = OnceLock::new();
|
||||
|
||||
// Set by the stdin thread at shutdown; the store actor polls it (see
|
||||
// store_loop). An always-on app actor that never returns would otherwise
|
||||
// block smarm's AllDone and keep serve_with_shutdown from returning.
|
||||
static SHUTTING_DOWN: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn store() -> &'static Sender<Request> {
|
||||
STORE_TX.get_or_init(|| {
|
||||
let (tx, rx) = channel::<Request>();
|
||||
@@ -187,6 +204,28 @@ fn parse_id(s: &str) -> Option<u64> {
|
||||
// Handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// SSE demo: `curl -N localhost:8080/ticker` streams a tick every second
|
||||
/// (with `: keep-alive` comments if it ever goes quiet). The producer
|
||||
/// exits on SseClosed (client gone / write timeout / shutdown drain) or
|
||||
/// when the example is shutting down.
|
||||
fn ticker(conn: Conn, _next: Next) -> Conn {
|
||||
let (conn, events) = conn.sse();
|
||||
smarm::spawn(move || {
|
||||
let mut n: u64 = 0;
|
||||
loop {
|
||||
if SHUTTING_DOWN.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return; // dropping `events` ends the stream cleanly
|
||||
}
|
||||
if events.send("tick", &n.to_string()).is_err() {
|
||||
return; // SseClosed
|
||||
}
|
||||
n += 1;
|
||||
smarm::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
conn
|
||||
}
|
||||
|
||||
fn list(conn: Conn, _next: Next) -> Conn {
|
||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||
store().send(Request::List { reply: tx }).ok();
|
||||
@@ -261,10 +300,25 @@ fn main() {
|
||||
.post( "/users", create)
|
||||
.get( "/users/:id", get_one)
|
||||
.put( "/users/:id", update)
|
||||
.delete("/users/:id", delete),
|
||||
.delete("/users/:id", delete)
|
||||
.get( "/ticker", ticker),
|
||||
);
|
||||
|
||||
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
||||
println!("urus-crud: DB at {DB_PATH}");
|
||||
serve_with(cfg, pipeline).unwrap();
|
||||
println!("urus-crud: listening on 127.0.0.1:8080 — press Enter to shut down");
|
||||
|
||||
// Graceful shutdown on stdin-Enter: a plain OS thread blocks on
|
||||
// read_line and fires the handle. No signal handling crate needed.
|
||||
let (handle, signal) = shutdown_handle();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut line);
|
||||
println!("urus-crud: shutting down (draining in-flight requests)…");
|
||||
SHUTTING_DOWN.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
||||
println!("urus-crud: bye");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! WebSocket chat rooms (v0.5): `urus::pubsub` wired to the v0.4 duplex.
|
||||
//!
|
||||
//! cargo run --example ws_chat
|
||||
//! websocat ws://127.0.0.1:8080/chat/lobby (run two of these)
|
||||
//!
|
||||
//! The shapes this demonstrates:
|
||||
//!
|
||||
//! - **One `PubSub<String>` for the whole app**, topics are rooms
|
||||
//! (`room:{name}`). Built lazily on the first connection via a
|
||||
//! NON-static `Arc<OnceLock<PubSub>>` captured by the route closure:
|
||||
//! `PubSub::new()` spawns the table actor, which smarm only allows
|
||||
//! in-runtime — and keeping the cell non-static means the table's
|
||||
//! last handle drops in-runtime when the drained pipeline drops, so
|
||||
//! `serve_with_shutdown` actually returns. A `static` cell would pin
|
||||
//! the table forever and block smarm's all-done. (Same constraint
|
||||
//! crud's store solves with its OnceLock; the non-static refinement
|
||||
//! is what makes graceful shutdown compose.)
|
||||
//!
|
||||
//! - **`on_open` subscribes and spawns the relay** — a listen-only
|
||||
//! client receives the room without ever sending. The subscription is
|
||||
//! pinned to the CONNECTION actor (`on_open` runs inside it), so the
|
||||
//! monitor cleans up exactly when the connection dies.
|
||||
//!
|
||||
//! - **The relay holds the `Receiver` and a `WsSender` clone — and
|
||||
//! deliberately NOT a `PubSub` handle.** A relay holding the handle
|
||||
//! keeps the table's inbox open while the table keeps the relay's
|
||||
//! receiver open: neither ever exits, and shutdown hangs. Receiver
|
||||
//! only: conn dies → monitor prunes → sender drops → relay's recv
|
||||
//! errs → relay exits. Every link in that chain is in-runtime.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use urus::{
|
||||
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, PubSub, Router,
|
||||
WsHandler, WsSender,
|
||||
};
|
||||
|
||||
type Bus = PubSub<String>;
|
||||
|
||||
struct ChatHandler {
|
||||
bus: Arc<OnceLock<Bus>>,
|
||||
room: String,
|
||||
}
|
||||
|
||||
impl ChatHandler {
|
||||
fn topic(&self) -> String {
|
||||
format!("room:{}", self.room)
|
||||
}
|
||||
|
||||
fn bus(&self) -> &Bus {
|
||||
// First connection anywhere spawns the table; we are inside the
|
||||
// connection actor here, so the spawn is legal.
|
||||
self.bus.get_or_init(PubSub::new)
|
||||
}
|
||||
}
|
||||
|
||||
impl WsHandler for ChatHandler {
|
||||
fn on_open(&mut self, sender: &WsSender) {
|
||||
let topic = self.topic();
|
||||
let rx = match self.bus().subscribe(&topic) {
|
||||
Ok(rx) => rx,
|
||||
Err(_) => {
|
||||
let _ = sender.close(1011, "chat bus down");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = self
|
||||
.bus()
|
||||
.broadcast_from(smarm::self_pid(), &topic, format!("* someone joined {topic}"));
|
||||
|
||||
// The relay: room messages -> this socket. Exits when the
|
||||
// subscription is pruned (conn death / unsubscribe) or the
|
||||
// socket is gone (WsClosed). See the module docs for why it
|
||||
// must not capture a Bus handle.
|
||||
let out = sender.clone();
|
||||
smarm::spawn(move || {
|
||||
while let Ok(msg) = rx.recv() {
|
||||
if out.send(Message::Text((*msg).clone())).is_err() {
|
||||
break; // client gone or server draining
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn on_message(&mut self, msg: Message, sender: &WsSender) {
|
||||
let Message::Text(text) = msg else {
|
||||
let _ = sender.close(1003, "text only");
|
||||
return;
|
||||
};
|
||||
// broadcast_from: the sender's own relay is skipped — no echo.
|
||||
// self_pid() here is the connection actor, the pid on_open
|
||||
// subscribed as.
|
||||
let _ = self
|
||||
.bus()
|
||||
.broadcast_from(smarm::self_pid(), &self.topic(), text);
|
||||
}
|
||||
|
||||
fn on_close(&mut self, _code: Option<u16>, _reason: &str) {
|
||||
let topic = self.topic();
|
||||
let _ = self
|
||||
.bus()
|
||||
.broadcast_from(smarm::self_pid(), &topic, format!("* someone left {topic}"));
|
||||
// No explicit unsubscribe: the connection actor is about to
|
||||
// exit and the monitor prunes the subscription (which is also
|
||||
// what stops the relay).
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let bus: Arc<OnceLock<Bus>> = Arc::new(OnceLock::new());
|
||||
|
||||
let pipeline = Pipeline::new().plug(Router::new().get("/chat/:room", move |c: Conn, _n: Next| {
|
||||
let room = c.params.get("room").unwrap_or("lobby").to_string();
|
||||
let bus = bus.clone();
|
||||
c.upgrade(ChatHandler { bus, room })
|
||||
}));
|
||||
|
||||
let (handle, signal) = shutdown_handle();
|
||||
std::thread::spawn(move || {
|
||||
println!("ws_chat on ws://127.0.0.1:8080/chat/:room — press Enter to shut down");
|
||||
let mut line = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut line);
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
serve_with_shutdown(Config::new("0.0.0.0:8080".parse().unwrap()), pipeline, signal).unwrap();
|
||||
println!("ws_chat: drained, bye");
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! WebSocket echo server (v0.4).
|
||||
//!
|
||||
//! cargo run --example ws_echo
|
||||
//! websocat ws://127.0.0.1:8080/echo
|
||||
//!
|
||||
//! Demonstrates the chunk-3 handler model:
|
||||
//! - `EchoHandler` runs INSIDE the connection actor's select loop — echo
|
||||
//! is exactly the workload that wants zero extra moving parts.
|
||||
//! - `/clock` shows the other shape: the handler spawns a producer actor
|
||||
//! at `on_message("start")` and hands it a `WsSender` clone — the
|
||||
//! SSE-producer pattern, verbatim. The producer exits when its send
|
||||
//! fails (`WsClosed`: client gone or server shutting down).
|
||||
//!
|
||||
//! Routing happens before the upgrade, so one server can host both
|
||||
//! endpoints — `Conn::upgrade(handler)` is just another thing a route
|
||||
//! handler returns.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use urus::{
|
||||
serve_with_shutdown, shutdown_handle, Config, Conn, Message, Next, Pipeline, Router,
|
||||
WsHandler, WsSender,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /echo — everything comes straight back
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct EchoHandler;
|
||||
|
||||
impl WsHandler for EchoHandler {
|
||||
fn on_message(&mut self, msg: Message, sender: &WsSender) {
|
||||
if matches!(&msg, Message::Text(t) if t == "bye") {
|
||||
let _ = sender.close(1000, "you said bye");
|
||||
return;
|
||||
}
|
||||
let _ = sender.send(msg);
|
||||
}
|
||||
|
||||
fn on_close(&mut self, code: Option<u16>, reason: &str) {
|
||||
println!("ws_echo: /echo closed (code {code:?}, reason {reason:?})");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// /clock — "start" spawns a producer actor ticking once a second
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ClockHandler {
|
||||
started: bool,
|
||||
}
|
||||
|
||||
impl WsHandler for ClockHandler {
|
||||
fn on_message(&mut self, msg: Message, sender: &WsSender) {
|
||||
match msg {
|
||||
Message::Text(t) if t == "start" && !self.started => {
|
||||
self.started = true;
|
||||
let out = sender.clone();
|
||||
smarm::spawn(move || {
|
||||
let mut n = 0u64;
|
||||
loop {
|
||||
if out.text(format!("tick {n}")).is_err() {
|
||||
return; // WsClosed: connection over, we follow
|
||||
}
|
||||
n += 1;
|
||||
smarm::sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
let _ = sender.text("send \"start\" to begin");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() {
|
||||
let pipeline = Pipeline::new().plug(
|
||||
Router::new()
|
||||
.get("/echo", |c: Conn, _n: Next| c.upgrade(EchoHandler))
|
||||
.get("/clock", |c: Conn, _n: Next| {
|
||||
c.upgrade(ClockHandler { started: false })
|
||||
}),
|
||||
);
|
||||
|
||||
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
||||
println!("ws_echo: ws://127.0.0.1:8080/echo and /clock — press Enter to shut down");
|
||||
|
||||
let (handle, signal) = shutdown_handle();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let _ = std::io::stdin().read_line(&mut line);
|
||||
println!("ws_echo: shutting down…");
|
||||
handle.shutdown();
|
||||
});
|
||||
|
||||
serve_with_shutdown(cfg, pipeline, signal).unwrap();
|
||||
println!("ws_echo: bye");
|
||||
}
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Flake hammer: the conn-lifecycle test discipline, in one command.
|
||||
#
|
||||
# Default (no args) = the full pre-commit ritual for conn-lifecycle changes:
|
||||
# 1. N x the lifecycle-sensitive integration subset (fail-fast)
|
||||
# 2. M x the full suite (unit + integration + doc)
|
||||
#
|
||||
# Usage:
|
||||
# scripts/hammer.sh # 35x subset + 3x full
|
||||
# scripts/hammer.sh -n 50 -f 5 # override counts
|
||||
# scripts/hammer.sh -n 20 -f 0 -- shutdown sse # custom filter, skip full runs
|
||||
#
|
||||
# On failure: stops immediately, output of the failing run is in $LOG.
|
||||
|
||||
set -u
|
||||
cd "$(dirname "$0")/.."
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
|
||||
N=35
|
||||
FULL=3
|
||||
SUBSET=(shutdown timeout reaped slowloris streaming chunked sse stalled ws_)
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-n) N="$2"; shift 2 ;;
|
||||
-f) FULL="$2"; shift 2 ;;
|
||||
--) shift; SUBSET=("$@"); break ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
LOG=target/hammer-fail.log
|
||||
mkdir -p target
|
||||
|
||||
echo "== build once =="
|
||||
cargo test --no-run --quiet || exit 1
|
||||
|
||||
fail() { # $1 = label
|
||||
echo
|
||||
echo "FAILED at $1 — output in $LOG"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "== ${N}x subset: ${SUBSET[*]} =="
|
||||
for ((i = 1; i <= N; i++)); do
|
||||
printf '\r subset %d/%d ' "$i" "$N"
|
||||
cargo test --quiet --test integration -- "${SUBSET[@]}" >"$LOG" 2>&1 \
|
||||
|| fail "subset run $i/$N"
|
||||
done
|
||||
echo
|
||||
|
||||
echo "== ${FULL}x full suite =="
|
||||
for ((i = 1; i <= FULL; i++)); do
|
||||
printf ' full %d/%d\n' "$i" "$FULL"
|
||||
cargo test --quiet >"$LOG" 2>&1 || fail "full run $i/$FULL"
|
||||
done
|
||||
|
||||
rm -f "$LOG"
|
||||
echo "hammer green: ${N}x subset + ${FULL}x full"
|
||||
+1010
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,223 @@
|
||||
//! Phoenix V2 wire codec — feature `"phoenix"` (implies `"channels"`,
|
||||
//! spends dependency slot #4 on `serde`/`serde_json`, ratified
|
||||
//! 2026-06-12).
|
||||
//!
|
||||
//! The wire format phoenix.js speaks: one JSON array per frame,
|
||||
//!
|
||||
//! ```text
|
||||
//! [join_ref, ref, topic, event, payload]
|
||||
//! ```
|
||||
//!
|
||||
//! refs are strings or `null`; replies are `event: "phx_reply"` with
|
||||
//! `payload: {"status": "ok"|"error", "response": <payload>}`.
|
||||
//!
|
||||
//! # `Json<T>`, not a blanket impl on `P`
|
||||
//!
|
||||
//! The ratified sketch said "JsonCodec blanket impl via serde". A
|
||||
//! literal `impl<P: Serialize + DeserializeOwned> Encode for P` is
|
||||
//! coherence poison: features are additive across a dependency graph,
|
||||
//! so the moment ANY crate enables `"phoenix"`, every hand-written
|
||||
//! `Encode` impl in every other crate stops compiling (the compiler
|
||||
//! cannot prove a foreign type will never gain `Serialize`). That
|
||||
//! breaks the design's own promise that `"channels"`-only users supply
|
||||
//! their own codecs. The blanket therefore lives one newtype away:
|
||||
//! [`Json<T>`] is V2-JSON for ANY serde-capable `T` — including
|
||||
//! `Json<serde_json::Value>` for schemaless payloads — while the
|
||||
//! `Encode`/`Decode` namespace stays open.
|
||||
//!
|
||||
//! # Strictness
|
||||
//!
|
||||
//! Decoding is exactly as strict as `T`'s `Deserialize`: a phoenix.js
|
||||
//! `{}` join payload fails against a `T` with required fields (the
|
||||
//! socket closes `1002`). Use `#[serde(default)]`, `Option` fields, or
|
||||
//! `Json<serde_json::Value>` where the client is loose. Encoding a `T`
|
||||
//! that fails `Serialize` (non-string map keys and friends) panics —
|
||||
//! that is a bug in the payload type, and the panic rides the
|
||||
//! documented channel-panic contract (linked teardown).
|
||||
|
||||
use super::{
|
||||
ChannelFrame, ChannelMessage, Decode, DecodeError, Encode, FrameRef, MessageRef, Status,
|
||||
EV_REPLY,
|
||||
};
|
||||
use crate::ws::Message;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
/// V2-JSON payload wrapper: `Json<T>` speaks the phoenix wire for any
|
||||
/// `T: Serialize + DeserializeOwned`. Derefs to `T`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct Json<T>(pub T);
|
||||
|
||||
impl<T> std::ops::Deref for Json<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
impl<T> std::ops::DerefMut for Json<T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
&mut self.0
|
||||
}
|
||||
}
|
||||
impl<T> From<T> for Json<T> {
|
||||
fn from(t: T) -> Self {
|
||||
Json(t)
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_object() -> Value {
|
||||
Value::Object(Map::new())
|
||||
}
|
||||
|
||||
fn to_value<T: Serialize>(t: &T) -> Value {
|
||||
serde_json::to_value(t).expect("phoenix codec: payload failed to serialize")
|
||||
}
|
||||
|
||||
impl<T: Serialize + DeserializeOwned + Send + Sync + 'static> Encode for Json<T> {
|
||||
fn encode(f: FrameRef<'_, Self>) -> Message {
|
||||
let (event, payload) = match f.message {
|
||||
MessageRef::Event { event, payload } => {
|
||||
(event, payload.map(|p| to_value(&p.0)).unwrap_or_else(empty_object))
|
||||
}
|
||||
MessageRef::Reply { status, payload } => (
|
||||
EV_REPLY,
|
||||
json!({
|
||||
"status": match status {
|
||||
Status::Ok => "ok",
|
||||
Status::Error => "error",
|
||||
},
|
||||
"response": payload.map(|p| to_value(&p.0)).unwrap_or_else(empty_object),
|
||||
}),
|
||||
),
|
||||
};
|
||||
let arr = json!([f.join_ref, f.reference, f.topic, event, payload]);
|
||||
Message::Text(arr.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Serialize + DeserializeOwned + Send + Sync + 'static> Decode for Json<T> {
|
||||
fn decode(msg: &Message) -> Result<ChannelFrame<Self>, DecodeError> {
|
||||
let Message::Text(s) = msg else {
|
||||
return Err(DecodeError("phoenix V2 frames are text".into()));
|
||||
};
|
||||
let (join_ref, reference, topic, event, payload): (
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
String,
|
||||
String,
|
||||
Value,
|
||||
) = serde_json::from_str(s).map_err(|e| DecodeError(e.to_string()))?;
|
||||
|
||||
let message = if event == EV_REPLY {
|
||||
// Clients don't reply in practice; decoded for symmetry
|
||||
// (and ignored upstream).
|
||||
let status = match payload.get("status").and_then(Value::as_str) {
|
||||
Some("ok") => Status::Ok,
|
||||
_ => Status::Error,
|
||||
};
|
||||
let response = payload.get("response").cloned().unwrap_or_else(empty_object);
|
||||
let payload = serde_json::from_value::<T>(response)
|
||||
.map_err(|e| DecodeError(format!("reply response: {e}")))?;
|
||||
ChannelMessage::Reply { status, payload: Some(Json(payload)) }
|
||||
} else {
|
||||
let payload = serde_json::from_value::<T>(payload)
|
||||
.map_err(|e| DecodeError(format!("payload for '{event}': {e}")))?;
|
||||
ChannelMessage::Event { event, payload: Json(payload) }
|
||||
};
|
||||
Ok(ChannelFrame { join_ref, reference, topic, message })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
type V = Json<Value>;
|
||||
|
||||
fn parse(m: &Message) -> Value {
|
||||
let Message::Text(s) = m else { panic!("not text") };
|
||||
serde_json::from_str(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_phoenix_js_join_frame() {
|
||||
let m = Message::Text(r#"["3","3","room:lobby","phx_join",{"token":"t"}]"#.into());
|
||||
let f = V::decode(&m).unwrap();
|
||||
assert_eq!(f.join_ref.as_deref(), Some("3"));
|
||||
assert_eq!(f.reference.as_deref(), Some("3"));
|
||||
assert_eq!(f.topic, "room:lobby");
|
||||
let ChannelMessage::Event { event, payload } = f.message else { panic!() };
|
||||
assert_eq!(event, "phx_join");
|
||||
assert_eq!(payload.0, json!({"token": "t"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_heartbeat_null_refs_allowed() {
|
||||
let m = Message::Text(r#"[null,"7","phoenix","heartbeat",{}]"#.into());
|
||||
let f = V::decode(&m).unwrap();
|
||||
assert_eq!(f.join_ref, None);
|
||||
assert_eq!(f.topic, "phoenix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_ok_reply_with_status_response_wrapper() {
|
||||
let p = Json(json!({"user_count": 2}));
|
||||
let m = V::encode(FrameRef {
|
||||
join_ref: Some("3"),
|
||||
reference: Some("4"),
|
||||
topic: "room:lobby",
|
||||
message: MessageRef::Reply { status: Status::Ok, payload: Some(&p) },
|
||||
});
|
||||
assert_eq!(
|
||||
parse(&m),
|
||||
json!(["3", "4", "room:lobby", "phx_reply",
|
||||
{"status": "ok", "response": {"user_count": 2}}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_payloadless_reply_and_event_as_empty_object() {
|
||||
let m = V::encode(FrameRef {
|
||||
join_ref: None,
|
||||
reference: Some("9"),
|
||||
topic: "phoenix",
|
||||
message: MessageRef::Reply { status: Status::Ok, payload: None },
|
||||
});
|
||||
assert_eq!(
|
||||
parse(&m),
|
||||
json!([null, "9", "phoenix", "phx_reply", {"status": "ok", "response": {}}])
|
||||
);
|
||||
let m = V::encode(FrameRef {
|
||||
join_ref: Some("3"),
|
||||
reference: None,
|
||||
topic: "room:lobby",
|
||||
message: MessageRef::Event { event: "phx_close", payload: None },
|
||||
});
|
||||
assert_eq!(parse(&m), json!(["3", null, "room:lobby", "phx_close", {}]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_payload_round_trip_and_strictness() {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||
struct Chat {
|
||||
body: String,
|
||||
}
|
||||
let m = Message::Text(r#"["1","2","room:a","new_msg",{"body":"hi"}]"#.into());
|
||||
let f = Json::<Chat>::decode(&m).unwrap();
|
||||
let ChannelMessage::Event { payload, .. } = f.message else { panic!() };
|
||||
assert_eq!(payload.0, Chat { body: "hi".into() });
|
||||
|
||||
// Missing required field: the codec is as strict as T.
|
||||
let m = Message::Text(r#"["1","2","room:a","new_msg",{}]"#.into());
|
||||
assert!(Json::<Chat>::decode(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_and_binary_are_decode_errors() {
|
||||
assert!(V::decode(&Message::Text("not json".into())).is_err());
|
||||
assert!(V::decode(&Message::Text(r#"{"a":1}"#.into())).is_err());
|
||||
assert!(V::decode(&Message::Binary(vec![1, 2, 3])).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
//! Opt-in channel session persistence (the ratified v0.6 design).
|
||||
//!
|
||||
//! Default channel lifetime is transport-bound: cold start per join,
|
||||
//! actor dies with the socket. A [`ChannelSession`] impl registered via
|
||||
//! [`PrefixRouter::channel_session`](super::PrefixRouter::channel_session)
|
||||
//! changes that: the channel actor outlives its transport, buffering
|
||||
//! outbound broadcasts (`VecDeque<Arc<Broadcast<P>>>` — the same `Arc`s
|
||||
//! the pubsub relay path carries, zero re-allocation) until the client
|
||||
//! rejoins, the buffer fills, or the TTL expires.
|
||||
//!
|
||||
//! Shape (one registry gen_server per `channel_session` registration,
|
||||
//! monomorphic over the session key — no type erasure):
|
||||
//!
|
||||
//! - `deploy` on the conn actor computes the session key and casts the
|
||||
//! whole join handshake to the registry.
|
||||
//! - The registry owns `key -> (pid, control sender)`. Existing entry:
|
||||
//! the handshake is forwarded as an `Attach` (reattach). Absent or
|
||||
//! dead (send failure raced the monitor `Down`): spawn fresh,
|
||||
//! monitor, insert. Down prunes, guarded by pid match against
|
||||
//! replaced entries.
|
||||
//! - The session actor is **not linked** to any connection — outliving
|
||||
//! the transport is the point. Its exits: explicit leave, rejected
|
||||
//! (re)join, TTL expiry, buffer cap, pubsub relay death, or the
|
||||
//! registry's control sender dropping — which is exactly the shutdown
|
||||
//! chain (conns die -> router `Arc`s drop -> registry's inbox closes
|
||||
//! -> registry exits -> control senders drop -> every parked session
|
||||
//! wakes on the closed control arm, terminates, and exits; `AllDone`
|
||||
//! composes without links).
|
||||
//!
|
||||
//! As-landed decisions (veto by diff):
|
||||
//! - **Every attach calls `ch.join()` again** on the same instance —
|
||||
//! the client's `phx_join` needs an ack payload and the channel gets
|
||||
//! to re-auth. Session channels must treat `join` as re-entrant.
|
||||
//! - **A rejected rejoin ends the session** (error reply, `terminate`,
|
||||
//! exit): `Err` means "this transport may not have this channel", and
|
||||
//! a zombie session held open for an unauthorized client is a
|
||||
//! liability. Next join is a cold start.
|
||||
//! - **A second transport evicts the first** (best-effort `phx_close`
|
||||
//! on the old socket): a session is single-transport by definition;
|
||||
//! two tabs wanting independent channels should not share a session
|
||||
//! key.
|
||||
//! - **TTL is per detach episode** (reset on every disconnect), and the
|
||||
//! pubsub subscription is made once, on the first successful join, to
|
||||
//! the first join's topic.
|
||||
//! - **`ChannelSession::Key: Clone`** beyond the ratified
|
||||
//! `Eq + Hash + Send` — the registry keeps a `pid -> key` reverse
|
||||
//! index for monitor-`Down` pruning.
|
||||
|
||||
use super::*;
|
||||
|
||||
use smarm::gen_server::{self, GenServer, ServerCtx};
|
||||
use smarm::{Down, ServerRef, Watcher};
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::hash::Hash;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Opt-in session persistence config for a channel type. All methods
|
||||
/// are static: the config is consulted before any channel instance
|
||||
/// exists.
|
||||
pub trait ChannelSession<P>: Send + 'static {
|
||||
/// What identifies "the same session" across reconnects.
|
||||
type Key: Eq + Hash + Send + 'static;
|
||||
|
||||
/// Derive the session key from a join. Same key, same (live)
|
||||
/// channel actor; the join payload is the natural carrier for a
|
||||
/// client-held session token.
|
||||
fn session_key(topic: &str, join_payload: &P) -> Self::Key;
|
||||
|
||||
/// Detached broadcasts buffered before the session tears itself
|
||||
/// down (next join is a cold start).
|
||||
fn buffer_cap() -> usize {
|
||||
128
|
||||
}
|
||||
|
||||
/// How long a detached session waits for a rejoin before tearing
|
||||
/// itself down. Reset on every detach.
|
||||
fn ttl() -> Duration {
|
||||
Duration::from_secs(30)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The session-aware factory (what channel_session registers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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>>,
|
||||
}
|
||||
|
||||
/// The registry's working bounds for a session key.
|
||||
pub(super) trait SessionKey: Eq + Hash + Clone + Send + 'static {}
|
||||
impl<K: Eq + Hash + Clone + Send + 'static> SessionKey for K {}
|
||||
|
||||
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> SessionFactory<P, K> {
|
||||
/// **In-runtime only** — spawns the registry gen_server.
|
||||
pub(super) fn new<S: ChannelSession<P, Key = K>>(inner: Arc<dyn ChannelFactory<P>>) -> Self {
|
||||
let registry = gen_server::start(Registry {
|
||||
factory: inner.clone(),
|
||||
cap: S::buffer_cap(),
|
||||
ttl: S::ttl(),
|
||||
sessions: HashMap::new(),
|
||||
pid_key: HashMap::new(),
|
||||
watcher: None,
|
||||
});
|
||||
SessionFactory { inner, keyfn: S::session_key, registry }
|
||||
}
|
||||
}
|
||||
|
||||
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> ChannelFactory<P>
|
||||
for SessionFactory<P, K>
|
||||
{
|
||||
fn create(&self, topic: &str) -> Box<dyn Channel<P>> {
|
||||
self.inner.create(topic)
|
||||
}
|
||||
|
||||
fn deploy(&self, hs: JoinHandshake<P>) -> ChannelInbox<P>
|
||||
where
|
||||
P: Encode + Decode,
|
||||
{
|
||||
let key = (self.keyfn)(&hs.topic, &hs.payload);
|
||||
let (tx, rx) = smarm::channel::<In<P>>();
|
||||
// Cast: the actor acks the join straight to the socket; the
|
||||
// conn actor has nothing to wait for. A dead registry can only
|
||||
// mean shutdown — the failed join is moot.
|
||||
let _ = self.registry.cast(Join { key, hs, inbound: rx });
|
||||
ChannelInbox(tx)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The registry gen_server: key -> live session actor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) struct Join<P: Send + Sync + 'static, K> {
|
||||
key: K,
|
||||
hs: JoinHandshake<P>,
|
||||
inbound: Receiver<In<P>>,
|
||||
}
|
||||
|
||||
struct Registry<P: Send + Sync + 'static, K> {
|
||||
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>,
|
||||
}
|
||||
|
||||
impl<P: Encode + Decode + Send + Sync + 'static, K: SessionKey> GenServer for Registry<P, K> {
|
||||
type Call = ();
|
||||
type Reply = ();
|
||||
type Cast = Join<P, K>;
|
||||
type Info = ();
|
||||
|
||||
fn init(&mut self, ctx: &ServerCtx) {
|
||||
self.watcher = Some(ctx.watcher());
|
||||
}
|
||||
|
||||
fn handle_call(&mut self, _request: ()) {}
|
||||
|
||||
fn handle_cast(&mut self, Join { key, hs, inbound }: Join<P, K>) {
|
||||
let attach = Ctl::Attach { hs, inbound };
|
||||
let attach = match self.sessions.get(&key) {
|
||||
Some((_, ctl)) => match ctl.send(attach) {
|
||||
Ok(()) => return, // reattached
|
||||
// The actor died and its Down hasn't landed yet:
|
||||
// recover the handshake, replace below.
|
||||
Err(smarm::channel::SendError(a)) => a,
|
||||
},
|
||||
None => attach,
|
||||
};
|
||||
let (ctl_tx, ctl_rx) = smarm::channel::<Ctl<P>>();
|
||||
let (factory, cap, ttl) = (self.factory.clone(), self.cap, self.ttl);
|
||||
let pid = smarm::spawn(move || run_session(factory, cap, ttl, ctl_rx)).pid();
|
||||
// Cannot fail: the receiver is owned by the just-spawned actor.
|
||||
let _ = ctl_tx.send(attach);
|
||||
self.watcher
|
||||
.as_ref()
|
||||
.expect("watcher set in init")
|
||||
.watch(smarm::monitor(pid));
|
||||
self.pid_key.insert(pid, key.clone());
|
||||
self.sessions.insert(key, (pid, ctl_tx));
|
||||
}
|
||||
|
||||
fn handle_down(&mut self, down: Down) {
|
||||
if let Some(key) = self.pid_key.remove(&down.pid) {
|
||||
// Pid guard: a stale Down for an entry handle_cast already
|
||||
// replaced must not evict the replacement.
|
||||
if self.sessions.get(&key).is_some_and(|(p, _)| *p == down.pid) {
|
||||
self.sessions.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The session actor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Ctl<P: Send + Sync + 'static> {
|
||||
Attach { hs: JoinHandshake<P>, inbound: Receiver<In<P>> },
|
||||
}
|
||||
|
||||
enum Step<P: Send + Sync + 'static> {
|
||||
Attach { hs: JoinHandshake<P>, inbound: Receiver<In<P>> },
|
||||
Attached { inbound: Receiver<In<P>> },
|
||||
Detached,
|
||||
Exit,
|
||||
}
|
||||
|
||||
fn run_session<P: Encode + Decode + Send + Sync + 'static>(
|
||||
factory: Arc<dyn ChannelFactory<P>>,
|
||||
cap: usize,
|
||||
ttl: Duration,
|
||||
ctl: Receiver<Ctl<P>>,
|
||||
) {
|
||||
// The initial Attach is cast by the registry in the same handler
|
||||
// that spawned us. Closed instead = registry died first (shutdown
|
||||
// raced the spawn): nothing exists yet, nothing to clean.
|
||||
let Ok(Ctl::Attach { hs, inbound }) = ctl.recv() else { return };
|
||||
|
||||
// Partial moves: ws/bus/topic/join_ref go into the socket,
|
||||
// reference/payload stay behind for the first attach below.
|
||||
let mut socket = ChannelSocket {
|
||||
ws: hs.ws,
|
||||
bus: hs.bus,
|
||||
topic: hs.topic,
|
||||
join_ref: hs.join_ref,
|
||||
cur_ref: RefCell::new(None),
|
||||
};
|
||||
let mut ch: Option<Box<dyn Channel<P>>> = None;
|
||||
let mut joined = false;
|
||||
let mut bus_rx: Option<Receiver<Arc<Broadcast<P>>>> = None;
|
||||
let mut buffer: VecDeque<Arc<Broadcast<P>>> = VecDeque::new();
|
||||
|
||||
let mut step = attach(
|
||||
&factory,
|
||||
&mut ch,
|
||||
&mut joined,
|
||||
&mut bus_rx,
|
||||
&mut buffer,
|
||||
&mut socket,
|
||||
hs.reference,
|
||||
hs.payload,
|
||||
inbound,
|
||||
);
|
||||
loop {
|
||||
step = match step {
|
||||
Step::Attach { hs, inbound } => {
|
||||
socket.ws = hs.ws;
|
||||
socket.join_ref = hs.join_ref;
|
||||
socket.topic = hs.topic;
|
||||
attach(
|
||||
&factory,
|
||||
&mut ch,
|
||||
&mut joined,
|
||||
&mut bus_rx,
|
||||
&mut buffer,
|
||||
&mut socket,
|
||||
hs.reference,
|
||||
hs.payload,
|
||||
inbound,
|
||||
)
|
||||
}
|
||||
Step::Attached { inbound } => attached_loop(
|
||||
ch.as_deref_mut().expect("attached implies created"),
|
||||
&socket,
|
||||
&ctl,
|
||||
&inbound,
|
||||
bus_rx.as_ref().expect("attached implies subscribed"),
|
||||
&mut buffer,
|
||||
),
|
||||
Step::Detached => detached_loop(
|
||||
&ctl,
|
||||
bus_rx.as_ref().expect("detached only after first subscribe"),
|
||||
&mut buffer,
|
||||
cap,
|
||||
ttl,
|
||||
),
|
||||
Step::Exit => {
|
||||
if joined {
|
||||
if let Some(c) = ch.as_deref_mut() {
|
||||
c.terminate();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// One attach handshake: create-once, (re)join, subscribe-once, ack,
|
||||
/// drain. On a drain failure the undelivered tail stays in `buffer`
|
||||
/// for the next attach.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn attach<P: Encode + Decode + Send + Sync + 'static>(
|
||||
factory: &Arc<dyn ChannelFactory<P>>,
|
||||
ch: &mut Option<Box<dyn Channel<P>>>,
|
||||
joined: &mut bool,
|
||||
bus_rx: &mut Option<Receiver<Arc<Broadcast<P>>>>,
|
||||
buffer: &mut VecDeque<Arc<Broadcast<P>>>,
|
||||
socket: &mut ChannelSocket<P>,
|
||||
reference: Option<String>,
|
||||
payload: P,
|
||||
inbound: Receiver<In<P>>,
|
||||
) -> Step<P> {
|
||||
if ch.is_none() {
|
||||
*ch = Some(factory.create(&socket.topic));
|
||||
}
|
||||
let c = ch.as_deref_mut().expect("just created");
|
||||
|
||||
socket.set_ref(reference);
|
||||
let topic = socket.topic.clone();
|
||||
match c.join(&topic, payload, socket) {
|
||||
Ok(reply) => {
|
||||
if bus_rx.is_none() {
|
||||
// First successful join: subscribe BEFORE the ok reply
|
||||
// (subscribe is a call — once the client sees its ack,
|
||||
// membership is a fact, not a race). Once per session:
|
||||
// the subscription is actor-scoped, survives detach,
|
||||
// and that survival IS the buffering path.
|
||||
match socket.bus.subscribe(&topic) {
|
||||
Ok(rx) => *bus_rx = Some(rx),
|
||||
Err(_) => {
|
||||
socket.send_reply(Status::Error, None);
|
||||
socket.set_ref(None);
|
||||
return Step::Exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
*joined = true;
|
||||
socket.send_reply(Status::Ok, Some(&reply));
|
||||
socket.set_ref(None);
|
||||
// Drain with the NEW join generation's ref; buffered pushes
|
||||
// have no request to correlate to (reference: None).
|
||||
while let Some(b) = buffer.front() {
|
||||
let out = P::encode(FrameRef {
|
||||
join_ref: socket.join_ref.as_deref(),
|
||||
reference: None,
|
||||
topic: &socket.topic,
|
||||
message: MessageRef::Event { event: &b.event, payload: Some(&b.payload) },
|
||||
});
|
||||
if socket.ws.send(out).is_ok() {
|
||||
buffer.pop_front();
|
||||
} else {
|
||||
return Step::Detached;
|
||||
}
|
||||
}
|
||||
Step::Attached { inbound }
|
||||
}
|
||||
Err(reply) => {
|
||||
// Rejected (re)join = session over, by decision: error
|
||||
// reply, terminate (in run_session, iff it ever joined),
|
||||
// exit. Next join is a cold start.
|
||||
socket.send_reply(Status::Error, Some(&reply));
|
||||
socket.set_ref(None);
|
||||
Step::Exit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attached_loop<P: Encode + Decode + Send + Sync + 'static>(
|
||||
ch: &mut dyn Channel<P>,
|
||||
socket: &ChannelSocket<P>,
|
||||
ctl: &Receiver<Ctl<P>>,
|
||||
inbound: &Receiver<In<P>>,
|
||||
bus_rx: &Receiver<Arc<Broadcast<P>>>,
|
||||
buffer: &mut VecDeque<Arc<Broadcast<P>>>,
|
||||
) -> Step<P> {
|
||||
// ctl sits at lowest priority: it only ever carries an eviction by
|
||||
// a second transport (rare) or the closed-arm shutdown signal. A
|
||||
// closed arm stays ready forever, but every closed observation
|
||||
// exits the loop immediately — no spin, no starvation window.
|
||||
loop {
|
||||
match smarm::select(&[inbound, bus_rx, ctl]) {
|
||||
0 => match inbound.try_recv() {
|
||||
Ok(Some(In::Event { reference, event, payload })) => {
|
||||
socket.set_ref(reference);
|
||||
ch.handle_in(&event, payload, socket);
|
||||
socket.set_ref(None);
|
||||
}
|
||||
Ok(Some(In::Leave { reference })) => {
|
||||
// Explicit leave ends the SESSION, not just the
|
||||
// attachment: ok reply, close event, terminate.
|
||||
socket.set_ref(reference);
|
||||
socket.send_reply(Status::Ok, None);
|
||||
socket.set_ref(None);
|
||||
close_event(socket);
|
||||
return Step::Exit;
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Conn actor finished or replaced this join generation:
|
||||
// the transport is gone, the session is not.
|
||||
Err(_) => return Step::Detached,
|
||||
},
|
||||
1 => match bus_rx.try_recv() {
|
||||
Ok(Some(b)) => {
|
||||
let out = P::encode(FrameRef {
|
||||
join_ref: socket.join_ref.as_deref(),
|
||||
reference: None,
|
||||
topic: &socket.topic,
|
||||
message: MessageRef::Event { event: &b.event, payload: Some(&b.payload) },
|
||||
});
|
||||
if socket.ws.send(out).is_err() {
|
||||
// Transport died under us: this broadcast is
|
||||
// the buffer's first entry, nothing was lost.
|
||||
buffer.push_back(b);
|
||||
return Step::Detached;
|
||||
}
|
||||
}
|
||||
Ok(None) => {}
|
||||
// The topic relay died: pubsub is gone, session over.
|
||||
Err(_) => return Step::Exit,
|
||||
},
|
||||
2 => match ctl.try_recv() {
|
||||
Ok(Some(Ctl::Attach { hs, inbound })) => {
|
||||
// A second transport claims the session key: evict
|
||||
// this one (best effort — it may already be dead)
|
||||
// and hand the session over.
|
||||
close_event(socket);
|
||||
return Step::Attach { hs, inbound };
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Registry gone = shutdown chain: terminate and exit.
|
||||
Err(_) => return Step::Exit,
|
||||
},
|
||||
_ => unreachable!("three arms"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detached_loop<P: Encode + Decode + Send + Sync + 'static>(
|
||||
ctl: &Receiver<Ctl<P>>,
|
||||
bus_rx: &Receiver<Arc<Broadcast<P>>>,
|
||||
buffer: &mut VecDeque<Arc<Broadcast<P>>>,
|
||||
cap: usize,
|
||||
ttl: Duration,
|
||||
) -> Step<P> {
|
||||
// TTL is per detach episode: the clock starts now, every time.
|
||||
let deadline = Instant::now() + ttl;
|
||||
loop {
|
||||
// cap.max(1): a cap of 0 means "no buffering tolerated" — the
|
||||
// first buffered broadcast tears the session down; an empty
|
||||
// buffer still waits out the TTL.
|
||||
if buffer.len() >= cap.max(1) {
|
||||
return Step::Exit;
|
||||
}
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match smarm::select_timeout(&[ctl, bus_rx], remaining) {
|
||||
// TTL expired with no rejoin: session over.
|
||||
None => return Step::Exit,
|
||||
Some(0) => match ctl.try_recv() {
|
||||
Ok(Some(Ctl::Attach { hs, inbound })) => return Step::Attach { hs, inbound },
|
||||
Ok(None) => {}
|
||||
Err(_) => return Step::Exit,
|
||||
},
|
||||
Some(1) => match bus_rx.try_recv() {
|
||||
Ok(Some(b)) => buffer.push_back(b),
|
||||
Ok(None) => {}
|
||||
Err(_) => return Step::Exit,
|
||||
},
|
||||
Some(_) => unreachable!("two arms"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort `phx_close` push for the socket's current join
|
||||
/// generation.
|
||||
fn close_event<P: Encode + Decode + Send + Sync + 'static>(socket: &ChannelSocket<P>) {
|
||||
let _ = socket.ws.send(P::encode(FrameRef {
|
||||
join_ref: socket.join_ref.as_deref(),
|
||||
reference: None,
|
||||
topic: &socket.topic,
|
||||
message: MessageRef::Event { event: EV_CLOSE, payload: None },
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests — runtime-backed, on the toy pipe codec from testkit. The
|
||||
// session-config types (`Cfg*`) are deliberately separate from the
|
||||
// channel they configure: `channel_session::<S>` only needs `S` for
|
||||
// keying/cap/ttl, the factory builds the channel.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::testkit::{eventually, Harness, TP};
|
||||
use super::super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Counts its joins (cold start replies `joined:1`); flags
|
||||
/// terminate; broadcasts on "shout".
|
||||
struct Counter {
|
||||
joins: u32,
|
||||
term: Arc<AtomicBool>,
|
||||
reject_rejoin: bool,
|
||||
}
|
||||
|
||||
impl Channel<TP> for Counter {
|
||||
fn join(&mut self, _topic: &str, _payload: TP, _s: &ChannelSocket<TP>) -> Result<TP, TP> {
|
||||
self.joins += 1;
|
||||
if self.reject_rejoin && self.joins > 1 {
|
||||
return Err(TP("not again".into()));
|
||||
}
|
||||
Ok(TP(format!("joined:{}", self.joins)))
|
||||
}
|
||||
fn handle_in(&mut self, event: &str, payload: TP, s: &ChannelSocket<TP>) {
|
||||
match event {
|
||||
"shout" => s.broadcast("news", payload),
|
||||
_ => s.reply(Status::Ok, TP(format!("echo:{}", payload.0))),
|
||||
}
|
||||
}
|
||||
fn terminate(&mut self) {
|
||||
self.term.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn counter_factory(term: &Arc<AtomicBool>, reject_rejoin: bool) -> impl ChannelFactory<TP> {
|
||||
let term = term.clone();
|
||||
move |_t: &str| -> Box<dyn Channel<TP>> {
|
||||
Box::new(Counter { joins: 0, term: term.clone(), reject_rejoin })
|
||||
}
|
||||
}
|
||||
|
||||
/// Session config: keyed by topic alone, defaults otherwise.
|
||||
struct Cfg;
|
||||
impl ChannelSession<TP> for Cfg {
|
||||
type Key = String;
|
||||
fn session_key(topic: &str, _p: &TP) -> String {
|
||||
topic.to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiny TTL (50ms) for the expiry test.
|
||||
struct CfgTtl;
|
||||
impl ChannelSession<TP> for CfgTtl {
|
||||
type Key = String;
|
||||
fn session_key(topic: &str, _p: &TP) -> String {
|
||||
topic.to_owned()
|
||||
}
|
||||
fn ttl() -> Duration {
|
||||
Duration::from_millis(50)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap of 2 (and a long TTL so only the cap can fire).
|
||||
struct CfgCap;
|
||||
impl ChannelSession<TP> for CfgCap {
|
||||
type Key = String;
|
||||
fn session_key(topic: &str, _p: &TP) -> String {
|
||||
topic.to_owned()
|
||||
}
|
||||
fn buffer_cap() -> usize {
|
||||
2
|
||||
}
|
||||
fn ttl() -> Duration {
|
||||
Duration::from_secs(30)
|
||||
}
|
||||
}
|
||||
|
||||
fn session_hub<S: ChannelSession<TP, Key = String>>(
|
||||
term: &Arc<AtomicBool>,
|
||||
reject_rejoin: bool,
|
||||
) -> ChannelHub<TP> {
|
||||
ChannelHub::new(
|
||||
PrefixRouter::new()
|
||||
.channel_session::<S>("room:*", counter_factory(term, reject_rejoin)),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_buffers_across_reconnect_and_drains_in_order() {
|
||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||
smarm::run(move || {
|
||||
let hub = session_hub::<Cfg>(&term, false);
|
||||
let mut h1 = Harness::new(&hub);
|
||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h1.recv());
|
||||
|
||||
// Transport dies. Whether the actor has observed the detach
|
||||
// yet or not, both paths (closed inbound arm, failed ws
|
||||
// send) land the broadcasts in the buffer.
|
||||
drop(h1);
|
||||
hub.broadcast("room:a", "news", TP("one".into()));
|
||||
hub.broadcast("room:a", "news", TP("two".into()));
|
||||
|
||||
let mut h2 = Harness::new(&hub);
|
||||
h2.send("j2|r2|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h2.recv()); // warm rejoin ack
|
||||
out2.lock().unwrap().push(h2.recv()); // drained, in order
|
||||
out2.lock().unwrap().push(h2.recv());
|
||||
});
|
||||
let out = out.lock().unwrap();
|
||||
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
|
||||
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:2");
|
||||
assert_eq!(out[2], "j2|-|room:a|event|news|one");
|
||||
assert_eq!(out[3], "j2|-|room:a|event|news|two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_ttl_expiry_tears_down_then_cold_start() {
|
||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||
let term2 = term.clone();
|
||||
smarm::run(move || {
|
||||
let hub = session_hub::<CfgTtl>(&term2, false);
|
||||
let mut h1 = Harness::new(&hub);
|
||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h1.recv());
|
||||
drop(h1);
|
||||
assert!(eventually(|| term2.load(Ordering::SeqCst)), "ttl never fired");
|
||||
|
||||
let mut h2 = Harness::new(&hub);
|
||||
h2.send("j2|r2|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h2.recv());
|
||||
});
|
||||
let out = out.lock().unwrap();
|
||||
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
|
||||
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:1"); // cold
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_buffer_cap_tears_down_then_cold_start() {
|
||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||
smarm::run(move || {
|
||||
let hub = session_hub::<CfgCap>(&term, false);
|
||||
let mut h1 = Harness::new(&hub);
|
||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h1.recv());
|
||||
drop(h1);
|
||||
// Cap is 2: the second buffered broadcast tears it down
|
||||
// (ttl is 30s — only the cap can fire here).
|
||||
hub.broadcast("room:a", "news", TP("one".into()));
|
||||
hub.broadcast("room:a", "news", TP("two".into()));
|
||||
assert!(eventually(|| term.load(Ordering::SeqCst)), "cap never fired");
|
||||
|
||||
let mut h2 = Harness::new(&hub);
|
||||
h2.send("j2|r2|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h2.recv());
|
||||
});
|
||||
let out = out.lock().unwrap();
|
||||
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
|
||||
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:1"); // cold
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leave_ends_the_session_not_just_the_attachment() {
|
||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||
smarm::run(move || {
|
||||
let hub = session_hub::<Cfg>(&term, false);
|
||||
let mut h = Harness::new(&hub);
|
||||
h.send("j1|r1|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h.recv());
|
||||
h.send("j1|r2|room:a|event|phx_leave|");
|
||||
out2.lock().unwrap().push(h.recv()); // ok reply
|
||||
out2.lock().unwrap().push(h.recv()); // phx_close
|
||||
assert!(eventually(|| term.load(Ordering::SeqCst)), "leave never terminated");
|
||||
|
||||
h.send("j2|r3|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h.recv());
|
||||
});
|
||||
let out = out.lock().unwrap();
|
||||
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
|
||||
assert_eq!(out[1], "j1|r2|room:a|reply|ok|");
|
||||
assert_eq!(out[2], "j1|-|room:a|event|phx_close|");
|
||||
assert_eq!(out[3], "j2|r3|room:a|reply|ok|joined:1"); // cold
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_transport_evicts_first() {
|
||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||
smarm::run(move || {
|
||||
let hub = session_hub::<Cfg>(&term, false);
|
||||
let mut h1 = Harness::new(&hub);
|
||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h1.recv());
|
||||
|
||||
let mut h2 = Harness::new(&hub);
|
||||
h2.send("j2|r2|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h2.recv()); // warm handover ack
|
||||
out2.lock().unwrap().push(h1.recv()); // eviction phx_close
|
||||
|
||||
hub.broadcast("room:a", "news", TP("x".into()));
|
||||
out2.lock().unwrap().push(h2.recv()); // only h2 is attached
|
||||
h1.assert_silence();
|
||||
|
||||
// h1's conn-side entry is stale: its inbound receiver was
|
||||
// dropped on eviction, so the next event self-prunes with
|
||||
// an error reply.
|
||||
h1.send("j1|r3|room:a|event|ping|x");
|
||||
out2.lock().unwrap().push(h1.recv());
|
||||
});
|
||||
let out = out.lock().unwrap();
|
||||
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
|
||||
assert_eq!(out[1], "j2|r2|room:a|reply|ok|joined:2");
|
||||
assert_eq!(out[2], "j1|-|room:a|event|phx_close|");
|
||||
assert_eq!(out[3], "j2|-|room:a|event|news|x");
|
||||
assert_eq!(out[4], "j1|r3|room:a|reply|error|");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_rejoin_ends_the_session() {
|
||||
let out = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let (out2, term) = (out.clone(), Arc::new(AtomicBool::new(false)));
|
||||
smarm::run(move || {
|
||||
let hub = session_hub::<Cfg>(&term, true);
|
||||
let mut h1 = Harness::new(&hub);
|
||||
h1.send("j1|r1|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h1.recv());
|
||||
drop(h1);
|
||||
|
||||
let mut h2 = Harness::new(&hub);
|
||||
h2.send("j2|r2|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h2.recv()); // rejoin rejected
|
||||
assert!(eventually(|| term.load(Ordering::SeqCst)), "reject never terminated");
|
||||
|
||||
let mut h3 = Harness::new(&hub);
|
||||
h3.send("j3|r3|room:a|event|phx_join|u");
|
||||
out2.lock().unwrap().push(h3.recv()); // cold start
|
||||
});
|
||||
let out = out.lock().unwrap();
|
||||
assert_eq!(out[0], "j1|r1|room:a|reply|ok|joined:1");
|
||||
assert_eq!(out[1], "j2|r2|room:a|reply|error|not again");
|
||||
assert_eq!(out[2], "j3|r3|room:a|reply|ok|joined:1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Shared test scaffolding for the channels module tree: the toy
|
||||
//! pipe-delimited codec (`TP`) and the conn-side `Harness` that fakes a
|
||||
//! websocket transport. `cfg(test)` only.
|
||||
|
||||
use super::*;
|
||||
use crate::ws::duplex::Outbound;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Toy payload + codec: `jref|ref|topic|kind|event-or-status|payload`
|
||||
/// with `-` for absent refs. Enough to drive the machinery.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct TP(pub(crate) String);
|
||||
|
||||
pub(crate) fn part(o: Option<&str>) -> &str {
|
||||
o.unwrap_or("-")
|
||||
}
|
||||
|
||||
impl Encode for TP {
|
||||
fn encode(f: FrameRef<'_, Self>) -> Message {
|
||||
let s = match f.message {
|
||||
MessageRef::Event { event, payload } => format!(
|
||||
"{}|{}|{}|event|{}|{}",
|
||||
part(f.join_ref),
|
||||
part(f.reference),
|
||||
f.topic,
|
||||
event,
|
||||
payload.map(|p| p.0.as_str()).unwrap_or("")
|
||||
),
|
||||
MessageRef::Reply { status, payload } => format!(
|
||||
"{}|{}|{}|reply|{}|{}",
|
||||
part(f.join_ref),
|
||||
part(f.reference),
|
||||
f.topic,
|
||||
match status {
|
||||
Status::Ok => "ok",
|
||||
Status::Error => "error",
|
||||
},
|
||||
payload.map(|p| p.0.as_str()).unwrap_or("")
|
||||
),
|
||||
};
|
||||
Message::Text(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Decode for TP {
|
||||
fn decode(msg: &Message) -> Result<ChannelFrame<Self>, DecodeError> {
|
||||
let Message::Text(s) = msg else {
|
||||
return Err(DecodeError("binary".into()));
|
||||
};
|
||||
let p: Vec<&str> = s.splitn(6, '|').collect();
|
||||
if p.len() != 6 || p[3] != "event" {
|
||||
return Err(DecodeError(format!("bad frame: {s}")));
|
||||
}
|
||||
let opt = |x: &str| (x != "-").then(|| x.to_string());
|
||||
Ok(ChannelFrame {
|
||||
join_ref: opt(p[0]),
|
||||
reference: opt(p[1]),
|
||||
topic: p[2].to_string(),
|
||||
message: ChannelMessage::Event { event: p[4].to_string(), payload: TP(p[5].into()) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A fake socket: the conn-side handler plus the raw Outbound channel
|
||||
/// a real duplex loop would drain. `recv_text` pulls the next encoded
|
||||
/// frame the "client" would see.
|
||||
pub(crate) struct Harness {
|
||||
handler: SocketHandler<TP>,
|
||||
sender: WsSender,
|
||||
out_rx: smarm::Receiver<Outbound>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
pub(crate) fn new(hub: &ChannelHub<TP>) -> Self {
|
||||
let (tx, out_rx) = smarm::channel();
|
||||
Self {
|
||||
handler: SocketHandler {
|
||||
bus: hub.bus.clone(),
|
||||
router: hub.router.clone(),
|
||||
joined: HashMap::new(),
|
||||
},
|
||||
sender: WsSender { tx },
|
||||
out_rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed one wire line in, as the duplex loop would.
|
||||
pub(crate) fn send(&mut self, line: &str) {
|
||||
self.handler
|
||||
.on_message(Message::Text(line.to_string()), &self.sender.clone());
|
||||
}
|
||||
|
||||
/// Assert nothing arrives for this client within a short window —
|
||||
/// for "the broadcast must NOT reach the evicted transport" cases.
|
||||
pub(crate) fn assert_silence(&self) {
|
||||
if let Ok(Outbound::Msg(Message::Text(s))) =
|
||||
self.out_rx.recv_timeout(Duration::from_millis(100))
|
||||
{
|
||||
panic!("expected silence, got frame: {s}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Next encoded frame the client would see (2s budget — channel
|
||||
/// actors answer asynchronously).
|
||||
pub(crate) fn recv(&self) -> String {
|
||||
match self.out_rx.recv_timeout(Duration::from_secs(2)) {
|
||||
Ok(Outbound::Msg(Message::Text(s))) => s,
|
||||
other => panic!("expected outbound text frame, got {:?}", other.is_ok()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn eventually(mut f: impl FnMut() -> bool) -> bool {
|
||||
for _ in 0..200 {
|
||||
if f() {
|
||||
return true;
|
||||
}
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
}
|
||||
false
|
||||
}
|
||||
+117
-4
@@ -146,21 +146,73 @@ impl Body {
|
||||
// RespBody — what plugs put on the wire.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Enum, not a trait, so the connection actor can pattern-match. Leaves room
|
||||
// for future variants like Sse, Stream, Chunked without touching the plug
|
||||
// API.
|
||||
// Enum, not a trait, so the connection actor can pattern-match.
|
||||
//
|
||||
// `Stream` is the pull-shaped streaming body (v0.3 design decision): the
|
||||
// handler hands back a `smarm::Receiver<Vec<u8>>` — typically the read end
|
||||
// of a channel whose `Sender` lives in a producer actor the handler
|
||||
// spawned — and the connection actor pumps it onto the wire. The conn
|
||||
// actor keeps sole ownership of the socket and of write deadlines; the
|
||||
// producer never touches the fd. End-of-stream is signalled by dropping
|
||||
// every `Sender` (the conn actor then emits the terminating 0-chunk).
|
||||
// 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(Debug, Clone)]
|
||||
pub enum RespBody {
|
||||
Empty,
|
||||
Bytes(Vec<u8>),
|
||||
Stream(StreamBody),
|
||||
}
|
||||
|
||||
/// A streaming response body. Construct with [`StreamBody::new`] (or
|
||||
/// `RespBody::from(rx)`) and hand it to [`Conn::put_body`].
|
||||
///
|
||||
/// On HTTP/1.1 the connection actor sends it with
|
||||
/// `Transfer-Encoding: chunked` and the connection stays reusable after
|
||||
/// the stream completes. On HTTP/1.0 (no chunked framing) the bytes are
|
||||
/// written raw and the connection closes at end of stream to delimit the
|
||||
/// body.
|
||||
pub struct StreamBody {
|
||||
pub rx: smarm::Receiver<Vec<u8>>,
|
||||
/// If set, the connection actor's pump waits for each chunk with
|
||||
/// `recv_timeout(interval)` instead of a bare `recv`, and on expiry
|
||||
/// writes `payload` as its own chunk and keeps waiting. This is how
|
||||
/// SSE keep-alive comments work: liveness comes from the ping (a dead
|
||||
/// client is detected when a heartbeat write stalls past
|
||||
/// `write_timeout`), not from any request clock.
|
||||
pub heartbeat: Option<(std::time::Duration, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl StreamBody {
|
||||
pub fn new(rx: smarm::Receiver<Vec<u8>>) -> Self {
|
||||
Self { rx, heartbeat: None }
|
||||
}
|
||||
|
||||
pub fn with_heartbeat(mut self, interval: std::time::Duration, payload: Vec<u8>) -> Self {
|
||||
self.heartbeat = Some((interval, payload));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RespBody {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
RespBody::Empty => f.write_str("Empty"),
|
||||
RespBody::Bytes(b) => f.debug_tuple("Bytes").field(&b.len()).finish(),
|
||||
RespBody::Stream(_) => f.write_str("Stream(..)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RespBody {
|
||||
/// Body length for fixed bodies; 0 for `Stream` (a stream has no
|
||||
/// length up front — that is the point — and is never serialised with
|
||||
/// a `content-length`).
|
||||
pub fn len_hint(&self) -> usize {
|
||||
match self {
|
||||
RespBody::Empty => 0,
|
||||
RespBody::Bytes(b) => b.len(),
|
||||
RespBody::Stream(_) => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -256,6 +308,11 @@ pub struct Conn {
|
||||
pub params: Params,
|
||||
pub assigns: Assigns,
|
||||
pub halted: bool,
|
||||
|
||||
/// Set by [`Conn::upgrade`] on an accepted WebSocket handshake; the
|
||||
/// connection actor sees it (with status 101) and leaves the HTTP
|
||||
/// loop after writing the response. `None` for plain HTTP.
|
||||
pub upgrade: Option<crate::ws::WsUpgrade>,
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
@@ -277,6 +334,8 @@ impl Conn {
|
||||
params: Params::new(),
|
||||
assigns: Assigns::new(),
|
||||
halted: false,
|
||||
|
||||
upgrade: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +365,52 @@ impl Conn {
|
||||
self.halted = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Accept a WebSocket upgrade on this request (RFC 6455 §4.2),
|
||||
/// handing `handler` to the duplex loop that takes the socket over.
|
||||
///
|
||||
/// On a valid handshake: status `101`, the `upgrade`/`connection`/
|
||||
/// `sec-websocket-accept` response headers, the [`WsUpgrade`] payload
|
||||
/// the connection actor acts on, and the pipeline is halted. After
|
||||
/// the 101 the connection actor leaves HTTP and drives `handler` —
|
||||
/// see [`WsHandler`](crate::ws::WsHandler) for the callback contract.
|
||||
///
|
||||
/// On an invalid handshake: `handler` is dropped untouched and the
|
||||
/// appropriate rejection goes out (`400`, or `426` +
|
||||
/// `sec-websocket-version: 13` for a version mismatch), empty body,
|
||||
/// pipeline halted; the connection stays plain HTTP with normal
|
||||
/// keep-alive semantics. Pre-check with
|
||||
/// [`ws::handshake::is_upgrade_request`](crate::ws::handshake::is_upgrade_request)
|
||||
/// to branch before committing.
|
||||
///
|
||||
/// [`WsUpgrade`]: crate::ws::WsUpgrade
|
||||
pub fn upgrade(mut self, handler: impl crate::ws::WsHandler) -> Self {
|
||||
use crate::ws::{handshake, Rejection};
|
||||
match handshake::validate(&self) {
|
||||
Ok(accept) => {
|
||||
self.upgrade = Some(crate::ws::WsUpgrade {
|
||||
handler: Box::new(handler),
|
||||
});
|
||||
self.put_status(101)
|
||||
.put_header("upgrade", "websocket")
|
||||
.put_header("connection", "Upgrade")
|
||||
.put_header("sec-websocket-accept", accept)
|
||||
.put_body(RespBody::Empty)
|
||||
.halt()
|
||||
}
|
||||
Err(rej) => {
|
||||
let c = self
|
||||
.put_status(rej.status())
|
||||
.put_body(RespBody::Empty)
|
||||
.halt();
|
||||
if rej == Rejection::UnsupportedVersion {
|
||||
c.put_header("sec-websocket-version", "13")
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Conn {
|
||||
@@ -326,3 +431,11 @@ impl From<&'static str> for RespBody {
|
||||
impl From<&[u8]> for RespBody {
|
||||
fn from(s: &[u8]) -> Self { RespBody::Bytes(s.to_vec()) }
|
||||
}
|
||||
impl From<StreamBody> for RespBody {
|
||||
fn from(s: StreamBody) -> Self { RespBody::Stream(s) }
|
||||
}
|
||||
impl From<smarm::Receiver<Vec<u8>>> for RespBody {
|
||||
fn from(rx: smarm::Receiver<Vec<u8>>) -> Self {
|
||||
RespBody::Stream(StreamBody::new(rx))
|
||||
}
|
||||
}
|
||||
|
||||
+487
-35
@@ -13,14 +13,17 @@
|
||||
//! `wait_readable` between bytes and `wait_writable` during slow writes;
|
||||
//! during those parks, other connection actors progress freely.
|
||||
|
||||
use crate::conn::{Body, Conn, RespBody};
|
||||
use crate::conn::{Body, Conn, HttpVersion, RespBody, StreamBody};
|
||||
use crate::conn_registry::{Cast, ConnRegistry, DeregisterGuard};
|
||||
use crate::net::OwnedFd;
|
||||
use crate::parser::{self, ParseError};
|
||||
use crate::plug::Pipeline;
|
||||
|
||||
use smarm::ServerRef;
|
||||
|
||||
use std::io::{self, ErrorKind};
|
||||
use std::os::fd::RawFd;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Limits
|
||||
@@ -38,7 +41,31 @@ pub struct ConnLimits {
|
||||
/// Hard cap on Content-Length we'll accept. 16 MiB is enough for a CRUD
|
||||
/// example; configurable in `Config`.
|
||||
pub max_body_bytes: usize,
|
||||
/// Idle budget between requests: how long we'll park waiting for the
|
||||
/// FIRST byte of a request (including the first request on a fresh
|
||||
/// connection). Expiry closes the connection silently — nothing is
|
||||
/// owed to a client that isn't talking.
|
||||
pub keep_alive_timeout: Duration,
|
||||
/// Per-request wall-clock budget, measured from the first byte of a
|
||||
/// request until the request (head + body) is fully read. Expiry
|
||||
/// mid-head gets a best-effort 408; expiry mid-body just closes.
|
||||
/// Pipeline run time is NOT covered — that's the handler's business.
|
||||
/// Covers the READ phase only; the write phase has its own
|
||||
/// per-write budget (`write_timeout`) so a streaming response can
|
||||
/// legitimately outlive any whole-request clock.
|
||||
pub request_timeout: Duration,
|
||||
/// Per-write budget for response bytes: every `write_all` (the fixed
|
||||
/// head+body, and each streamed chunk) must complete within this.
|
||||
/// A client that stops reading mid-response is dropped when its
|
||||
/// socket buffer fills and a write stalls past the budget.
|
||||
pub write_timeout: Duration,
|
||||
/// WebSocket: hard cap on a single frame's payload, enforced from
|
||||
/// the frame header BEFORE the payload is buffered. Violation closes
|
||||
/// with 1009.
|
||||
pub max_frame_payload: usize,
|
||||
/// WebSocket: hard cap on a complete (reassembled) message; spans
|
||||
/// fragments. Violation closes with 1009.
|
||||
pub max_message_bytes: usize,
|
||||
}
|
||||
|
||||
impl Default for ConnLimits {
|
||||
@@ -49,6 +76,10 @@ impl Default for ConnLimits {
|
||||
max_head_bytes: 64 * 1024,
|
||||
max_body_bytes: 16 * 1024 * 1024,
|
||||
keep_alive_timeout: Duration::from_secs(60),
|
||||
request_timeout: Duration::from_secs(30),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
max_frame_payload: 1024 * 1024,
|
||||
max_message_bytes: 4 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,34 +88,69 @@ impl Default for ConnLimits {
|
||||
// run_connection — entry point spawned by the listener actor.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
pub fn run_connection(
|
||||
fd: OwnedFd,
|
||||
pipeline: Pipeline,
|
||||
limits: ConnLimits,
|
||||
registry: ServerRef<ConnRegistry>,
|
||||
) {
|
||||
// The OwnedFd cleans up via Drop on any exit path (panic, error, or
|
||||
// normal close). No explicit close calls below.
|
||||
let raw = fd.as_raw();
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(limits.initial_read_buf);
|
||||
|
||||
// Self-register (initially idle: no request head parsed yet) and arm
|
||||
// the deregistration guard. Both casts come from this actor, so
|
||||
// Started always precedes Ended in the registry's inbox — see
|
||||
// conn_registry module docs for why the listener must not do this.
|
||||
let me = smarm::self_pid();
|
||||
let _ = registry.cast(Cast::ConnStarted(me));
|
||||
let _guard = DeregisterGuard::new(registry.clone(), me, Cast::ConnEnded);
|
||||
|
||||
loop {
|
||||
// ----- 1. Read until we have a full request head. -----
|
||||
let parsed = match read_head(raw, &mut buf, &limits) {
|
||||
// We are idle until a head parses: stoppable by a draining
|
||||
// registry while parked here.
|
||||
let (parsed, request_deadline) = match read_head(raw, &mut buf, &limits) {
|
||||
Ok(p) => p,
|
||||
Err(ReadHeadErr::ClientClosed) => {
|
||||
// Clean EOF between requests (or before any request). Normal.
|
||||
return;
|
||||
}
|
||||
Err(ReadHeadErr::IdleTimeout) => {
|
||||
// keep_alive_timeout expired waiting for the first byte of
|
||||
// a request. Nothing is owed; close silently.
|
||||
return;
|
||||
}
|
||||
Err(ReadHeadErr::RequestTimeout) => {
|
||||
// request_timeout expired mid-head (slowloris and friends).
|
||||
// Best-effort 408 WITHOUT parking on writability — a client
|
||||
// that stalls reads must not defeat the timeout by making
|
||||
// the 408 write park forever.
|
||||
try_write_once(raw, b"HTTP/1.1 408 Request Timeout\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
Err(ReadHeadErr::Io(_)) => {
|
||||
// Network error or timeout. Best-effort close; we're done.
|
||||
// Network error. Best-effort close; we're done.
|
||||
return;
|
||||
}
|
||||
Err(ReadHeadErr::Parse(e)) => {
|
||||
emit_error_response(raw, &e);
|
||||
emit_error_response(raw, &e, Instant::now() + limits.write_timeout);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = registry.cast(Cast::ConnBusy(me));
|
||||
|
||||
// ----- 2. Read body. -----
|
||||
// Content-Length pre-check only applies to fixed bodies; a chunked
|
||||
// body is bounded incrementally by the decoder.
|
||||
let body_len = parsed.content_length.unwrap_or(0);
|
||||
if body_len > limits.max_body_bytes {
|
||||
let _ = write_all(raw, b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n");
|
||||
let _ = write_all(
|
||||
raw,
|
||||
b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
Instant::now() + limits.write_timeout,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,14 +158,46 @@ pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
// 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").is_err() {
|
||||
if write_all(raw, b"HTTP/1.1 100 Continue\r\n\r\n", Instant::now() + limits.write_timeout).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let body = match read_body(raw, &mut buf, parsed.head_len, body_len) {
|
||||
Ok(b) => b,
|
||||
// `consumed_past_head`: how many RAW bytes of `buf` past the head
|
||||
// this request's body occupied — for chunked bodies that is framing
|
||||
// included, NOT the decoded length. The keep-alive drain at the
|
||||
// bottom of the loop must drop exactly this much to land on the
|
||||
// next pipelined request.
|
||||
let (body, consumed_past_head) = if parsed.chunked {
|
||||
match read_chunked_body(raw, &mut buf, parsed.head_len, &limits, request_deadline) {
|
||||
Ok(ok) => ok,
|
||||
Err(ChunkedBodyErr::TooLarge) => {
|
||||
let _ = write_all(
|
||||
raw,
|
||||
b"HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
Instant::now() + limits.write_timeout,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(ChunkedBodyErr::Malformed) => {
|
||||
let _ = write_all(
|
||||
raw,
|
||||
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
Instant::now() + limits.write_timeout,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Timeout mid-body (and any other io error) -> just close.
|
||||
Err(ChunkedBodyErr::Io(_)) => return,
|
||||
}
|
||||
} else {
|
||||
match read_body(raw, &mut buf, parsed.head_len, body_len, request_deadline) {
|
||||
Ok(b) => (b, body_len),
|
||||
// Timeout mid-body (and any other body io error) -> just
|
||||
// close; there's no point talking HTTP to a client this far
|
||||
// gone.
|
||||
Err(_) => return,
|
||||
}
|
||||
};
|
||||
|
||||
let keep_alive = parsed.keep_alive;
|
||||
@@ -117,6 +215,16 @@ pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
let mut response_conn = match result {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
// Distinguish a genuine handler panic from smarm's stop
|
||||
// sentinel, which is also a panic payload and which this
|
||||
// catch_unwind would otherwise swallow — turning a
|
||||
// graceful stop into a 500-and-keep-running. The stop
|
||||
// flag is persistent (not consumed by raising the
|
||||
// sentinel), so if we were stopped this re-raises it
|
||||
// here, outside the catch, and we unwind properly (the
|
||||
// fd and registry guards clean up).
|
||||
smarm::preempt::check_cancelled();
|
||||
|
||||
// Compose a 500 manually; the original Conn was moved into
|
||||
// the closure.
|
||||
let mut c = Conn::new();
|
||||
@@ -132,20 +240,71 @@ pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
.put_body(RespBody::Empty);
|
||||
}
|
||||
|
||||
// ----- 4. Write the response. -----
|
||||
let bytes = parser::serialise_response(&response_conn, keep_alive);
|
||||
if write_all(raw, &bytes).is_err() {
|
||||
// ----- 3.5 WebSocket upgrade. -----
|
||||
// An accepted handshake (payload + 101) ends HTTP on this socket:
|
||||
// write the 101 head, hand the fd to the duplex loop with the
|
||||
// boxed handler and any bytes already read past this request (a
|
||||
// client may pipeline its first frame behind the handshake — those
|
||||
// bytes are ws bytes now). The registry entry stays Busy for the
|
||||
// whole ws lifetime: an open WebSocket is in-flight work, not
|
||||
// reapable idle HTTP; graceful shutdown force-stops it out of the
|
||||
// select park at the drain deadline. The status check is
|
||||
// defensive: a post-handler plug that clobbered the 101 forfeits
|
||||
// the upgrade and falls through to plain HTTP.
|
||||
if response_conn.upgrade.is_some() && response_conn.status == Some(101) {
|
||||
let head = parser::serialise_response(&response_conn, true);
|
||||
if write_all(raw, &head, Instant::now() + limits.write_timeout).is_err() {
|
||||
return;
|
||||
}
|
||||
let upgrade = response_conn.upgrade.take().expect("checked above");
|
||||
buf.drain(..head_len + consumed_past_head);
|
||||
crate::ws::duplex::run_duplex(raw, buf, upgrade.handler, &limits);
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- 4. Write the response. -----
|
||||
// A Stream body on HTTP/1.0 has no chunked framing: the body is
|
||||
// delimited by EOF, so keep-alive is forced off for this response
|
||||
// (and `connection: close` goes on the wire). Error statuses on
|
||||
// 1.0 also force close: we don't advertise keep-alive on a
|
||||
// 4xx/5xx to a protocol generation where reuse is opt-in.
|
||||
let is_stream = matches!(response_conn.resp_body, RespBody::Stream(_));
|
||||
let is_error = response_conn.status.unwrap_or(200) >= 400;
|
||||
let keep_alive = keep_alive
|
||||
&& !(version == HttpVersion::Http10 && (is_stream || is_error));
|
||||
|
||||
let head_bytes = parser::serialise_response(&response_conn, keep_alive);
|
||||
if write_all(raw, &head_bytes, Instant::now() + limits.write_timeout).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
if let RespBody::Stream(stream) = response_conn.resp_body {
|
||||
let chunked = version == HttpVersion::Http11;
|
||||
if pump_stream(raw, stream, chunked, limits.write_timeout).is_err() {
|
||||
return;
|
||||
}
|
||||
if !chunked {
|
||||
// EOF delimits the HTTP/1.0 stream body.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 5. Loop or close. -----
|
||||
if !keep_alive {
|
||||
return;
|
||||
}
|
||||
|
||||
// Drop the request bytes (head + body) from `buf`; anything past
|
||||
// them is the start of the next pipelined request.
|
||||
let consumed = head_len + body_len;
|
||||
// Response is on the wire; nothing is owed. Going idle here makes
|
||||
// us stoppable by a draining registry while we park for the next
|
||||
// keep-alive request. (If the next request is already pipelined in
|
||||
// `buf`, the very next read_head parses it without parking and we
|
||||
// go Busy again — a draining registry's request_stop may still
|
||||
// catch us, which is acceptable: drain means no new work.)
|
||||
let _ = registry.cast(Cast::ConnIdle(me));
|
||||
|
||||
// Drop the request bytes (head + raw body framing) from `buf`;
|
||||
// anything past them is the start of the next pipelined request.
|
||||
let consumed = head_len + consumed_past_head;
|
||||
buf.drain(..consumed);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +316,12 @@ pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
#[allow(dead_code)] // io::Error is captured for future logging
|
||||
enum ReadHeadErr {
|
||||
ClientClosed,
|
||||
/// keep_alive_timeout expired while waiting for the first byte of a
|
||||
/// request. Close silently.
|
||||
IdleTimeout,
|
||||
/// request_timeout expired after the request had started arriving.
|
||||
/// Best-effort 408.
|
||||
RequestTimeout,
|
||||
Io(io::Error),
|
||||
Parse(ParseError),
|
||||
}
|
||||
@@ -164,17 +329,41 @@ enum ReadHeadErr {
|
||||
/// Read until `parse_head` succeeds or fails definitively. `buf` may already
|
||||
/// contain leftover bytes from a previous keep-alive cycle; we try to parse
|
||||
/// those before reading more from the socket.
|
||||
///
|
||||
/// Two wall-clock budgets govern the waits (each wait uses whichever budget
|
||||
/// is currently active):
|
||||
///
|
||||
/// - while `buf` is empty and nothing has arrived, we are *idle* and the
|
||||
/// wait is bounded by `keep_alive_timeout`;
|
||||
/// - the instant the request has started (first byte read, or pipelined
|
||||
/// bytes already in `buf` at entry), the *request* clock starts: an
|
||||
/// `Instant` deadline of `request_timeout` from that moment, which also
|
||||
/// covers body reads — it is returned alongside the parsed head so the
|
||||
/// caller can thread it into `read_body`.
|
||||
fn read_head(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
limits: &ConnLimits,
|
||||
) -> Result<parser::ParsedHead, ReadHeadErr> {
|
||||
) -> Result<(parser::ParsedHead, Instant), ReadHeadErr> {
|
||||
let entry = Instant::now();
|
||||
let idle_deadline = entry + limits.keep_alive_timeout;
|
||||
// Pipelined leftovers count as a started request.
|
||||
let mut request_deadline: Option<Instant> = if buf.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(entry + limits.request_timeout)
|
||||
};
|
||||
|
||||
loop {
|
||||
// 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) {
|
||||
Ok(h) => return Ok(h),
|
||||
Ok(h) => {
|
||||
let deadline = request_deadline
|
||||
.unwrap_or_else(|| Instant::now() + limits.request_timeout);
|
||||
return Ok((h, deadline));
|
||||
}
|
||||
Err(ParseError::Incomplete) => {} // need more bytes
|
||||
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
||||
}
|
||||
@@ -184,10 +373,24 @@ fn read_head(
|
||||
return Err(ReadHeadErr::Parse(ParseError::TooManyHeaders));
|
||||
}
|
||||
|
||||
// Read more.
|
||||
match read_some(fd, buf, limits.initial_read_buf) {
|
||||
// Read more, bounded by whichever budget is active.
|
||||
let deadline = request_deadline.unwrap_or(idle_deadline);
|
||||
match read_some(fd, buf, limits.initial_read_buf, deadline) {
|
||||
Ok(0) => return Err(ReadHeadErr::ClientClosed),
|
||||
Ok(_) => continue,
|
||||
Ok(_) => {
|
||||
if request_deadline.is_none() {
|
||||
// First byte(s) of this request: the request clock
|
||||
// starts now.
|
||||
request_deadline = Some(Instant::now() + limits.request_timeout);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::TimedOut => {
|
||||
return Err(if request_deadline.is_some() {
|
||||
ReadHeadErr::RequestTimeout
|
||||
} else {
|
||||
ReadHeadErr::IdleTimeout
|
||||
});
|
||||
}
|
||||
Err(e) => return Err(ReadHeadErr::Io(e)),
|
||||
}
|
||||
}
|
||||
@@ -202,6 +405,7 @@ fn read_body(
|
||||
buf: &mut Vec<u8>,
|
||||
head_len: usize,
|
||||
body_len: usize,
|
||||
deadline: Instant,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
// Bytes already in `buf` past the head belong to the body.
|
||||
let already = buf.len().saturating_sub(head_len);
|
||||
@@ -213,10 +417,11 @@ fn read_body(
|
||||
return Ok(buf[head_len..head_len + body_len].to_vec());
|
||||
}
|
||||
|
||||
// Read until we have the rest.
|
||||
// Read until we have the rest, on the same request budget that the
|
||||
// head was read under.
|
||||
let mut total_read = already;
|
||||
while total_read < body_len {
|
||||
match read_some(fd, buf, 8 * 1024) {
|
||||
match read_some(fd, buf, 8 * 1024, deadline) {
|
||||
Ok(0) => return Err(io::Error::new(ErrorKind::UnexpectedEof, "client closed during body")),
|
||||
Ok(n) => total_read += n,
|
||||
Err(e) => return Err(e),
|
||||
@@ -226,18 +431,160 @@ fn read_body(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_some — single epoll-park + read loop.
|
||||
// read_chunked_body — incremental chunked transfer-decoding (request side).
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Appends what it reads onto `buf`. Returns bytes read, 0 for EOF, or the
|
||||
// last io error.
|
||||
// Decodes `Transfer-Encoding: chunked` from `buf[head_len..]`, reading more
|
||||
// from the socket as needed on the SAME request deadline the head was read
|
||||
// under. Returns (decoded_body, raw_bytes_consumed_past_head) — the raw
|
||||
// count includes all framing and the trailer section, so the caller's
|
||||
// keep-alive drain lands exactly on the next pipelined request.
|
||||
//
|
||||
// Bounds: the DECODED size is capped at max_body_bytes (-> TooLarge/413);
|
||||
// a single size line (incl. chunk extensions, which are ignored) is capped
|
||||
// at MAX_SIZE_LINE and the trailer section at MAX_TRAILER_BYTES (->
|
||||
// Malformed/400) so framing spam can't grow `buf` unboundedly. Trailers
|
||||
// are consumed and discarded — nothing in the pipeline wants them yet.
|
||||
|
||||
fn read_some(fd: RawFd, buf: &mut Vec<u8>, chunk: usize) -> io::Result<usize> {
|
||||
const MAX_SIZE_LINE: usize = 128;
|
||||
const MAX_TRAILER_BYTES: usize = 8 * 1024;
|
||||
|
||||
#[allow(dead_code)] // io::Error is captured for future logging
|
||||
enum ChunkedBodyErr {
|
||||
Io(io::Error),
|
||||
Malformed,
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
fn read_chunked_body(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
head_len: usize,
|
||||
limits: &ConnLimits,
|
||||
deadline: Instant,
|
||||
) -> Result<(Vec<u8>, usize), ChunkedBodyErr> {
|
||||
// Ensure `buf` holds at least `until` bytes, reading on the request
|
||||
// deadline. Io(TimedOut) on expiry, UnexpectedEof on early close.
|
||||
fn fill_to(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
until: usize,
|
||||
deadline: Instant,
|
||||
) -> Result<(), ChunkedBodyErr> {
|
||||
while buf.len() < until {
|
||||
match read_some(fd, buf, 8 * 1024, deadline) {
|
||||
Ok(0) => {
|
||||
return Err(ChunkedBodyErr::Io(io::Error::new(
|
||||
ErrorKind::UnexpectedEof,
|
||||
"client closed during chunked body",
|
||||
)))
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => return Err(ChunkedBodyErr::Io(e)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Find "\r\n" in buf[from..], reading more as needed; the line may be
|
||||
// at most `max_line` bytes (terminator excluded). Returns the index of
|
||||
// the '\r'.
|
||||
fn find_crlf(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
from: usize,
|
||||
max_line: usize,
|
||||
deadline: Instant,
|
||||
) -> Result<usize, ChunkedBodyErr> {
|
||||
let mut scan = from;
|
||||
loop {
|
||||
while scan + 1 < buf.len() {
|
||||
if buf[scan] == b'\r' && buf[scan + 1] == b'\n' {
|
||||
return Ok(scan);
|
||||
}
|
||||
scan += 1;
|
||||
if scan - from > max_line {
|
||||
return Err(ChunkedBodyErr::Malformed);
|
||||
}
|
||||
}
|
||||
fill_to(fd, buf, buf.len() + 1, deadline)?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut pos = head_len;
|
||||
let mut decoded: Vec<u8> = Vec::new();
|
||||
|
||||
loop {
|
||||
// ----- size line: HEX[;extensions]\r\n -----
|
||||
let line_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE, deadline)?;
|
||||
let line = &buf[pos..line_end];
|
||||
let size_str = match line.iter().position(|&b| b == b';') {
|
||||
Some(i) => &line[..i], // chunk extensions: ignored
|
||||
None => line,
|
||||
};
|
||||
let size_str = std::str::from_utf8(size_str)
|
||||
.map_err(|_| ChunkedBodyErr::Malformed)?
|
||||
.trim();
|
||||
let size = usize::from_str_radix(size_str, 16)
|
||||
.map_err(|_| ChunkedBodyErr::Malformed)?;
|
||||
pos = line_end + 2;
|
||||
|
||||
if size == 0 {
|
||||
// ----- trailer section: zero or more header lines, then CRLF -----
|
||||
let trailer_start = pos;
|
||||
loop {
|
||||
let t_end = find_crlf(fd, buf, pos, MAX_SIZE_LINE.max(1024), deadline)?;
|
||||
let empty = t_end == pos;
|
||||
pos = t_end + 2;
|
||||
if empty {
|
||||
return Ok((decoded, pos - head_len));
|
||||
}
|
||||
if pos - trailer_start > MAX_TRAILER_BYTES {
|
||||
return Err(ChunkedBodyErr::Malformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if decoded.len() + size > limits.max_body_bytes {
|
||||
return Err(ChunkedBodyErr::TooLarge);
|
||||
}
|
||||
|
||||
// ----- chunk payload + trailing CRLF -----
|
||||
fill_to(fd, buf, pos + size + 2, deadline)?;
|
||||
decoded.extend_from_slice(&buf[pos..pos + size]);
|
||||
if &buf[pos + size..pos + size + 2] != b"\r\n" {
|
||||
return Err(ChunkedBodyErr::Malformed);
|
||||
}
|
||||
pos += size + 2;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// read_some — single epoll-park + read loop, bounded by a deadline.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Appends what it reads onto `buf`. Returns bytes read, 0 for EOF,
|
||||
// `ErrorKind::TimedOut` when `deadline` passes before the fd turns
|
||||
// readable, or the last io error.
|
||||
|
||||
pub(crate) fn read_some(
|
||||
fd: RawFd,
|
||||
buf: &mut Vec<u8>,
|
||||
chunk: usize,
|
||||
deadline: Instant,
|
||||
) -> io::Result<usize> {
|
||||
// Loop to absorb EAGAIN: a readable wakeup followed by EAGAIN is
|
||||
// possible (signal race, etc). Re-park and retry rather than returning
|
||||
// 0 (which would be confused with EOF by callers).
|
||||
// 0 (which would be confused with EOF by callers). The deadline is an
|
||||
// Instant, so spurious wakes don't reset the budget.
|
||||
loop {
|
||||
smarm::wait_readable(fd)?;
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(io::Error::new(ErrorKind::TimedOut, "read deadline elapsed"));
|
||||
}
|
||||
if !smarm::wait_readable_timeout(fd, remaining)? {
|
||||
return Err(io::Error::new(ErrorKind::TimedOut, "read deadline elapsed"));
|
||||
}
|
||||
|
||||
let start = buf.len();
|
||||
buf.resize(start + chunk, 0);
|
||||
@@ -262,13 +609,40 @@ fn read_some(fd: RawFd, buf: &mut Vec<u8>, chunk: usize) -> io::Result<usize> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// write_all — robust write loop.
|
||||
// try_write_once — single non-parking write attempt, result ignored.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// For best-effort farewells (the 408) to clients we've decided to drop:
|
||||
// one non-blocking write syscall, no wait_writable park. A client that
|
||||
// stalls its read side must not be able to keep this actor alive past its
|
||||
// own timeout. The socket send buffer almost always has room for a
|
||||
// one-liner, so in practice the 408 lands.
|
||||
|
||||
fn write_all(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
|
||||
fn try_write_once(fd: RawFd, buf: &[u8]) {
|
||||
unsafe {
|
||||
let _ = libc::write(fd, buf.as_ptr() as *const _, buf.len());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// write_all — robust write loop, bounded by a deadline.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Mirrors read_some: each writability park is bounded by the remaining
|
||||
// budget. `ErrorKind::TimedOut` when the deadline passes before the bytes
|
||||
// are down — a client that stops reading must not pin this actor in
|
||||
// wait_writable forever (the write-side twin of slowloris).
|
||||
|
||||
pub(crate) fn write_all(fd: RawFd, mut buf: &[u8], deadline: Instant) -> io::Result<()> {
|
||||
while !buf.is_empty() {
|
||||
// Park on writability before each syscall.
|
||||
smarm::wait_writable(fd)?;
|
||||
// Park on writability before each syscall, bounded by the budget.
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Err(io::Error::new(ErrorKind::TimedOut, "write deadline elapsed"));
|
||||
}
|
||||
if !smarm::wait_writable_timeout(fd, remaining)? {
|
||||
return Err(io::Error::new(ErrorKind::TimedOut, "write deadline elapsed"));
|
||||
}
|
||||
|
||||
let n = unsafe {
|
||||
libc::write(fd, buf.as_ptr() as *const _, buf.len())
|
||||
@@ -288,11 +662,89 @@ fn write_all(fd: RawFd, mut buf: &[u8]) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// pump_stream — drive a RespBody::Stream onto the wire.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The pull side of the v0.3 streaming design: the handler's producer actor
|
||||
// owns the Sender; this conn actor owns the socket and every write
|
||||
// deadline. We park in `recv()` between chunks — that park is stoppable
|
||||
// (a draining registry's `request_stop` unwinds us out of `park_current`
|
||||
// via the stop sentinel; fd + registry guards clean up), so an infinite
|
||||
// stream is force-stoppable at the drain deadline like any other in-flight
|
||||
// request. End of stream is the channel closing: every Sender dropped.
|
||||
//
|
||||
// Each chunk gets a FRESH write_timeout budget — a stream is expected to
|
||||
// outlive any whole-response clock; what is not tolerated is a single
|
||||
// write stalling. On write failure we return Err: the conn loop drops the
|
||||
// Receiver, and the producer's next `send` observes the closed channel and
|
||||
// should exit (that is the documented producer contract).
|
||||
//
|
||||
// `chunked` selects HTTP/1.1 chunked framing (hex-length CRLF payload
|
||||
// CRLF, terminated by a 0-chunk) vs HTTP/1.0 raw writes (EOF-delimited;
|
||||
// caller closes). Empty chunks are skipped — a zero-length chunk would
|
||||
// terminate the framing early.
|
||||
|
||||
fn pump_stream(
|
||||
fd: RawFd,
|
||||
stream: StreamBody,
|
||||
chunked: bool,
|
||||
write_timeout: Duration,
|
||||
) -> io::Result<()> {
|
||||
let write_chunk = |payload: &[u8]| -> io::Result<()> {
|
||||
let deadline = Instant::now() + write_timeout;
|
||||
if chunked {
|
||||
let mut framed = Vec::with_capacity(payload.len() + 20);
|
||||
framed.extend_from_slice(format!("{:x}\r\n", payload.len()).as_bytes());
|
||||
framed.extend_from_slice(payload);
|
||||
framed.extend_from_slice(b"\r\n");
|
||||
write_all(fd, &framed, deadline)
|
||||
} else {
|
||||
write_all(fd, payload, deadline)
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
// With a heartbeat configured (SSE), the wait between chunks is the
|
||||
// heartbeat interval; expiry emits the ping and keeps waiting. A
|
||||
// ping write that stalls past write_timeout errors out below —
|
||||
// that is the dead-client detector. Without one, plain recv():
|
||||
// stoppable by the draining registry either way.
|
||||
let msg = match &stream.heartbeat {
|
||||
Some((interval, payload)) => match stream.rx.recv_timeout(*interval) {
|
||||
Ok(chunk) => Some(chunk),
|
||||
Err(smarm::RecvTimeoutError::Timeout) => {
|
||||
write_chunk(payload)?;
|
||||
continue;
|
||||
}
|
||||
Err(smarm::RecvTimeoutError::Disconnected) => None,
|
||||
},
|
||||
None => stream.rx.recv().ok(),
|
||||
};
|
||||
|
||||
match msg {
|
||||
Some(chunk) => {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
write_chunk(&chunk)?;
|
||||
}
|
||||
None => {
|
||||
// All senders dropped: end of stream.
|
||||
if chunked {
|
||||
write_all(fd, b"0\r\n\r\n", Instant::now() + write_timeout)?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error responses for unparseable / malformed requests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn emit_error_response(fd: RawFd, err: &ParseError) {
|
||||
fn emit_error_response(fd: RawFd, err: &ParseError, deadline: Instant) {
|
||||
let resp: &[u8] = match err {
|
||||
ParseError::TooManyHeaders =>
|
||||
b"HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
@@ -305,5 +757,5 @@ fn emit_error_response(fd: RawFd, err: &ParseError) {
|
||||
_ =>
|
||||
b"HTTP/1.1 400 Bad Request\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
||||
};
|
||||
let _ = write_all(fd, resp);
|
||||
let _ = write_all(fd, resp, deadline);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Connection registry — the shutdown coordinator (v0.2 chunk 2).
|
||||
//!
|
||||
//! A `gen_server` tracking live connection pids and their busy/idle
|
||||
//! state. Connection actors self-register as their first action and
|
||||
//! self-deregister via a drop guard (so panic unwinds deregister too);
|
||||
//! both casts come from the same sender, so Started always precedes
|
||||
//! Ended in the inbox. (The roadmap sketched the *listener* casting
|
||||
//! `{Started, pid}`, but then a short-lived conn's Ended could overtake
|
||||
//! its Started and leak a dead pid into the set forever. Self-
|
||||
//! registration makes the order a per-sender FIFO guarantee instead of
|
||||
//! a race.)
|
||||
//!
|
||||
//! Listeners are deliberately NOT tracked here: they shut down via a
|
||||
//! shared flag + timed accept-waits (see `serve`), never via
|
||||
//! `request_stop` — stopping pids that announce themselves is racy (the
|
||||
//! spawn-to-registration gap), and smarm's `request_stop` is lossy
|
||||
//! against an actor that is QUEUED and then parks without passing an
|
||||
//! observation point (see the v0.2 shutdown notes in the commit
|
||||
//! message). Connections don't suffer this in practice: every stop the
|
||||
//! registry issues targets a pid that just sent us a cast (so it is
|
||||
//! running or parked, both covered), and the force-stop path re-sweeps
|
||||
//! until the set empties.
|
||||
//!
|
||||
//! Drain protocol: `BeginDrain` stops every idle connection immediately
|
||||
//! and flips the registry into draining mode, in which any connection
|
||||
//! that *becomes* idle (finishes its in-flight request) is stopped on the
|
||||
//! spot. Busy connections are left to finish; `ForceStopConns` (sent by
|
||||
//! `serve` at the drain deadline) stops whatever remains. `request_stop`
|
||||
//! unwinds a conn actor parked in `wait_readable` safely (smarm's 06-10
|
||||
//! io fix) and `OwnedFd::drop` closes its socket on the way out.
|
||||
//!
|
||||
//! This server is also the planned introspection point for ws/channels
|
||||
//! (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 std::collections::HashMap;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum Cast {
|
||||
/// A connection actor started (self-registered, initially idle: it has
|
||||
/// not parsed a request head yet).
|
||||
ConnStarted(Pid),
|
||||
/// Parsed a request head; a response is now owed.
|
||||
ConnBusy(Pid),
|
||||
/// Response written; parked (or about to park) waiting for the next
|
||||
/// keep-alive request.
|
||||
ConnIdle(Pid),
|
||||
ConnEnded(Pid),
|
||||
/// Stop idle conns now and stop each remaining conn as it goes idle.
|
||||
BeginDrain,
|
||||
/// Drain deadline passed: stop every remaining conn.
|
||||
ForceStopConns,
|
||||
}
|
||||
|
||||
pub enum Call {
|
||||
ConnCount,
|
||||
}
|
||||
|
||||
pub enum Reply {
|
||||
ConnCount(usize),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ConnState {
|
||||
Busy,
|
||||
Idle,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ConnRegistry {
|
||||
conns: HashMap<Pid, ConnState>,
|
||||
draining: bool,
|
||||
}
|
||||
|
||||
impl GenServer for ConnRegistry {
|
||||
type Call = Call;
|
||||
type Reply = Reply;
|
||||
type Cast = Cast;
|
||||
type Info = ();
|
||||
|
||||
fn handle_call(&mut self, request: Call) -> Reply {
|
||||
match request {
|
||||
Call::ConnCount => Reply::ConnCount(self.conns.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, request: Cast) {
|
||||
match request {
|
||||
Cast::ConnStarted(pid) => {
|
||||
self.conns.insert(pid, ConnState::Idle);
|
||||
if self.draining {
|
||||
// Listener-stop race: this conn was accepted just
|
||||
// before its listener died. Drain means no new work.
|
||||
smarm::request_stop(pid);
|
||||
}
|
||||
}
|
||||
Cast::ConnBusy(pid) => {
|
||||
if let Some(s) = self.conns.get_mut(&pid) {
|
||||
*s = ConnState::Busy;
|
||||
}
|
||||
}
|
||||
Cast::ConnIdle(pid) => {
|
||||
if let Some(s) = self.conns.get_mut(&pid) {
|
||||
*s = ConnState::Idle;
|
||||
if self.draining {
|
||||
// Finished its in-flight request; nothing more is
|
||||
// owed. The pid leaves the map via its drop
|
||||
// guard's ConnEnded once the unwind completes.
|
||||
smarm::request_stop(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Cast::ConnEnded(pid) => { self.conns.remove(&pid); }
|
||||
Cast::BeginDrain => {
|
||||
self.draining = true;
|
||||
for (pid, state) in &self.conns {
|
||||
if *state == ConnState::Idle {
|
||||
smarm::request_stop(*pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Cast::ForceStopConns => {
|
||||
for pid in self.conns.keys() {
|
||||
smarm::request_stop(*pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start() -> ServerRef<ConnRegistry> {
|
||||
ServerBuilder::new(ConnRegistry::default()).start()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Drop guard — self-deregistration on any exit path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Casts `make(pid)` on drop. Runs on normal return, `request_stop`
|
||||
/// 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>,
|
||||
pid: Pid,
|
||||
make: fn(Pid) -> Cast,
|
||||
}
|
||||
|
||||
impl DeregisterGuard {
|
||||
pub fn new(registry: ServerRef<ConnRegistry>, pid: Pid, make: fn(Pid) -> Cast) -> Self {
|
||||
Self { registry, pid, make }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DeregisterGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.registry.cast((self.make)(self.pid));
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -24,10 +24,23 @@ pub mod router;
|
||||
pub mod parser;
|
||||
pub mod net;
|
||||
pub mod conn_actor;
|
||||
pub mod conn_registry;
|
||||
pub mod serve;
|
||||
pub mod sse;
|
||||
pub mod ws;
|
||||
pub mod pubsub;
|
||||
#[cfg(feature = "channels")]
|
||||
pub mod channels;
|
||||
|
||||
// Re-exports — what most users want at the crate root.
|
||||
pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody};
|
||||
pub use conn::{Assigns, Body, Conn, HeaderMap, HttpVersion, Method, Params, RespBody, StreamBody};
|
||||
pub use plug::{Next, Pipeline, Plug};
|
||||
pub use router::Router;
|
||||
pub use serve::{serve, serve_with, Config};
|
||||
pub use sse::{EventSender, SseClosed};
|
||||
pub use pubsub::{PubSub, PubSubDown};
|
||||
#[cfg(feature = "channels")]
|
||||
pub use channels::{Channel, ChannelHub, ChannelSession, ChannelSocket, PrefixRouter, Status, TopicRouter};
|
||||
pub use ws::{Message, WsClosed, WsHandler, WsSender};
|
||||
pub use serve::{
|
||||
serve, serve_with, serve_with_shutdown, shutdown_handle, Config, Handle, ShutdownSignal,
|
||||
};
|
||||
|
||||
@@ -52,6 +52,13 @@ impl Drop for OwnedFd {
|
||||
// becomes the unique closer.
|
||||
unsafe impl Send for OwnedFd {}
|
||||
|
||||
// SAFETY: shared references only expose `as_raw(&self)` — reading an
|
||||
// integer. No interior mutability; `into_raw` takes `self` by value and is
|
||||
// unreachable through a shared reference. Needed so a supervisor
|
||||
// `ChildSpec` factory (an `Fn` closure, hence shared across restarts) can
|
||||
// own its listener fd via `Arc<OwnedFd>`.
|
||||
unsafe impl Sync for OwnedFd {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bind_and_listen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+127
-15
@@ -4,14 +4,13 @@
|
||||
//! copy, battle-tested). Body framing, keep-alive logic and response writing
|
||||
//! are ours.
|
||||
//!
|
||||
//! v1 body framing:
|
||||
//! Body framing:
|
||||
//! - `Content-Length: N` — read exactly N bytes.
|
||||
//! - No body header — empty body.
|
||||
//! - `Transfer-Encoding: chunked` — deferred. Returns ParseError::Unsupported
|
||||
//! and the connection actor responds 411 Length Required + close.
|
||||
//!
|
||||
//! Keep it stupid simple. Chunked decoding lands when something actually
|
||||
//! requests it.
|
||||
//! - `Transfer-Encoding: chunked` (HTTP/1.1) — flagged in `ParsedHead`;
|
||||
//! the connection actor decodes incrementally (`read_chunked_body`).
|
||||
//! Chunked + Content-Length together, or chunked on HTTP/1.0, is
|
||||
//! Malformed (request-smuggling ambiguity; RFC 7230 §3.3.3).
|
||||
|
||||
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
|
||||
|
||||
@@ -30,8 +29,9 @@ pub enum ParseError {
|
||||
TooManyHeaders,
|
||||
/// `Content-Length` header could not be parsed as an integer.
|
||||
BadContentLength,
|
||||
/// A wire feature we haven't implemented yet (e.g. chunked encoding).
|
||||
/// Connection actor responds 411 + close.
|
||||
/// A wire feature we haven't implemented. Currently unconstructed
|
||||
/// (chunked decoding landed in v0.3); kept for future unsupported
|
||||
/// framings. Connection actor responds 411 + close.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ pub struct ParsedHead {
|
||||
pub version: HttpVersion,
|
||||
pub headers: HeaderMap,
|
||||
pub content_length: Option<usize>,
|
||||
/// `Transfer-Encoding: chunked` — the body is chunked-framed and the
|
||||
/// connection actor decodes it (`read_chunked_body`). Mutually
|
||||
/// exclusive with `content_length` (rejected as Malformed).
|
||||
pub chunked: bool,
|
||||
pub keep_alive: bool,
|
||||
pub expect_100: bool,
|
||||
}
|
||||
@@ -130,7 +134,13 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
}
|
||||
|
||||
if chunked {
|
||||
return Err(ParseError::Unsupported);
|
||||
// Transfer-Encoding is an HTTP/1.1 mechanism; a 1.0 request
|
||||
// carrying it is malformed. And a request carrying BOTH a
|
||||
// Content-Length and TE: chunked is the classic request-smuggling
|
||||
// ambiguity — RFC 7230 §3.3.3 lets a server reject it, and we do.
|
||||
if version == HttpVersion::Http10 || content_length.is_some() {
|
||||
return Err(ParseError::Malformed);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep-alive logic, RFC 7230 §6.3:
|
||||
@@ -149,6 +159,7 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
||||
version,
|
||||
headers,
|
||||
content_length,
|
||||
chunked,
|
||||
keep_alive,
|
||||
expect_100,
|
||||
})
|
||||
@@ -188,6 +199,7 @@ pub fn build_conn(head: ParsedHead, body: Body) -> Conn {
|
||||
pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
||||
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
|
||||
let body_len = conn.resp_body.len_hint();
|
||||
let is_stream = matches!(conn.resp_body, RespBody::Stream(_));
|
||||
let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len);
|
||||
|
||||
let status = conn.status.unwrap_or(200);
|
||||
@@ -202,11 +214,17 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
||||
out.extend_from_slice(b"\r\n");
|
||||
|
||||
// User headers — written first so subsequent injection can skip them.
|
||||
// For Stream bodies WE own the framing: a user `content-length` or
|
||||
// `transfer-encoding` is dropped rather than emitted (the combination
|
||||
// of content-length + chunked is a smuggling vector, and a stream has
|
||||
// no length to promise anyway).
|
||||
let mut wrote_content_length = false;
|
||||
let mut wrote_connection = false;
|
||||
|
||||
for (name, value) in conn.resp_headers.iter() {
|
||||
match name {
|
||||
"content-length" if is_stream => continue,
|
||||
"transfer-encoding" if is_stream => continue,
|
||||
"content-length" => wrote_content_length = true,
|
||||
"connection" => wrote_connection = true,
|
||||
_ => {}
|
||||
@@ -217,22 +235,44 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
|
||||
if !wrote_content_length {
|
||||
if is_stream {
|
||||
// HTTP/1.1: chunked framing, connection reusable afterwards.
|
||||
// HTTP/1.0: no chunked TE exists; the body is raw bytes delimited
|
||||
// by EOF — the caller passes keep_alive = false and we emit
|
||||
// `connection: close` below.
|
||||
if conn.version == HttpVersion::Http11 {
|
||||
out.extend_from_slice(b"transfer-encoding: chunked\r\n");
|
||||
}
|
||||
} else if !wrote_content_length && !(100..200).contains(&status) {
|
||||
// 1xx responses have no body by definition (RFC 7230 §3.3.2) —
|
||||
// injecting `content-length: 0` on the 101 upgrade response is a
|
||||
// protocol violation some clients reject.
|
||||
out.extend_from_slice(b"content-length: ");
|
||||
out.extend_from_slice(body_len.to_string().as_bytes());
|
||||
out.extend_from_slice(b"\r\n");
|
||||
}
|
||||
|
||||
if !wrote_connection && !keep_alive {
|
||||
if !wrote_connection {
|
||||
if !keep_alive {
|
||||
out.extend_from_slice(b"connection: close\r\n");
|
||||
} else if conn.version == HttpVersion::Http10 {
|
||||
// HTTP/1.0 defaults to close: a connection we intend to keep
|
||||
// open MUST be advertised back, or a spec-following client
|
||||
// waits for an EOF that never comes (`ab -k` deadlocked on
|
||||
// exactly this). 1.1 keep-alive is the default and stays
|
||||
// implicit.
|
||||
out.extend_from_slice(b"connection: keep-alive\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
out.extend_from_slice(b"\r\n");
|
||||
|
||||
// Body.
|
||||
// Fixed bodies are written inline with the head; a Stream body is
|
||||
// pumped by the connection actor after this head goes on the wire.
|
||||
match &conn.resp_body {
|
||||
RespBody::Empty => {}
|
||||
RespBody::Bytes(b) => out.extend_from_slice(b),
|
||||
RespBody::Stream(_) => {}
|
||||
}
|
||||
|
||||
out
|
||||
@@ -340,11 +380,28 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chunked_unsupported() {
|
||||
fn parse_chunked_is_flagged() {
|
||||
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
|
||||
let head = parse_head(req, 64).unwrap();
|
||||
assert!(head.chunked);
|
||||
assert_eq!(head.content_length, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chunked_plus_content_length_is_malformed() {
|
||||
let req = b"POST /a HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::Unsupported) => {}
|
||||
_ => panic!("expected Unsupported for chunked"),
|
||||
Err(ParseError::Malformed) => {}
|
||||
_ => panic!("expected Malformed for CL + chunked"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_chunked_on_http10_is_malformed() {
|
||||
let req = b"POST /a HTTP/1.0\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n";
|
||||
match parse_head(req, 64) {
|
||||
Err(ParseError::Malformed) => {}
|
||||
_ => panic!("expected Malformed for chunked on 1.0"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +423,61 @@ mod tests {
|
||||
assert!(s.contains("connection: close"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_stream_http11_is_chunked_no_content_length() {
|
||||
let (_tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
let conn = Conn::new().put_status(200).put_body(RespBody::from(rx));
|
||||
let bytes = serialise_response(&conn, true);
|
||||
let s = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(s.contains("transfer-encoding: chunked"));
|
||||
assert!(!s.contains("content-length"));
|
||||
assert!(s.ends_with("\r\n\r\n")); // head only, no body bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_stream_strips_user_framing_headers() {
|
||||
let (_tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
let conn = Conn::new().put_status(200)
|
||||
.put_header("content-length", "999")
|
||||
.put_header("transfer-encoding", "gzip")
|
||||
.put_body(RespBody::from(rx));
|
||||
let bytes = serialise_response(&conn, true);
|
||||
let s = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(!s.contains("content-length"));
|
||||
assert!(!s.contains("gzip"));
|
||||
assert!(s.contains("transfer-encoding: chunked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_stream_http10_no_te_and_closes() {
|
||||
let (_tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
let mut conn = Conn::new().put_status(200).put_body(RespBody::from(rx));
|
||||
conn.version = HttpVersion::Http10;
|
||||
// The conn actor forces keep_alive=false for a 1.0 stream.
|
||||
let bytes = serialise_response(&conn, false);
|
||||
let s = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(!s.contains("transfer-encoding"));
|
||||
assert!(!s.contains("content-length"));
|
||||
assert!(s.contains("connection: close"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_http10_keepalive_is_echoed() {
|
||||
let mut conn = Conn::new().put_status(200).put_body("hi");
|
||||
conn.version = HttpVersion::Http10;
|
||||
let bytes = serialise_response(&conn, true);
|
||||
let s = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(s.contains("connection: keep-alive"), "head: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_http11_keepalive_stays_implicit() {
|
||||
let conn = Conn::new().put_status(200).put_body("hi");
|
||||
let bytes = serialise_response(&conn, true);
|
||||
let s = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(!s.contains("connection:"), "head: {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialise_user_content_length_is_respected() {
|
||||
let conn = Conn::new().put_status(200)
|
||||
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
//! `urus::pubsub` — local-node topic pub/sub, phoenix_pubsub-shaped.
|
||||
//!
|
||||
//! Deliberately independent of HTTP: nothing here imports the rest of
|
||||
//! urus, only smarm. Usable from any smarm app; candidate for crate
|
||||
//! extraction later.
|
||||
//!
|
||||
//! # Shape (v0.5 design, user-ratified)
|
||||
//!
|
||||
//! - **Generic per instance** (`PubSub<M>`): zero-cost dispatch, no
|
||||
//! downcasts. One instance per message domain; heterogeneous events on
|
||||
//! one bus are an app-side `enum M`.
|
||||
//! - **Subscribe returns a `Receiver<Arc<M>>`**: the subscriber owns its
|
||||
//! receive loop and composes with the v0.4 ws outbound pattern (a relay
|
||||
//! actor pumping the receiver into a `WsSender` clone). One fresh
|
||||
//! receiver per subscription; fan-in (`subscribe_with(topic, sender)`)
|
||||
//! is a compatible later addition if wanted.
|
||||
//! - **Unbounded mailboxes**: `broadcast` never blocks the topic table —
|
||||
//! a bounded-block policy would head-of-line-block *every* topic behind
|
||||
//! one slow subscriber, and bounded-drop silently loses messages.
|
||||
//! Memory risk is a live-but-not-receiving subscriber, bounded by two
|
||||
//! cleanup paths: monitor `Down` on subscriber death, and pruning on
|
||||
//! send failure when a receiver was dropped. Bench before sharding.
|
||||
//! - **User-owned handle**: construct a `PubSub<M>`, clone it around
|
||||
//! (handler closures already thread state this way). No global named
|
||||
//! instance — see the module note on `register` below.
|
||||
//! - **One subscription per `(pid, topic)`**: subscribe is idempotent;
|
||||
//! re-subscribing replaces the sender (the old receiver's channel
|
||||
//! closes). Kills the silent double-delivery bug class.
|
||||
//!
|
||||
//! # Cleanup mechanics
|
||||
//!
|
||||
//! The table monitors each subscriber pid **once** (first subscribe) via
|
||||
//! the gen_server [`Watcher`]; the `Down` removes the pid from every
|
||||
//! topic — a full table scan, deliberately: deaths are rare and topic
|
||||
//! counts small at this stage, a pid→topics reverse index is the
|
||||
//! documented optimisation if that ever measures hot (same spirit as the
|
||||
//! roadmap's "don't pre-shard"). A pid that unsubscribes from everything
|
||||
//! but stays alive keeps its (one-shot, inert) monitor until death;
|
||||
//! that's a bounded bookkeeping entry, not a leak.
|
||||
//!
|
||||
//! # Construction must happen in-runtime
|
||||
//!
|
||||
//! [`PubSub::new`] spawns the table actor, which smarm only permits from
|
||||
//! inside `Runtime::run`. Pipelines are built *before* `serve*` boots the
|
||||
//! runtime, so the working pattern (same constraint crud's store hits) is
|
||||
//! lazy init from the first handler — but with a **non-static**
|
||||
//! `Arc<OnceLock<PubSub<M>>>` captured by the route closure, NOT a
|
||||
//! `static`: when the drained pipeline drops at graceful shutdown, the
|
||||
//! cell (and thus the last handle) drops in-runtime, the table's inbox
|
||||
//! closes, and the table exits — `serve_with_shutdown` returns. A
|
||||
//! `static` pins the table forever and blocks smarm's all-done. The same
|
||||
//! reasoning forbids long-lived consumer actors (relays, producers) from
|
||||
//! holding a `PubSub` clone: relay holds table's inbox open, table holds
|
||||
//! relay's receiver open, neither exits. Relays take the `Receiver` only.
|
||||
//! See `examples/ws_chat.rs` and the `shutdown_with_open_chat_terminates`
|
||||
//! integration test for the full chain.
|
||||
//!
|
||||
//! # 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
|
||||
//! 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.
|
||||
|
||||
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};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The topic table is gone — its gen_server actor exited (panic or runtime
|
||||
/// shutdown). Every operation on the handle fails with this from then on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PubSubDown;
|
||||
|
||||
impl fmt::Display for PubSubDown {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "pubsub topic table is down")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PubSubDown {}
|
||||
|
||||
/// A clonable handle to one pub/sub instance (one topic table actor).
|
||||
///
|
||||
/// Payloads are broadcast as `Arc<M>`: one allocation per broadcast, not
|
||||
/// per subscriber.
|
||||
pub struct PubSub<M: Send + Sync + 'static> {
|
||||
server: ServerRef<Table<M>>,
|
||||
}
|
||||
|
||||
impl<M: Send + Sync + 'static> Clone for PubSub<M> {
|
||||
fn clone(&self) -> Self {
|
||||
PubSub { server: self.server.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Send + Sync + 'static> Default for PubSub<M> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Send + Sync + 'static> PubSub<M> {
|
||||
/// Start a fresh topic table. Must run inside the smarm runtime (it
|
||||
/// spawns the table's gen_server actor). The table lives until the
|
||||
/// last handle is dropped.
|
||||
pub fn new() -> Self {
|
||||
PubSub { server: gen_server::start(Table::new()) }
|
||||
}
|
||||
|
||||
/// Subscribe the **calling actor** to `topic`. Returns the receiving
|
||||
/// end; messages arrive as `Arc<M>`. Idempotent per `(pid, topic)`:
|
||||
/// subscribing again replaces the sender, closing the previously
|
||||
/// returned receiver.
|
||||
///
|
||||
/// The subscription is cleaned up when the calling actor dies. If the
|
||||
/// receive loop runs in a *different* actor than the session that
|
||||
/// should scope the subscription, use [`subscribe_as`](Self::subscribe_as)
|
||||
/// to pin cleanup to the right pid (e.g. a ws connection actor
|
||||
/// subscribing, with a spawned relay doing the receiving).
|
||||
pub fn subscribe(&self, topic: impl Into<String>) -> Result<Receiver<Arc<M>>, PubSubDown> {
|
||||
self.subscribe_as(smarm::self_pid(), topic)
|
||||
}
|
||||
|
||||
/// [`subscribe`](Self::subscribe), but the subscription's lifetime is
|
||||
/// tied to `pid` instead of the calling actor.
|
||||
pub fn subscribe_as(
|
||||
&self,
|
||||
pid: Pid,
|
||||
topic: impl Into<String>,
|
||||
) -> Result<Receiver<Arc<M>>, PubSubDown> {
|
||||
let (tx, rx) = channel();
|
||||
// A call, not a cast: when this returns the table is updated, so
|
||||
// a broadcast issued right after by the same caller is seen.
|
||||
match self.server.call(Call::Subscribe { topic: topic.into(), pid, tx }) {
|
||||
Ok(_) => Ok(rx),
|
||||
Err(_) => Err(PubSubDown),
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the calling actor's subscription to `topic` (its receiver's
|
||||
/// channel closes). No-op if not subscribed.
|
||||
pub fn unsubscribe(&self, topic: impl Into<String>) -> Result<(), PubSubDown> {
|
||||
self.unsubscribe_as(smarm::self_pid(), topic)
|
||||
}
|
||||
|
||||
/// [`unsubscribe`](Self::unsubscribe) for an explicit pid.
|
||||
pub fn unsubscribe_as(&self, pid: Pid, topic: impl Into<String>) -> Result<(), PubSubDown> {
|
||||
self.server
|
||||
.cast(Cast::Unsubscribe { topic: topic.into(), pid })
|
||||
.map_err(|_| PubSubDown)
|
||||
}
|
||||
|
||||
/// Broadcast `msg` to every subscriber of `topic`. Never blocks on
|
||||
/// subscribers (unbounded mailboxes); returns once the table has the
|
||||
/// request queued.
|
||||
pub fn broadcast(&self, topic: impl Into<String>, msg: M) -> Result<(), PubSubDown> {
|
||||
self.cast_broadcast(topic.into(), msg, None)
|
||||
}
|
||||
|
||||
/// [`broadcast`](Self::broadcast), skipping delivery to `from` —
|
||||
/// pass `smarm::self_pid()` for the phoenix `broadcast_from` shape
|
||||
/// ("everyone in the room but me").
|
||||
pub fn broadcast_from(
|
||||
&self,
|
||||
from: Pid,
|
||||
topic: impl Into<String>,
|
||||
msg: M,
|
||||
) -> Result<(), PubSubDown> {
|
||||
self.cast_broadcast(topic.into(), msg, Some(from))
|
||||
}
|
||||
|
||||
/// Number of live subscriptions on `topic`. Counts entries in the
|
||||
/// table — a subscriber whose receiver was dropped but hasn't been
|
||||
/// pruned yet (no broadcast since, still alive) is still counted.
|
||||
pub fn subscriber_count(&self, topic: impl Into<String>) -> Result<usize, PubSubDown> {
|
||||
match self.server.call(Call::Count { topic: topic.into() }) {
|
||||
Ok(Reply::Count(n)) => Ok(n),
|
||||
Ok(Reply::Subscribed) => unreachable!("Count call answered with Subscribed"),
|
||||
Err(_) => Err(PubSubDown),
|
||||
}
|
||||
}
|
||||
|
||||
/// The topic table actor's pid — for introspection / registry use.
|
||||
pub fn pid(&self) -> Pid {
|
||||
self.server.pid()
|
||||
}
|
||||
|
||||
fn cast_broadcast(&self, topic: String, msg: M, skip: Option<Pid>) -> Result<(), PubSubDown> {
|
||||
self.server
|
||||
.cast(Cast::Broadcast { topic, msg: Arc::new(msg), skip })
|
||||
.map_err(|_| PubSubDown)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The table gen_server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
enum Call<M> {
|
||||
Subscribe { topic: String, pid: Pid, tx: Sender<Arc<M>> },
|
||||
Count { topic: String },
|
||||
}
|
||||
|
||||
enum Reply {
|
||||
Subscribed,
|
||||
Count(usize),
|
||||
}
|
||||
|
||||
enum Cast<M> {
|
||||
Unsubscribe { topic: String, pid: Pid },
|
||||
Broadcast { topic: String, msg: Arc<M>, skip: Option<Pid> },
|
||||
}
|
||||
|
||||
struct Table<M: Send + Sync + 'static> {
|
||||
topics: HashMap<String, HashMap<Pid, Sender<Arc<M>>>>,
|
||||
/// Pids we already hold a monitor for. Monitors are one-shot and
|
||||
/// created at most once per live pid (first subscribe); `handle_down`
|
||||
/// retires the entry so a reused-slot pid (fresh generation) gets a
|
||||
/// fresh monitor.
|
||||
monitored: HashSet<Pid>,
|
||||
watcher: Option<Watcher>,
|
||||
}
|
||||
|
||||
impl<M: Send + Sync + 'static> Table<M> {
|
||||
fn new() -> Self {
|
||||
Table { topics: HashMap::new(), monitored: HashSet::new(), watcher: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl<M: Send + Sync + 'static> GenServer for Table<M> {
|
||||
type Call = Call<M>;
|
||||
type Reply = Reply;
|
||||
type Cast = Cast<M>;
|
||||
type Info = ();
|
||||
|
||||
fn init(&mut self, ctx: &ServerCtx) {
|
||||
self.watcher = Some(ctx.watcher());
|
||||
}
|
||||
|
||||
fn handle_call(&mut self, request: Call<M>) -> Reply {
|
||||
match request {
|
||||
Call::Subscribe { topic, pid, tx } => {
|
||||
if self.monitored.insert(pid) {
|
||||
let m = smarm::monitor(pid);
|
||||
self.watcher
|
||||
.as_ref()
|
||||
.expect("watcher set in init")
|
||||
.watch(m);
|
||||
}
|
||||
// Insert replaces: idempotent per (pid, topic); the old
|
||||
// sender drops and the stale receiver's channel closes.
|
||||
self.topics.entry(topic).or_default().insert(pid, tx);
|
||||
Reply::Subscribed
|
||||
}
|
||||
Call::Count { topic } => {
|
||||
Reply::Count(self.topics.get(&topic).map_or(0, HashMap::len))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_cast(&mut self, request: Cast<M>) {
|
||||
match request {
|
||||
Cast::Unsubscribe { topic, pid } => {
|
||||
if let Some(subs) = self.topics.get_mut(&topic) {
|
||||
subs.remove(&pid);
|
||||
if subs.is_empty() {
|
||||
self.topics.remove(&topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
Cast::Broadcast { topic, msg, skip } => {
|
||||
let Some(subs) = self.topics.get_mut(&topic) else { return };
|
||||
// Prune-on-send-failure: a dropped receiver (subscriber
|
||||
// alive but done listening, e.g. ws conn reaped at
|
||||
// write_timeout) is removed lazily here; subscriber
|
||||
// *death* is handled eagerly by the monitor.
|
||||
subs.retain(|pid, tx| {
|
||||
if skip == Some(*pid) {
|
||||
return true;
|
||||
}
|
||||
tx.send(Arc::clone(&msg)).is_ok()
|
||||
});
|
||||
if subs.is_empty() {
|
||||
self.topics.remove(&topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_down(&mut self, down: Down) {
|
||||
self.monitored.remove(&down.pid);
|
||||
// Full scan, by design — see the module docs.
|
||||
self.topics.retain(|_, subs| {
|
||||
subs.remove(&down.pid);
|
||||
!subs.is_empty()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (runtime-backed: results collected outside, asserted after run)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Poll `f` until it returns true or ~2s elapse. For the
|
||||
/// asynchronous cleanup paths (monitor Down delivery).
|
||||
fn eventually(mut f: impl FnMut() -> bool) -> bool {
|
||||
for _ in 0..200 {
|
||||
if f() {
|
||||
return true;
|
||||
}
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_reaches_all_subscribers_once() {
|
||||
let got = Arc::new(Mutex::new(Vec::<(u32, String)>::new()));
|
||||
let got2 = got.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<String>::new();
|
||||
let mut handles = Vec::new();
|
||||
for i in 0..2u32 {
|
||||
let ps = ps.clone();
|
||||
let got = got2.clone();
|
||||
handles.push(smarm::spawn(move || {
|
||||
let rx = ps.subscribe("room:a").unwrap();
|
||||
let msg = rx.recv().unwrap();
|
||||
got.lock().unwrap().push((i, (*msg).clone()));
|
||||
}));
|
||||
}
|
||||
// Subscribes are calls from the spawned actors; wait until
|
||||
// both are in the table before broadcasting.
|
||||
assert!(eventually(|| ps.subscriber_count("room:a").unwrap() == 2));
|
||||
ps.broadcast("room:a", "hello".to_string()).unwrap();
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
let mut v = got.lock().unwrap().clone();
|
||||
v.sort();
|
||||
assert_eq!(v, vec![(0, "hello".into()), (1, "hello".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arc_payload_is_shared_not_cloned() {
|
||||
let ptrs = Arc::new(Mutex::new(Vec::<usize>::new()));
|
||||
let ptrs2 = ptrs.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<Vec<u8>>::new();
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let ps = ps.clone();
|
||||
let ptrs = ptrs2.clone();
|
||||
handles.push(smarm::spawn(move || {
|
||||
let rx = ps.subscribe("t").unwrap();
|
||||
let msg = rx.recv().unwrap();
|
||||
ptrs.lock().unwrap().push(Arc::as_ptr(&msg) as usize);
|
||||
}));
|
||||
}
|
||||
assert!(eventually(|| ps.subscriber_count("t").unwrap() == 2));
|
||||
ps.broadcast("t", vec![1, 2, 3]).unwrap();
|
||||
for h in handles {
|
||||
h.join().unwrap();
|
||||
}
|
||||
});
|
||||
let v = ptrs.lock().unwrap();
|
||||
assert_eq!(v.len(), 2);
|
||||
assert_eq!(v[0], v[1], "both subscribers should see the same allocation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broadcast_from_skips_the_sender() {
|
||||
let got = Arc::new(Mutex::new(Vec::<String>::new()));
|
||||
let got2 = got.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<String>::new();
|
||||
let ps_loud = ps.clone();
|
||||
let got = got2.clone();
|
||||
let loud = smarm::spawn(move || {
|
||||
let rx = ps_loud.subscribe("room").unwrap();
|
||||
ps_loud
|
||||
.broadcast_from(smarm::self_pid(), "room", "from loud".into())
|
||||
.unwrap();
|
||||
// Must receive the OTHER broadcast only.
|
||||
let msg = rx.recv().unwrap();
|
||||
got.lock().unwrap().push(format!("loud got {}", *msg));
|
||||
});
|
||||
let rx = ps.subscribe("room").unwrap();
|
||||
assert!(eventually(|| ps.subscriber_count("room").unwrap() == 2));
|
||||
ps.broadcast("room", "for everyone".into()).unwrap();
|
||||
let first = rx.recv().unwrap();
|
||||
let second = rx.recv().unwrap();
|
||||
got2.lock()
|
||||
.unwrap()
|
||||
.push(format!("root got {} then {}", *first, *second));
|
||||
loud.join().unwrap();
|
||||
});
|
||||
let v = got.lock().unwrap();
|
||||
assert!(v.contains(&"loud got for everyone".to_string()), "{v:?}");
|
||||
// Root (not the broadcast_from sender) receives both, in order.
|
||||
assert!(
|
||||
v.contains(&"root got from loud then for everyone".to_string())
|
||||
|| v.contains(&"root got for everyone then from loud".to_string()),
|
||||
"{v:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsubscribe_closes_the_receiver_and_stops_delivery() {
|
||||
let ok = Arc::new(Mutex::new(false));
|
||||
let ok2 = ok.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<u32>::new();
|
||||
let rx = ps.subscribe("t").unwrap();
|
||||
ps.unsubscribe("t").unwrap();
|
||||
// Unsubscribe is a cast; the table holds the only sender, so
|
||||
// once processed the channel closes and recv errs.
|
||||
assert!(eventually(|| ps.subscriber_count("t").unwrap() == 0));
|
||||
assert!(rx.recv().is_err());
|
||||
ps.broadcast("t", 7).unwrap(); // no subscribers: no-op, no panic
|
||||
*ok2.lock().unwrap() = true;
|
||||
});
|
||||
assert!(*ok.lock().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resubscribe_replaces_the_old_subscription() {
|
||||
let got = Arc::new(Mutex::new((0u32, false)));
|
||||
let got2 = got.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<u32>::new();
|
||||
let rx_old = ps.subscribe("t").unwrap();
|
||||
let rx_new = ps.subscribe("t").unwrap();
|
||||
assert_eq!(ps.subscriber_count("t").unwrap(), 1, "idempotent per (pid, topic)");
|
||||
ps.broadcast("t", 42).unwrap();
|
||||
let v = *rx_new.recv().unwrap();
|
||||
let old_closed = rx_old.recv().is_err();
|
||||
*got2.lock().unwrap() = (v, old_closed);
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), (42, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dead_subscriber_is_pruned_by_the_monitor() {
|
||||
let ok = Arc::new(Mutex::new(false));
|
||||
let ok2 = ok.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<u32>::new();
|
||||
let ps2 = ps.clone();
|
||||
let h = smarm::spawn(move || {
|
||||
let _rx = ps2.subscribe("t").unwrap();
|
||||
// Exit without ever receiving: rx drops with the stack.
|
||||
});
|
||||
h.join().unwrap();
|
||||
// No broadcast issued — this MUST be the monitor path, not
|
||||
// prune-on-send-failure.
|
||||
*ok2.lock().unwrap() = eventually(|| ps.subscriber_count("t").unwrap() == 0);
|
||||
});
|
||||
assert!(*ok.lock().unwrap(), "monitor Down never pruned the dead subscriber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_receiver_is_pruned_on_next_broadcast() {
|
||||
let counts = Arc::new(Mutex::new((0usize, 0usize)));
|
||||
let counts2 = counts.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<u32>::new();
|
||||
let rx = ps.subscribe("t").unwrap();
|
||||
drop(rx);
|
||||
let before = ps.subscriber_count("t").unwrap();
|
||||
ps.broadcast("t", 1).unwrap();
|
||||
let mut after = ps.subscriber_count("t").unwrap();
|
||||
// Casts are ordered but count is a call that can overtake
|
||||
// nothing here — same inbox, so after the broadcast cast is
|
||||
// handled. Still poll defensively.
|
||||
if after != 0 {
|
||||
after = if eventually(|| ps.subscriber_count("t").unwrap() == 0) { 0 } else { after };
|
||||
}
|
||||
*counts2.lock().unwrap() = (before, after);
|
||||
});
|
||||
assert_eq!(*counts.lock().unwrap(), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topics_are_independent() {
|
||||
let got = Arc::new(Mutex::new(Vec::<u32>::new()));
|
||||
let got2 = got.clone();
|
||||
smarm::run(move || {
|
||||
let ps = PubSub::<u32>::new();
|
||||
let rx_a = ps.subscribe("a").unwrap();
|
||||
ps.broadcast("b", 99).unwrap(); // nobody on b; must not reach a
|
||||
ps.broadcast("a", 1).unwrap();
|
||||
got2.lock().unwrap().push(*rx_a.recv().unwrap());
|
||||
});
|
||||
assert_eq!(*got.lock().unwrap(), vec![1]);
|
||||
}
|
||||
}
|
||||
+272
-29
@@ -13,12 +13,17 @@
|
||||
//! waiting on "the same fd").
|
||||
|
||||
use crate::conn_actor::{run_connection, ConnLimits};
|
||||
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 std::io::{self, ErrorKind};
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
use std::os::fd::RawFd;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -33,7 +38,21 @@ pub struct Config {
|
||||
pub max_header_count: usize,
|
||||
pub read_buf_size: usize,
|
||||
pub request_timeout: Duration,
|
||||
/// Per-write budget for response bytes (the fixed head+body write, and
|
||||
/// each streamed chunk). See `ConnLimits::write_timeout`.
|
||||
pub write_timeout: Duration,
|
||||
pub max_body_bytes: usize,
|
||||
/// How long a graceful shutdown waits for in-flight requests before
|
||||
/// force-stopping the remaining connections. Idle keep-alive
|
||||
/// connections are closed immediately on shutdown and do not run the
|
||||
/// clock out.
|
||||
pub drain_timeout: Duration,
|
||||
/// WebSocket: cap on a single frame's payload (header-checked
|
||||
/// before buffering; violation closes 1009).
|
||||
pub max_frame_payload: usize,
|
||||
/// WebSocket: cap on a complete reassembled message (spans
|
||||
/// fragments; violation closes 1009).
|
||||
pub max_message_bytes: usize,
|
||||
/// Number of smarm scheduler OS threads. `None` means smarm's default
|
||||
/// (one per CPU). Set this to a small fixed number in tests so multiple
|
||||
/// concurrent test servers don't oversubscribe the host.
|
||||
@@ -53,7 +72,11 @@ impl Config {
|
||||
max_header_count: 64,
|
||||
read_buf_size: 8 * 1024,
|
||||
request_timeout: Duration::from_secs(30),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
max_body_bytes: 16 * 1024 * 1024,
|
||||
drain_timeout: Duration::from_secs(30),
|
||||
max_frame_payload: 1024 * 1024,
|
||||
max_message_bytes: 4 * 1024 * 1024,
|
||||
scheduler_threads: None,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +88,10 @@ impl Config {
|
||||
max_head_bytes: 64 * 1024,
|
||||
max_body_bytes: self.max_body_bytes,
|
||||
keep_alive_timeout: self.keep_alive_timeout,
|
||||
request_timeout: self.request_timeout,
|
||||
write_timeout: self.write_timeout,
|
||||
max_frame_payload: self.max_frame_payload,
|
||||
max_message_bytes: self.max_message_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,9 +112,43 @@ fn dup_fd(fd: RawFd) -> io::Result<OwnedFd> {
|
||||
// listener actor body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn listener_loop(listener: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
/// Test-only fault injection. When nonzero, the next accept-loop iteration
|
||||
/// of whichever listener gets there first decrements this and panics —
|
||||
/// *before* calling `accept`, so a pending connection stays in the kernel
|
||||
/// backlog and must be picked up by the restarted listener. Cost when idle
|
||||
/// is one relaxed load per accept-loop iteration (each of which already
|
||||
/// pays a syscall). Not public API.
|
||||
#[doc(hidden)]
|
||||
pub static INJECT_LISTENER_PANICS: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn listener_loop(
|
||||
listener: Arc<OwnedFd>,
|
||||
pipeline: Pipeline,
|
||||
limits: ConnLimits,
|
||||
registry: ServerRef<ConnRegistry>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) {
|
||||
let fd = listener.as_raw();
|
||||
|
||||
loop {
|
||||
// Self-termination on shutdown — the ONLY way a listener exits at
|
||||
// shutdown, and deliberately a normal return: under
|
||||
// `Restart::Transient` a normal exit is terminal, so the
|
||||
// supervisor's active-count drains and `sup.run()` returns on its
|
||||
// own. No pid is ever `request_stop`ped, which sidesteps both the
|
||||
// spawn-to-registration race of self-announced pids and smarm's
|
||||
// lossy stop-while-QUEUED window (a flag is wake-free and
|
||||
// race-free; a freshly spawned listener observes it on its very
|
||||
// first iteration, a parked one within LISTENER_TICK).
|
||||
if shutdown.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if INJECT_LISTENER_PANICS
|
||||
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1))
|
||||
.is_ok()
|
||||
{
|
||||
panic!("urus: injected listener panic (test hook)");
|
||||
}
|
||||
match accept_nonblocking(fd) {
|
||||
Ok(client) => {
|
||||
// Hand the fd off to a new connection actor. spawn() is
|
||||
@@ -95,15 +156,27 @@ fn listener_loop(listener: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
// shared lock.
|
||||
let p = pipeline.clone();
|
||||
let l = limits;
|
||||
smarm::spawn(move || run_connection(client, p, l));
|
||||
let r = registry.clone();
|
||||
smarm::spawn(move || run_connection(client, p, l, r));
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||
// No pending connection. Park until the listener is
|
||||
// readable again, then retry.
|
||||
if let Err(we) = smarm::wait_readable(fd) {
|
||||
// epoll registration failed — fatal for this listener.
|
||||
eprintln!("urus: listener wait_readable failed: {we}");
|
||||
return;
|
||||
// readable or the tick elapses; either way we come back
|
||||
// around through the shutdown-flag check above.
|
||||
match smarm::wait_readable_timeout(fd, LISTENER_TICK) {
|
||||
Ok(_ready) => {} // ready or tick — loop re-checks, retries accept
|
||||
Err(we) => {
|
||||
// epoll registration failed. Under Transient
|
||||
// supervision a normal return is terminal but a
|
||||
// panic restarts us — and a failed wait IS
|
||||
// abnormal, so panic: a transient failure (e.g.
|
||||
// EMFILE on the epoll set) heals by restart
|
||||
// instead of silently shrinking the pool. (smarm
|
||||
// catches actor panics in the trampoline; this is
|
||||
// a Signal::Panic to the supervisor, not process
|
||||
// noise.)
|
||||
panic!("urus: listener wait_readable failed: {we}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => {
|
||||
@@ -119,62 +192,232 @@ fn listener_loop(listener: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// listener OwnedFd drops here, closing the dup'd fd.
|
||||
// The Arc clone we were started with drops here (normal exit or
|
||||
// unwind), but the ChildSpec factory holds another — the fd outlives
|
||||
// any one incarnation of this listener.
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serve_with — main entry. Boots smarm, spawns listeners, blocks.
|
||||
// Handle / ShutdownSignal — graceful shutdown plumbing.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// How often the root actor polls for a shutdown signal (see
|
||||
/// `serve_with_shutdown` for why this is a poll); bounds shutdown latency.
|
||||
const SHUTDOWN_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Listener accept-waits are timed at this tick; the shutdown flag is
|
||||
/// observed at the top of every accept-loop iteration, so this bounds how
|
||||
/// long a fully idle listener takes to notice shutdown. It is the SOLE
|
||||
/// listener-shutdown mechanism (no `request_stop` — see the shutdown
|
||||
/// sequence notes). Idle cost: one timer wake per listener per tick.
|
||||
const LISTENER_TICK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// A clonable trigger for graceful shutdown. Safe to use from any OS
|
||||
/// thread (the send only enqueues; the serving side polls), e.g. from a
|
||||
/// signal-handling thread.
|
||||
#[derive(Clone)]
|
||||
pub struct Handle {
|
||||
tx: smarm::Sender<()>,
|
||||
}
|
||||
|
||||
impl Handle {
|
||||
/// Begin graceful shutdown: stop accepting, close idle keep-alive
|
||||
/// connections, drain in-flight requests up to `Config.drain_timeout`,
|
||||
/// then force-stop stragglers. `serve_with_shutdown` returns once the
|
||||
/// runtime has wound down. Idempotent; extra calls are no-ops.
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// The receiving half consumed by [`serve_with_shutdown`].
|
||||
pub struct ShutdownSignal {
|
||||
rx: smarm::Receiver<()>,
|
||||
}
|
||||
|
||||
/// Create a connected [`Handle`]/[`ShutdownSignal`] pair.
|
||||
pub fn shutdown_handle() -> (Handle, ShutdownSignal) {
|
||||
let (tx, rx) = smarm::channel();
|
||||
(Handle { tx }, ShutdownSignal { rx })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serve_with_shutdown — main entry. Boots smarm, supervises listeners,
|
||||
// blocks until shutdown.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Boots an smarm runtime (one OS thread per CPU by default — see smarm's
|
||||
// `Config::default()`) and runs until externally killed. We don't wire a
|
||||
// graceful-shutdown signal in v1; the runtime exits when all actors exit,
|
||||
// which they don't (listener loops are infinite). Ctrl-C is your friend.
|
||||
// `Config::default()`). The root actor starts the connection registry and
|
||||
// a one-for-one supervisor over the listener pool, then blocks on the
|
||||
// shutdown signal. A panicking listener is restarted on the same
|
||||
// (still-open) fd instead of silently shrinking the accept pool.
|
||||
// Connection actors stay unsupervised bare spawns — per spec, a connection
|
||||
// is cheap and its failure is local: a 500 path, not a restart path.
|
||||
// (`spawn` from a listener parents the conn actor under that listener,
|
||||
// which never registers a supervisor channel, so conn deaths are invisible
|
||||
// to the pool supervisor by construction.)
|
||||
//
|
||||
// Shutdown sequence (drain-then-stop, the v0.2 chunk 2 decision):
|
||||
// 1. Set the shared shutdown flag. Every listener observes it at the
|
||||
// top of its accept loop (parks are timed at LISTENER_TICK) and
|
||||
// returns normally. Under `Restart::Transient` a normal exit is
|
||||
// terminal, so no restart happens and the supervisor's active count
|
||||
// drains to zero.
|
||||
// 2. Join the supervisor: `sup.run()` returns on its own once every
|
||||
// listener has exited. The join is therefore the barrier "no new
|
||||
// connections can ever be accepted" — listener fds are closed (the
|
||||
// last Arc clones drop with the supervisor's ChildSpecs), and the
|
||||
// kernel refuses new connects.
|
||||
//
|
||||
// Why a flag and not `request_stop`: stopping pids that announce
|
||||
// themselves races the spawn-to-registration gap (a fast shutdown
|
||||
// CAN beat a fresh listener to the registry), and smarm's
|
||||
// `request_stop` is lossy against a QUEUED actor that then parks
|
||||
// without passing an observation point — found the hard way; see
|
||||
// the chunk 2 commit message. The flag is wake-free and race-free.
|
||||
// 3. BeginDrain: the registry stops idle conns now and each remaining
|
||||
// conn the moment it finishes its in-flight request.
|
||||
// 4. Poll until no conns remain or the drain deadline passes; past the
|
||||
// deadline, ForceStopConns every tick until the set empties (a conn
|
||||
// accepted just before listener death may register late).
|
||||
// 5. Root returns. `rt.run` itself returns only when every actor has
|
||||
// exited (force-stopped conns unwind through their fd waits safely —
|
||||
// smarm's 06-10 io fix — and close their sockets via OwnedFd::drop).
|
||||
|
||||
pub fn serve_with(config: Config, pipeline: Pipeline) -> io::Result<()> {
|
||||
pub fn serve_with_shutdown(
|
||||
config: Config,
|
||||
pipeline: Pipeline,
|
||||
signal: ShutdownSignal,
|
||||
) -> io::Result<()> {
|
||||
let listener = bind_and_listen(config.addr)?;
|
||||
println!("urus: listening on {}", config.addr);
|
||||
|
||||
// We want one connection-actor-spawning loop per listener pool slot.
|
||||
// Each gets its own dup'd fd so epoll registrations don't collide.
|
||||
// One connection-actor-spawning loop per listener pool slot. Each gets
|
||||
// its own dup'd fd so epoll registrations don't collide. Each fd is
|
||||
// owned by its ChildSpec's factory closure (via `Arc`): a restarted
|
||||
// listener re-enters `accept`/`wait_readable` on the same fd — no
|
||||
// re-dup, no window where the slot has no fd.
|
||||
let mut listener_fds = Vec::with_capacity(config.listener_pool);
|
||||
listener_fds.push(listener); // primary keeps the original
|
||||
listener_fds.push(Arc::new(listener)); // primary keeps the original
|
||||
|
||||
for _ in 1..config.listener_pool {
|
||||
let dup = dup_fd(listener_fds[0].as_raw())?;
|
||||
listener_fds.push(dup);
|
||||
listener_fds.push(Arc::new(dup));
|
||||
}
|
||||
|
||||
let limits = config.to_conn_limits();
|
||||
let drain_timeout = config.drain_timeout;
|
||||
|
||||
// smarm's runtime API: init(Config) then run(f). The closure is the
|
||||
// root actor; from there we spawn one listener per fd in the pool.
|
||||
let smarm_cfg = match config.scheduler_threads {
|
||||
Some(n) => smarm::Config::exact(n),
|
||||
None => smarm::Config::default(),
|
||||
};
|
||||
let rt = smarm::init(smarm_cfg);
|
||||
// Listener self-termination flag — see the shutdown sequence below.
|
||||
let shutdown_flag = Arc::new(AtomicBool::new(false));
|
||||
|
||||
rt.run(move || {
|
||||
let n = listener_fds.len();
|
||||
let mut handles = Vec::with_capacity(n);
|
||||
// Registry first: listeners and conns cast into it from birth.
|
||||
let registry = conn_registry::start();
|
||||
|
||||
let mut sup = OneForOne::new().strategy(Strategy::OneForOne);
|
||||
for (i, lfd) in listener_fds.into_iter().enumerate() {
|
||||
let p = pipeline.clone();
|
||||
let h = smarm::spawn(move || {
|
||||
let r = registry.clone();
|
||||
let sf = shutdown_flag.clone();
|
||||
sup = sup.child(ChildSpec::new(Restart::Transient, move || {
|
||||
println!("urus: listener {} starting", i);
|
||||
listener_loop(lfd, p, limits);
|
||||
});
|
||||
handles.push(h);
|
||||
// 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());
|
||||
}));
|
||||
}
|
||||
// Block forever (until ctrl-C) by joining the listeners. They
|
||||
// never exit on their own in v1.
|
||||
for h in handles {
|
||||
let _ = h.join();
|
||||
// Default intensity (3 per 5s) applies; a listener crash-looping
|
||||
// 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());
|
||||
|
||||
// Block until told to shut down. We poll `try_recv` + `sleep`
|
||||
// rather than parking in `recv`: a smarm `Sender::send` from a
|
||||
// foreign OS thread (no runtime in its TLS) enqueues fine but its
|
||||
// unpark is a `try_with_runtime` no-op — a parked receiver would
|
||||
// never wake. The timer wake comes from inside the runtime, so the
|
||||
// poll sees the message within one interval. (A cross-thread-safe
|
||||
// unpark is a smarm roadmap candidate; this poll dies with it.)
|
||||
// If every Handle was dropped the channel closes and no shutdown
|
||||
// can ever arrive: serve forever, exactly v1's semantics.
|
||||
loop {
|
||||
match signal.rx.try_recv() {
|
||||
Ok(Some(())) => break,
|
||||
Ok(None) => smarm::sleep(SHUTDOWN_POLL),
|
||||
Err(_) => smarm::sleep(Duration::from_secs(3600)),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Shutdown. -----
|
||||
// 1 + 2. Flag the listeners down and join the supervisor; the
|
||||
// join returns once every listener has exited normally
|
||||
// (Transient: normal exit is terminal). After this point
|
||||
// no connection can ever be accepted again.
|
||||
shutdown_flag.store(true, Ordering::Relaxed);
|
||||
let _ = sup_h.join();
|
||||
|
||||
// 3 + 4. Drain. Same sweep discipline as listeners on the force-
|
||||
// stop path: a conn accepted just before its listener died may
|
||||
// register after the deadline, so keep force-stopping until the
|
||||
// set is empty (each pass kills everything registered; new
|
||||
// registrants are a strictly shrinking population once listeners
|
||||
// are gone).
|
||||
let _ = registry.cast(Cast::BeginDrain);
|
||||
let deadline = std::time::Instant::now() + drain_timeout;
|
||||
let mut force = false;
|
||||
loop {
|
||||
match registry.call(Call::ConnCount) {
|
||||
Ok(Reply::ConnCount(0)) => break,
|
||||
Ok(_) => {}
|
||||
Err(_) => break, // registry gone; nothing left to track
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
if force || now >= deadline {
|
||||
force = true;
|
||||
let _ = registry.cast(Cast::ForceStopConns);
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
} else {
|
||||
smarm::sleep(Duration::from_millis(50).min(deadline - now));
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Our ServerRef 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.
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serve_with / serve — convenience entries without a shutdown handle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn serve_with(config: Config, pipeline: Pipeline) -> io::Result<()> {
|
||||
// The Handle is dropped immediately: shutdown can never be signalled
|
||||
// and the server runs until externally killed (v1 semantics).
|
||||
let (_handle, signal) = shutdown_handle();
|
||||
serve_with_shutdown(config, pipeline, signal)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// serve — convenience over serve_with.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
//! Server-Sent Events sugar over `RespBody::Stream`.
|
||||
//!
|
||||
//! An SSE response is just a streaming body with the right headers and a
|
||||
//! heartbeat: `Conn::sse()` wires all of it and hands back an
|
||||
//! [`EventSender`] for a producer actor to feed.
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use urus::{Conn, Next, Pipeline, Router};
|
||||
//! use std::time::Duration;
|
||||
//!
|
||||
//! let pipeline = Pipeline::new().plug(Router::new().get(
|
||||
//! "/events",
|
||||
//! |c: Conn, _n: Next| {
|
||||
//! let (c, events) = c.sse();
|
||||
//! smarm::spawn(move || {
|
||||
//! let mut i = 0;
|
||||
//! loop {
|
||||
//! if events.send("tick", &i.to_string()).is_err() {
|
||||
//! return; // client gone; conn actor dropped the stream
|
||||
//! }
|
||||
//! i += 1;
|
||||
//! smarm::sleep(Duration::from_secs(1));
|
||||
//! }
|
||||
//! });
|
||||
//! c
|
||||
//! },
|
||||
//! ));
|
||||
//! ```
|
||||
//!
|
||||
//! Liveness: the connection actor emits a `: keep-alive` comment chunk
|
||||
//! whenever [`HEARTBEAT_INTERVAL`] passes with no event (see
|
||||
//! `StreamBody::heartbeat`). A client that went away is detected when a
|
||||
//! write — event or heartbeat — stalls past `write_timeout`; the conn
|
||||
//! actor then drops the stream and the producer's next [`EventSender`]
|
||||
//! call returns `Err(SseClosed)`. There is no request clock on an SSE
|
||||
//! response: `request_timeout` covers only the read phase, by design.
|
||||
|
||||
use crate::conn::{Conn, RespBody, StreamBody};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// Default heartbeat: one comment line per this interval of silence.
|
||||
pub const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// What the heartbeat puts on the wire — an SSE comment, invisible to
|
||||
/// `EventSource` consumers.
|
||||
pub(crate) const HEARTBEAT_PAYLOAD: &[u8] = b": keep-alive\n\n";
|
||||
|
||||
/// The stream has ended: the connection actor dropped the receiving end
|
||||
/// (client disconnect, write timeout, or server shutdown). Producers
|
||||
/// should exit when they see this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SseClosed;
|
||||
|
||||
impl std::fmt::Display for SseClosed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("SSE stream closed")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SseClosed {}
|
||||
|
||||
/// Producer handle for an SSE response. Clonable — multiple producers may
|
||||
/// feed one stream; the stream ends when the LAST clone drops.
|
||||
#[derive(Clone)]
|
||||
pub struct EventSender {
|
||||
tx: smarm::Sender<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl EventSender {
|
||||
/// Send a named event: `event: {event}` + `data:` line(s).
|
||||
pub fn send(&self, event: &str, data: &str) -> Result<(), SseClosed> {
|
||||
self.tx
|
||||
.send(format_event(Some(event), data))
|
||||
.map_err(|_| SseClosed)
|
||||
}
|
||||
|
||||
/// Send an unnamed event (`data:` line(s) only) — what `EventSource`
|
||||
/// surfaces as a plain `message`.
|
||||
pub fn data(&self, data: &str) -> Result<(), SseClosed> {
|
||||
self.tx
|
||||
.send(format_event(None, data))
|
||||
.map_err(|_| SseClosed)
|
||||
}
|
||||
|
||||
/// Send a comment line. Invisible to `EventSource`; useful for
|
||||
/// application-level pings beyond the built-in heartbeat.
|
||||
pub fn comment(&self, text: &str) -> Result<(), SseClosed> {
|
||||
let mut out = Vec::with_capacity(text.len() + 4);
|
||||
out.extend_from_slice(b": ");
|
||||
out.extend_from_slice(text.as_bytes());
|
||||
out.extend_from_slice(b"\n\n");
|
||||
self.tx.send(out).map_err(|_| SseClosed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire format for one event. Multi-line `data` becomes one `data:` line
|
||||
/// per line (the SSE framing for embedded newlines); the blank line
|
||||
/// dispatches the event.
|
||||
pub(crate) fn format_event(event: Option<&str>, data: &str) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() + 32);
|
||||
if let Some(e) = event {
|
||||
out.extend_from_slice(b"event: ");
|
||||
out.extend_from_slice(e.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
for line in data.split('\n') {
|
||||
out.extend_from_slice(b"data: ");
|
||||
out.extend_from_slice(line.as_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.push(b'\n');
|
||||
out
|
||||
}
|
||||
|
||||
impl Conn {
|
||||
/// Turn this response into a Server-Sent Events stream with the
|
||||
/// default [`HEARTBEAT_INTERVAL`]. Returns the `Conn` (return it from
|
||||
/// the handler) and the [`EventSender`] to hand to a producer actor.
|
||||
pub fn sse(self) -> (Self, EventSender) {
|
||||
self.sse_with_heartbeat(HEARTBEAT_INTERVAL)
|
||||
}
|
||||
|
||||
/// [`Conn::sse`] with a custom heartbeat interval.
|
||||
pub fn sse_with_heartbeat(mut self, interval: Duration) -> (Self, EventSender) {
|
||||
let (tx, rx) = smarm::channel::<Vec<u8>>();
|
||||
if self.status.is_none() {
|
||||
self.status = Some(200);
|
||||
}
|
||||
self.resp_headers.set("content-type", "text/event-stream");
|
||||
self.resp_headers.set("cache-control", "no-cache");
|
||||
self.resp_body = RespBody::Stream(
|
||||
StreamBody::new(rx).with_heartbeat(interval, HEARTBEAT_PAYLOAD.to_vec()),
|
||||
);
|
||||
(self, EventSender { tx })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_named_event() {
|
||||
assert_eq!(
|
||||
format_event(Some("tick"), "42"),
|
||||
b"event: tick\ndata: 42\n\n".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_unnamed_event() {
|
||||
assert_eq!(format_event(None, "hi"), b"data: hi\n\n".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_multiline_data() {
|
||||
assert_eq!(
|
||||
format_event(None, "a\nb"),
|
||||
b"data: a\ndata: b\n\n".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_sets_headers_and_stream_body() {
|
||||
let (conn, _events) = Conn::new().sse();
|
||||
assert_eq!(conn.status, Some(200));
|
||||
assert_eq!(conn.resp_headers.get("content-type"), Some("text/event-stream"));
|
||||
assert_eq!(conn.resp_headers.get("cache-control"), Some("no-cache"));
|
||||
match &conn.resp_body {
|
||||
RespBody::Stream(s) => {
|
||||
let (interval, payload) = s.heartbeat.as_ref().expect("heartbeat set");
|
||||
assert_eq!(*interval, HEARTBEAT_INTERVAL);
|
||||
assert_eq!(payload.as_slice(), HEARTBEAT_PAYLOAD);
|
||||
}
|
||||
other => panic!("expected Stream body, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! The WebSocket duplex loop — v0.4 chunk 3.
|
||||
//!
|
||||
//! After an accepted upgrade the connection actor calls [`run_duplex`],
|
||||
//! which replaces the HTTP request loop for the rest of the socket's
|
||||
//! life. ONE actor speaks both directions (the v0.4 topology decision):
|
||||
//! each iteration parks on a two-arm select —
|
||||
//!
|
||||
//! try_select(&[&outbound_rx, &FdArm::readable(fd)])
|
||||
//!
|
||||
//! — smarm's RFC 008 fd arms composed with a channel arm on one wait
|
||||
//! epoch; urus is deliberately the first consumer of that machinery.
|
||||
//! The outbound arm sits at index 0 (select is priority-ordered): owed
|
||||
//! writes drain before we read more, which is honest backpressure.
|
||||
//! Accepted gap, by design: while a large outbound frame is mid-write
|
||||
//! the actor isn't reading, so a ping arriving during that write is
|
||||
//! answered after it completes.
|
||||
//!
|
||||
//! Handler model (the v0.4 chunk-3 decisions, mirroring SSE):
|
||||
//! - [`WsHandler`] callbacks run IN the connection actor, inside the
|
||||
//! select loop. A slow `on_message` stops reads — backpressure, not a
|
||||
//! bug. Handlers needing concurrency spawn their own actor and hand it
|
||||
//! a [`WsSender`] clone, exactly like an SSE producer.
|
||||
//! - [`WsSender`] is the clonable outbound handle; every method returns
|
||||
//! `Err(WsClosed)` once the connection is gone.
|
||||
//! - Control frames are invisible to the handler in v1: pings are
|
||||
//! auto-ponged (RFC 6455 §5.5.2 MUST), pongs are absorbed, a peer
|
||||
//! close is auto-echoed (§5.5.1) and surfaces only as `on_close`.
|
||||
//! `on_ping`/`on_pong` hooks can land later as default trait methods
|
||||
//! without breaking anyone.
|
||||
//!
|
||||
//! Liveness mirrors SSE: there is no request clock on an open
|
||||
//! WebSocket. An idle connection parks in the select (stoppable by a
|
||||
//! draining registry — graceful shutdown force-stops it at the drain
|
||||
//! deadline); a dead client is caught when a write stalls past
|
||||
//! `write_timeout`. Reads have no deadline of their own — the select
|
||||
//! only wakes the read path when bytes are pending.
|
||||
|
||||
use crate::conn_actor::{read_some, write_all, ConnLimits};
|
||||
use crate::ws::frame::{self, Assembler, Frame, FrameError, Message, Opcode};
|
||||
|
||||
use smarm::FdArm;
|
||||
|
||||
use std::os::fd::RawFd;
|
||||
use std::time::Instant;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler trait
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Application callbacks for an upgraded WebSocket connection. Both run
|
||||
/// inside the connection actor's select loop — see the module docs for
|
||||
/// what that implies (backpressure, spawn-for-concurrency).
|
||||
///
|
||||
/// A panic in `on_message` closes the connection with a `1011 internal
|
||||
/// error` close frame and skips `on_close` (the handler is not re-entered
|
||||
/// after it panicked).
|
||||
pub trait WsHandler: Send + 'static {
|
||||
/// The connection is up: the 101 has been written and the frame loop
|
||||
/// is about to start. Runs exactly once, first, inside the
|
||||
/// connection actor. This is the place to subscribe / spawn
|
||||
/// producers for sessions where the client may never send (a
|
||||
/// listen-only chat member, a live feed) — `on_message` never fires
|
||||
/// for those. NOTE the 101 reaches the client *before* `on_open`
|
||||
/// runs: a client that needs to know its subscriptions are live must
|
||||
/// use an application-level ack (send something, await the reply —
|
||||
/// any reply proves `on_open` completed, since callbacks are
|
||||
/// sequential in the conn actor). A panic here closes `1011` and
|
||||
/// skips `on_close`, exactly like a panicking `on_message`.
|
||||
/// Default: no-op.
|
||||
fn on_open(&mut self, sender: &WsSender) {
|
||||
let _ = sender;
|
||||
}
|
||||
|
||||
/// A complete data message (fragments already reassembled, text
|
||||
/// already UTF-8 validated) arrived from the client.
|
||||
fn on_message(&mut self, msg: Message, sender: &WsSender);
|
||||
|
||||
/// The connection is over. `code`/`reason` are what the PEER said:
|
||||
/// `Some` iff a close frame was received from the client (whether it
|
||||
/// initiated or echoed ours); `None` for everything wordless — EOF,
|
||||
/// write failure, or a close handshake that timed out. Called exactly
|
||||
/// once, last, on every exit path except a handler panic or a
|
||||
/// force-stop unwind.
|
||||
fn on_close(&mut self, code: Option<u16>, reason: &str) {
|
||||
let _ = (code, reason);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WsSender — the clonable outbound handle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The connection is gone (client disconnect, write timeout, close
|
||||
/// handshake completed, or server shutdown). Producers should exit when
|
||||
/// they see this — the exact contract of `SseClosed`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WsClosed;
|
||||
|
||||
impl std::fmt::Display for WsClosed {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("WebSocket connection closed")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for WsClosed {}
|
||||
|
||||
pub(crate) enum Outbound {
|
||||
Msg(Message),
|
||||
Ping,
|
||||
Close(u16, String),
|
||||
}
|
||||
|
||||
/// Outbound handle for an upgraded connection. Clonable — hand copies to
|
||||
/// producer actors freely; the connection outlives none of them (sends
|
||||
/// after it ends return `Err(WsClosed)`).
|
||||
///
|
||||
/// v1 sends each message as a single unfragmented frame; outbound
|
||||
/// fragmentation is not implemented.
|
||||
#[derive(Clone)]
|
||||
pub struct WsSender {
|
||||
pub(crate) tx: smarm::Sender<Outbound>,
|
||||
}
|
||||
|
||||
impl WsSender {
|
||||
/// Queue a data message for the client.
|
||||
pub fn send(&self, msg: Message) -> Result<(), WsClosed> {
|
||||
self.tx.send(Outbound::Msg(msg)).map_err(|_| WsClosed)
|
||||
}
|
||||
|
||||
/// Sugar: queue a text message.
|
||||
pub fn text(&self, s: impl Into<String>) -> Result<(), WsClosed> {
|
||||
self.send(Message::Text(s.into()))
|
||||
}
|
||||
|
||||
/// Sugar: queue a binary message.
|
||||
pub fn binary(&self, b: impl Into<Vec<u8>>) -> Result<(), WsClosed> {
|
||||
self.send(Message::Binary(b.into()))
|
||||
}
|
||||
|
||||
/// Queue a ping (empty payload). Application-level liveness beyond
|
||||
/// the built-in write-timeout detection; the pong is absorbed by the
|
||||
/// loop in v1.
|
||||
pub fn ping(&self) -> Result<(), WsClosed> {
|
||||
self.tx.send(Outbound::Ping).map_err(|_| WsClosed)
|
||||
}
|
||||
|
||||
/// Initiate the close handshake: a close frame with `code`/`reason`
|
||||
/// goes out, the loop then waits (bounded by `write_timeout`) for the
|
||||
/// peer's echo before dropping the socket. `reason` is truncated to
|
||||
/// fit the 125-byte control payload on a char boundary.
|
||||
pub fn close(&self, code: u16, reason: &str) -> Result<(), WsClosed> {
|
||||
self.tx
|
||||
.send(Outbound::Close(code, reason.to_string()))
|
||||
.map_err(|_| WsClosed)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run the duplex loop until the connection ends. `buf` carries any bytes
|
||||
/// already read past the upgrade request's head — a client may pipeline
|
||||
/// its first frame behind the handshake, and those bytes belong to us
|
||||
/// now.
|
||||
pub(crate) fn run_duplex(
|
||||
raw: RawFd,
|
||||
mut buf: Vec<u8>,
|
||||
mut handler: Box<dyn WsHandler>,
|
||||
limits: &ConnLimits,
|
||||
) {
|
||||
let (tx, rx) = smarm::channel::<Outbound>();
|
||||
// We hold this sender for handler callbacks, so the rx arm can never
|
||||
// report closed while the loop runs — no closed-arm starvation case.
|
||||
let sender = WsSender { tx };
|
||||
let mut asm = Assembler::new(limits.max_message_bytes);
|
||||
|
||||
// on_open before anything is decoded — even a pipelined first frame
|
||||
// is observed by a handler that knows the connection exists. Same
|
||||
// panic contract as on_message: re-raise a stop sentinel, else 1011.
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handler.on_open(&sender)));
|
||||
if r.is_err() {
|
||||
smarm::preempt::check_cancelled();
|
||||
let _ = close_and_drain_code(raw, 1011, "", &mut buf, limits);
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
// ----- 1. Decode everything already buffered. -----
|
||||
// Runs before the first select so a pipelined first frame is
|
||||
// served without waiting for fresh readability.
|
||||
loop {
|
||||
let (frame, consumed) =
|
||||
match frame::decode(&buf, true, limits.max_frame_payload) {
|
||||
Ok(Some(hit)) => hit,
|
||||
Ok(None) => break, // incomplete; need more bytes
|
||||
Err(e) => {
|
||||
let peer = close_and_drain(raw, e, &mut buf, limits);
|
||||
finish(&mut handler, peer);
|
||||
return;
|
||||
}
|
||||
};
|
||||
buf.drain(..consumed);
|
||||
|
||||
match frame.opcode {
|
||||
Opcode::Ping => {
|
||||
// §5.5.2 MUST pong, payload echoed. Written inline —
|
||||
// "as soon as practical" beats channel ordering.
|
||||
let pong = Frame::new(Opcode::Pong, frame.payload);
|
||||
if write_frame(raw, &pong, limits).is_err() {
|
||||
finish(&mut handler, None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Opcode::Pong => {} // invisible in v1
|
||||
Opcode::Close => {
|
||||
// Peer-initiated close: echo its status code (§5.5.1)
|
||||
// and end. The server side closes the TCP connection
|
||||
// first by design (§7.1.1 puts that burden on us).
|
||||
let echo_payload =
|
||||
frame.payload.get(..2).map(<[u8]>::to_vec).unwrap_or_default();
|
||||
let peer = match frame::parse_close_payload(&frame.payload) {
|
||||
Ok(cp) => {
|
||||
let _ = write_frame(
|
||||
raw,
|
||||
&Frame::new(Opcode::Close, echo_payload),
|
||||
limits,
|
||||
);
|
||||
cp
|
||||
}
|
||||
Err(e) => {
|
||||
// Malformed close payload: answer with the
|
||||
// mapped code instead of an echo.
|
||||
let _ = write_frame(
|
||||
raw,
|
||||
&Frame::new(
|
||||
Opcode::Close,
|
||||
frame::close_payload(e.close_code(), ""),
|
||||
),
|
||||
limits,
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
finish_with(&mut handler, peer);
|
||||
return;
|
||||
}
|
||||
_ => match asm.push(frame) {
|
||||
Ok(Some(msg)) => {
|
||||
// A panicking handler must not swallow smarm's stop
|
||||
// sentinel — same re-raise dance as the pipeline.
|
||||
let r = std::panic::catch_unwind(
|
||||
std::panic::AssertUnwindSafe(|| {
|
||||
handler.on_message(msg, &sender)
|
||||
}),
|
||||
);
|
||||
if r.is_err() {
|
||||
smarm::preempt::check_cancelled();
|
||||
// Genuine handler panic: 1011 out, no further
|
||||
// handler calls (no on_close).
|
||||
let _ = close_and_drain_code(
|
||||
raw, 1011, "", &mut buf, limits,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(None) => {} // mid-fragmentation
|
||||
Err(e) => {
|
||||
let peer = close_and_drain(raw, e, &mut buf, limits);
|
||||
finish(&mut handler, peer);
|
||||
return;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ----- 2. Park on first-of outbound / fd-readable. -----
|
||||
// Outbound at index 0: select is priority-ordered, so owed writes
|
||||
// go out before we read more. try_select over select: an fd-arm
|
||||
// registration failure (EBADF after a peer RST, EMFILE on the
|
||||
// epoll set) is a connection event, not a crash. NOTE from the
|
||||
// RFC 008 review: an `AlreadyExists` here would mean a stale
|
||||
// waiter survived select's eager cleanup — that is a smarm bug
|
||||
// candidate; capture it with --features smarm-trace and report,
|
||||
// do not paper over.
|
||||
let fd_arm = FdArm::readable(raw);
|
||||
let idx = match smarm::try_select(&[&rx, &fd_arm]) {
|
||||
Ok(i) => i,
|
||||
Err(_) => {
|
||||
finish(&mut handler, None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if idx == 0 {
|
||||
// Outbound arm won. Single receiver + we hold a sender: a
|
||||
// ready arm means a message is queued (Empty/closed are
|
||||
// impossible here, but stay defensive).
|
||||
let Ok(Some(out)) = rx.try_recv() else { continue };
|
||||
match out {
|
||||
Outbound::Msg(msg) => {
|
||||
let f = match msg {
|
||||
Message::Text(s) => Frame::new(Opcode::Text, s.into_bytes()),
|
||||
Message::Binary(b) => Frame::new(Opcode::Binary, b),
|
||||
};
|
||||
if write_frame(raw, &f, limits).is_err() {
|
||||
finish(&mut handler, None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Outbound::Ping => {
|
||||
let f = Frame::new(Opcode::Ping, Vec::new());
|
||||
if write_frame(raw, &f, limits).is_err() {
|
||||
finish(&mut handler, None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
Outbound::Close(code, reason) => {
|
||||
// We initiate: close out, bounded wait for the echo.
|
||||
let peer = close_and_drain_code(raw, code, &reason, &mut buf, limits);
|
||||
finish(&mut handler, peer);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Readable. The deadline is nominal — the fd already reported
|
||||
// ready, this returns without a fresh park in practice.
|
||||
match read_some(
|
||||
raw,
|
||||
&mut buf,
|
||||
limits.initial_read_buf,
|
||||
Instant::now() + limits.write_timeout,
|
||||
) {
|
||||
Ok(0) => {
|
||||
// EOF without a close frame: abnormal but common.
|
||||
finish(&mut handler, None);
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => {
|
||||
finish(&mut handler, None);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `on_close(None, "")` — wordless endings (EOF, write failure, errors).
|
||||
fn finish(handler: &mut Box<dyn WsHandler>, peer: Option<(u16, String)>) {
|
||||
finish_with(handler, peer);
|
||||
}
|
||||
|
||||
/// `on_close` with whatever the peer said, panic-shielded like
|
||||
/// `on_message` (stop sentinel re-raised; a panic here just ends the
|
||||
/// already-ending connection).
|
||||
fn finish_with(handler: &mut Box<dyn WsHandler>, peer: Option<(u16, String)>) {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
match &peer {
|
||||
Some((code, reason)) => handler.on_close(Some(*code), reason),
|
||||
None => handler.on_close(None, ""),
|
||||
}
|
||||
}));
|
||||
if r.is_err() {
|
||||
smarm::preempt::check_cancelled();
|
||||
}
|
||||
}
|
||||
|
||||
/// One frame onto the wire under a fresh `write_timeout` budget — the
|
||||
/// streaming-chunk discipline, verbatim.
|
||||
fn write_frame(raw: RawFd, f: &Frame, limits: &ConnLimits) -> std::io::Result<()> {
|
||||
write_all(raw, &f.encode(), Instant::now() + limits.write_timeout)
|
||||
}
|
||||
|
||||
/// Server-initiated close for a frame/assembly error: map to the §7.4
|
||||
/// code and run the handshake tail.
|
||||
fn close_and_drain(
|
||||
raw: RawFd,
|
||||
e: FrameError,
|
||||
buf: &mut Vec<u8>,
|
||||
limits: &ConnLimits,
|
||||
) -> Option<(u16, String)> {
|
||||
close_and_drain_code(raw, e.close_code(), "", buf, limits)
|
||||
}
|
||||
|
||||
/// Write our close frame, then wait — bounded by ONE `write_timeout`
|
||||
/// budget — for the peer's close frame, discarding data frames (§1.4: an
|
||||
/// endpoint that has sent close may discard incoming data). Returns what
|
||||
/// the peer's close said, `None` if it never arrived (EOF, timeout,
|
||||
/// garbage). The socket drops at the caller either way.
|
||||
fn close_and_drain_code(
|
||||
raw: RawFd,
|
||||
code: u16,
|
||||
reason: &str,
|
||||
buf: &mut Vec<u8>,
|
||||
limits: &ConnLimits,
|
||||
) -> Option<(u16, String)> {
|
||||
let ours = Frame::new(Opcode::Close, frame::close_payload(code, reason));
|
||||
if write_frame(raw, &ours, limits).is_err() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + limits.write_timeout;
|
||||
loop {
|
||||
loop {
|
||||
match frame::decode(buf, true, limits.max_frame_payload) {
|
||||
Ok(Some((frame, consumed))) => {
|
||||
buf.drain(..consumed);
|
||||
if frame.opcode == Opcode::Close {
|
||||
return frame::parse_close_payload(&frame.payload)
|
||||
.ok()
|
||||
.flatten();
|
||||
}
|
||||
// Data/ping mid-handshake: discarded.
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => return None, // garbage during the tail: give up
|
||||
}
|
||||
}
|
||||
match read_some(raw, buf, limits.initial_read_buf, deadline) {
|
||||
Ok(0) | Err(_) => return None,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
//! RFC 6455 §5 — the frame codec. Pure: bytes in, frames out, no io and
|
||||
//! no actor machinery, so every edge lives in fast unit tests. The
|
||||
//! connection actor (chunk 3) owns buffering and the socket.
|
||||
//!
|
||||
//! Server-side rules enforced here:
|
||||
//! - client→server frames MUST be masked (§5.1); decode takes
|
||||
//! `require_masked` so a future client mode can reuse the codec.
|
||||
//! - server→client frames are NEVER masked; [`Frame::encode`] doesn't
|
||||
//! offer masking. [`encode_masked`] exists for the client side of
|
||||
//! tests.
|
||||
//! - RSV bits must be 0 (we negotiate no extensions), §5.2.
|
||||
//! - Payload lengths must use the minimal encoding, §5.2.
|
||||
//! - Control frames: FIN set, payload ≤ 125, §5.5.
|
||||
//!
|
||||
//! Errors map to close codes via [`FrameError::close_code`]: protocol
|
||||
//! violations → 1002, oversize → 1009, bad UTF-8 in text → 1007.
|
||||
|
||||
/// §5.2 opcodes. Reserved values are rejected at decode.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Opcode {
|
||||
Continuation,
|
||||
Text,
|
||||
Binary,
|
||||
Close,
|
||||
Ping,
|
||||
Pong,
|
||||
}
|
||||
|
||||
impl Opcode {
|
||||
fn from_u4(n: u8) -> Option<Self> {
|
||||
Some(match n {
|
||||
0x0 => Opcode::Continuation,
|
||||
0x1 => Opcode::Text,
|
||||
0x2 => Opcode::Binary,
|
||||
0x8 => Opcode::Close,
|
||||
0x9 => Opcode::Ping,
|
||||
0xA => Opcode::Pong,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_u4(self) -> u8 {
|
||||
match self {
|
||||
Opcode::Continuation => 0x0,
|
||||
Opcode::Text => 0x1,
|
||||
Opcode::Binary => 0x2,
|
||||
Opcode::Close => 0x8,
|
||||
Opcode::Ping => 0x9,
|
||||
Opcode::Pong => 0xA,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_control(self) -> bool {
|
||||
matches!(self, Opcode::Close | Opcode::Ping | Opcode::Pong)
|
||||
}
|
||||
}
|
||||
|
||||
/// One decoded frame; payload already unmasked.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Frame {
|
||||
pub fin: bool,
|
||||
pub opcode: Opcode,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
pub fn new(opcode: Opcode, payload: impl Into<Vec<u8>>) -> Self {
|
||||
Frame { fin: true, opcode, payload: payload.into() }
|
||||
}
|
||||
|
||||
/// Serialise unmasked (server→client, §5.1: a server MUST NOT mask).
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.payload.len() + 10);
|
||||
encode_head(&mut out, self.fin, self.opcode, self.payload.len(), None);
|
||||
out.extend_from_slice(&self.payload);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Why decoding (or assembly) failed. Fatal for the connection: the ws
|
||||
/// close handshake should carry [`FrameError::close_code`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FrameError {
|
||||
/// §5.x violation; the str is a debugging breadcrumb, not protocol.
|
||||
Protocol(&'static str),
|
||||
/// Frame or assembled message exceeds the configured cap → 1009.
|
||||
TooLarge,
|
||||
/// A complete text message that is not valid UTF-8 → 1007.
|
||||
BadUtf8,
|
||||
}
|
||||
|
||||
impl FrameError {
|
||||
pub fn close_code(self) -> u16 {
|
||||
match self {
|
||||
FrameError::Protocol(_) => 1002,
|
||||
FrameError::TooLarge => 1009,
|
||||
FrameError::BadUtf8 => 1007,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to decode one frame from the front of `buf`.
|
||||
///
|
||||
/// - `Ok(Some((frame, consumed)))` — drop `consumed` bytes off the front.
|
||||
/// - `Ok(None)` — incomplete; read more. (Header-derived sizes are still
|
||||
/// bounds-checked first, so a hostile 8-byte length can't make the
|
||||
/// caller buffer toward it: oversize fails *before* the payload
|
||||
/// arrives.)
|
||||
/// - `Err(_)` — fatal; start the close handshake with the mapped code.
|
||||
pub fn decode(
|
||||
buf: &[u8],
|
||||
require_masked: bool,
|
||||
max_payload: usize,
|
||||
) -> Result<Option<(Frame, usize)>, FrameError> {
|
||||
if buf.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
let b0 = buf[0];
|
||||
let b1 = buf[1];
|
||||
|
||||
let fin = b0 & 0x80 != 0;
|
||||
if b0 & 0x70 != 0 {
|
||||
return Err(FrameError::Protocol("RSV bits set without extension"));
|
||||
}
|
||||
let opcode = Opcode::from_u4(b0 & 0x0F)
|
||||
.ok_or(FrameError::Protocol("reserved opcode"))?;
|
||||
|
||||
let masked = b1 & 0x80 != 0;
|
||||
if require_masked && !masked {
|
||||
return Err(FrameError::Protocol("unmasked client frame"));
|
||||
}
|
||||
|
||||
if opcode.is_control() {
|
||||
if !fin {
|
||||
return Err(FrameError::Protocol("fragmented control frame"));
|
||||
}
|
||||
if b1 & 0x7F > 125 {
|
||||
return Err(FrameError::Protocol("control payload > 125"));
|
||||
}
|
||||
}
|
||||
|
||||
// Payload length: 7-bit, or 126 + u16, or 127 + u64 — minimal form
|
||||
// required (§5.2).
|
||||
let (len, mut pos): (u64, usize) = match b1 & 0x7F {
|
||||
126 => {
|
||||
if buf.len() < 4 {
|
||||
return Ok(None);
|
||||
}
|
||||
let l = u16::from_be_bytes([buf[2], buf[3]]) as u64;
|
||||
if l < 126 {
|
||||
return Err(FrameError::Protocol("non-minimal 16-bit length"));
|
||||
}
|
||||
(l, 4)
|
||||
}
|
||||
127 => {
|
||||
if buf.len() < 10 {
|
||||
return Ok(None);
|
||||
}
|
||||
let l = u64::from_be_bytes(buf[2..10].try_into().unwrap());
|
||||
if l & (1 << 63) != 0 {
|
||||
return Err(FrameError::Protocol("64-bit length MSB set"));
|
||||
}
|
||||
if l < 65536 {
|
||||
return Err(FrameError::Protocol("non-minimal 64-bit length"));
|
||||
}
|
||||
(l, 10)
|
||||
}
|
||||
n => (n as u64, 2),
|
||||
};
|
||||
|
||||
// Cap check BEFORE waiting for the payload (see decode docs).
|
||||
if len > max_payload as u64 {
|
||||
return Err(FrameError::TooLarge);
|
||||
}
|
||||
let len = len as usize;
|
||||
|
||||
let mask_key: Option<[u8; 4]> = if masked {
|
||||
if buf.len() < pos + 4 {
|
||||
return Ok(None);
|
||||
}
|
||||
let k = [buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]];
|
||||
pos += 4;
|
||||
Some(k)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if buf.len() < pos + len {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut payload = buf[pos..pos + len].to_vec();
|
||||
if let Some(key) = mask_key {
|
||||
for (i, b) in payload.iter_mut().enumerate() {
|
||||
*b ^= key[i & 3];
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some((Frame { fin, opcode, payload }, pos + len)))
|
||||
}
|
||||
|
||||
fn encode_head(
|
||||
out: &mut Vec<u8>,
|
||||
fin: bool,
|
||||
opcode: Opcode,
|
||||
len: usize,
|
||||
mask: Option<[u8; 4]>,
|
||||
) {
|
||||
let mask_bit = if mask.is_some() { 0x80 } else { 0 };
|
||||
out.push(if fin { 0x80 } else { 0 } | opcode.to_u4());
|
||||
if len <= 125 {
|
||||
out.push(mask_bit | len as u8);
|
||||
} else if len <= u16::MAX as usize {
|
||||
out.push(mask_bit | 126);
|
||||
out.extend_from_slice(&(len as u16).to_be_bytes());
|
||||
} else {
|
||||
out.push(mask_bit | 127);
|
||||
out.extend_from_slice(&(len as u64).to_be_bytes());
|
||||
}
|
||||
if let Some(k) = mask {
|
||||
out.extend_from_slice(&k);
|
||||
}
|
||||
}
|
||||
|
||||
/// Client-side serialisation (masked). Servers never call this in
|
||||
/// production — it exists so tests (and an eventual client mode) can
|
||||
/// produce conformant client frames.
|
||||
pub fn encode_masked(frame: &Frame, key: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(frame.payload.len() + 14);
|
||||
encode_head(&mut out, frame.fin, frame.opcode, frame.payload.len(), Some(key));
|
||||
out.extend(frame.payload.iter().enumerate().map(|(i, b)| b ^ key[i & 3]));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Close payload (§5.5.1, §7.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse a close frame payload: empty is fine (`None`); otherwise a
|
||||
/// 2-byte code + optional UTF-8 reason. A 1-byte payload, an invalid
|
||||
/// wire code, or a non-UTF-8 reason are protocol errors.
|
||||
pub fn parse_close_payload(payload: &[u8]) -> Result<Option<(u16, String)>, FrameError> {
|
||||
match payload.len() {
|
||||
0 => Ok(None),
|
||||
1 => Err(FrameError::Protocol("1-byte close payload")),
|
||||
_ => {
|
||||
let code = u16::from_be_bytes([payload[0], payload[1]]);
|
||||
if !close_code_valid_on_wire(code) {
|
||||
return Err(FrameError::Protocol("invalid close code"));
|
||||
}
|
||||
let reason = std::str::from_utf8(&payload[2..])
|
||||
.map_err(|_| FrameError::Protocol("close reason not UTF-8"))?;
|
||||
Ok(Some((code, reason.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// §7.4.1/.2: codes an endpoint may put on the wire. 1004 is reserved;
|
||||
/// 1005/1006/1015 are signalling-only (MUST NOT appear in a close
|
||||
/// frame); 1000–1011 otherwise fine; 3000–4999 are app/registry space.
|
||||
fn close_code_valid_on_wire(code: u16) -> bool {
|
||||
matches!(code, 1000..=1003 | 1007..=1011 | 3000..=4999)
|
||||
}
|
||||
|
||||
/// Build a close frame payload for `code` (+ optional reason, truncated
|
||||
/// to fit the 125-byte control cap on a UTF-8 boundary).
|
||||
pub fn close_payload(code: u16, reason: &str) -> Vec<u8> {
|
||||
let mut p = Vec::with_capacity(2 + reason.len().min(123));
|
||||
p.extend_from_slice(&code.to_be_bytes());
|
||||
let mut cut = reason.len().min(123);
|
||||
while !reason.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
p.extend_from_slice(&reason.as_bytes()[..cut]);
|
||||
p
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message assembly (§5.4 fragmentation)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A complete application message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Message {
|
||||
Text(String),
|
||||
Binary(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Reassembles data frames into [`Message`]s. Feed it ONLY data frames
|
||||
/// (Text/Binary/Continuation) — control frames interleave at the caller
|
||||
/// (§5.4 allows them mid-fragmentation) and never enter the assembler.
|
||||
///
|
||||
/// Enforced here: continuation with nothing in progress; a new data
|
||||
/// frame while a fragmented message is in progress; total message size
|
||||
/// (1009); text UTF-8 validity, checked once on the complete message
|
||||
/// (we deliver whole messages, so per-frame incremental validation buys
|
||||
/// nothing).
|
||||
#[derive(Debug)]
|
||||
pub struct Assembler {
|
||||
buf: Vec<u8>,
|
||||
in_progress: Option<Opcode>, // Text or Binary
|
||||
max_message: usize,
|
||||
}
|
||||
|
||||
impl Assembler {
|
||||
pub fn new(max_message: usize) -> Self {
|
||||
Assembler { buf: Vec::new(), in_progress: None, max_message }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, frame: Frame) -> Result<Option<Message>, FrameError> {
|
||||
match (frame.opcode, self.in_progress) {
|
||||
(Opcode::Text | Opcode::Binary, Some(_)) => {
|
||||
return Err(FrameError::Protocol("new data frame mid-fragmentation"));
|
||||
}
|
||||
(Opcode::Continuation, None) => {
|
||||
return Err(FrameError::Protocol("continuation without a message"));
|
||||
}
|
||||
(Opcode::Text | Opcode::Binary, None) if frame.fin => {
|
||||
// Unfragmented fast path: no buffer copy.
|
||||
if frame.payload.len() > self.max_message {
|
||||
return Err(FrameError::TooLarge);
|
||||
}
|
||||
return Self::complete(frame.opcode, frame.payload).map(Some);
|
||||
}
|
||||
(Opcode::Text | Opcode::Binary, None) => {
|
||||
self.in_progress = Some(frame.opcode);
|
||||
}
|
||||
(Opcode::Continuation, Some(_)) => {}
|
||||
(op, _) if op.is_control() => {
|
||||
return Err(FrameError::Protocol("control frame fed to assembler"));
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
if self.buf.len() + frame.payload.len() > self.max_message {
|
||||
self.reset();
|
||||
return Err(FrameError::TooLarge);
|
||||
}
|
||||
self.buf.extend_from_slice(&frame.payload);
|
||||
|
||||
if frame.fin {
|
||||
let kind = self.in_progress.take().expect("checked above");
|
||||
let payload = std::mem::take(&mut self.buf);
|
||||
return Self::complete(kind, payload).map(Some);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn complete(kind: Opcode, payload: Vec<u8>) -> Result<Message, FrameError> {
|
||||
match kind {
|
||||
Opcode::Binary => Ok(Message::Binary(payload)),
|
||||
Opcode::Text => String::from_utf8(payload)
|
||||
.map(Message::Text)
|
||||
.map_err(|_| FrameError::BadUtf8),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.buf.clear();
|
||||
self.in_progress = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MAX: usize = 1 << 20;
|
||||
|
||||
fn dec(buf: &[u8]) -> Result<Option<(Frame, usize)>, FrameError> {
|
||||
decode(buf, true, MAX)
|
||||
}
|
||||
|
||||
// ----- §5.7 worked examples. -----
|
||||
|
||||
#[test]
|
||||
fn rfc_masked_hello() {
|
||||
// "A single-frame masked text message" containing "Hello".
|
||||
let wire = [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58];
|
||||
let (f, used) = dec(&wire).unwrap().unwrap();
|
||||
assert_eq!(used, wire.len());
|
||||
assert!(f.fin);
|
||||
assert_eq!(f.opcode, Opcode::Text);
|
||||
assert_eq!(f.payload, b"Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_unmasked_hello_rejected_then_allowed() {
|
||||
let wire = [0x81, 0x05, b'H', b'e', b'l', b'l', b'o'];
|
||||
assert_eq!(
|
||||
dec(&wire),
|
||||
Err(FrameError::Protocol("unmasked client frame"))
|
||||
);
|
||||
// Same bytes are fine when masking isn't required (client mode).
|
||||
let (f, _) = decode(&wire, false, MAX).unwrap().unwrap();
|
||||
assert_eq!(f.payload, b"Hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_fragmented_hel_lo() {
|
||||
let f1 = [0x01, 0x03, b'H', b'e', b'l'];
|
||||
let f2 = [0x80, 0x02, b'l', b'o'];
|
||||
let (a, _) = decode(&f1, false, MAX).unwrap().unwrap();
|
||||
let (b, _) = decode(&f2, false, MAX).unwrap().unwrap();
|
||||
assert!(!a.fin);
|
||||
assert_eq!(a.opcode, Opcode::Text);
|
||||
assert_eq!(b.opcode, Opcode::Continuation);
|
||||
|
||||
let mut asm = Assembler::new(MAX);
|
||||
assert_eq!(asm.push(a).unwrap(), None);
|
||||
assert_eq!(
|
||||
asm.push(b).unwrap(),
|
||||
Some(Message::Text("Hello".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_ping_pong_hello() {
|
||||
let ping = [0x89, 0x05, b'H', b'e', b'l', b'l', b'o'];
|
||||
let (f, _) = decode(&ping, false, MAX).unwrap().unwrap();
|
||||
assert_eq!(f.opcode, Opcode::Ping);
|
||||
assert_eq!(f.payload, b"Hello");
|
||||
// Pong response carries the same payload back, unmasked.
|
||||
let pong = Frame::new(Opcode::Pong, f.payload.clone()).encode();
|
||||
assert_eq!(pong, [0x8A, 0x05, b'H', b'e', b'l', b'l', b'o']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_256_bytes_binary_16bit_len() {
|
||||
let payload = vec![0xAB; 256];
|
||||
let wire = Frame::new(Opcode::Binary, payload.clone()).encode();
|
||||
assert_eq!(&wire[..4], &[0x82, 0x7E, 0x01, 0x00]);
|
||||
let (f, used) = decode(&wire, false, MAX).unwrap().unwrap();
|
||||
assert_eq!(used, wire.len());
|
||||
assert_eq!(f.payload, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc_64k_binary_64bit_len() {
|
||||
let payload = vec![7u8; 65536];
|
||||
let wire = Frame::new(Opcode::Binary, payload.clone()).encode();
|
||||
assert_eq!(&wire[..10], &[0x82, 0x7F, 0, 0, 0, 0, 0, 1, 0, 0]);
|
||||
let (f, _) = decode(&wire, false, MAX).unwrap().unwrap();
|
||||
assert_eq!(f.payload.len(), 65536);
|
||||
}
|
||||
|
||||
// ----- round trips. -----
|
||||
|
||||
#[test]
|
||||
fn masked_roundtrip_all_lengths() {
|
||||
// Cross the 125/126 and 65535/65536 encoding boundaries.
|
||||
for len in [0, 1, 125, 126, 127, 65535, 65536] {
|
||||
let f = Frame::new(Opcode::Binary, vec![0x5A; len]);
|
||||
let wire = encode_masked(&f, [0xDE, 0xAD, 0xBE, 0xEF]);
|
||||
let (g, used) = decode(&wire, true, 1 << 20).unwrap().unwrap();
|
||||
assert_eq!(used, wire.len(), "len {len}");
|
||||
assert_eq!(g, f, "len {len}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_is_incremental_at_every_prefix() {
|
||||
let f = Frame::new(Opcode::Text, b"incremental".to_vec());
|
||||
let wire = encode_masked(&f, [1, 2, 3, 4]);
|
||||
for cut in 0..wire.len() {
|
||||
assert_eq!(dec(&wire[..cut]).unwrap(), None, "prefix {cut}");
|
||||
}
|
||||
assert!(dec(&wire).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_leaves_trailing_bytes() {
|
||||
let f = Frame::new(Opcode::Text, b"a".to_vec());
|
||||
let mut wire = encode_masked(&f, [9, 9, 9, 9]);
|
||||
let first_len = wire.len();
|
||||
wire.extend_from_slice(&[0x81]); // start of a second frame
|
||||
let (_, used) = dec(&wire).unwrap().unwrap();
|
||||
assert_eq!(used, first_len);
|
||||
}
|
||||
|
||||
// ----- protocol violations. -----
|
||||
|
||||
#[test]
|
||||
fn rsv_bits_rejected() {
|
||||
for rsv in [0x40, 0x20, 0x10] {
|
||||
let wire = [0x81 | rsv, 0x80, 0, 0, 0, 0];
|
||||
assert!(matches!(dec(&wire), Err(FrameError::Protocol(_))), "rsv {rsv:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_opcodes_rejected() {
|
||||
for op in [0x3, 0x4, 0x5, 0x6, 0x7, 0xB, 0xC, 0xD, 0xE, 0xF] {
|
||||
let wire = [0x80 | op, 0x80, 0, 0, 0, 0];
|
||||
assert!(matches!(dec(&wire), Err(FrameError::Protocol(_))), "op {op:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_control_rejected() {
|
||||
let wire = [0x09, 0x80, 0, 0, 0, 0]; // ping, FIN=0
|
||||
assert_eq!(dec(&wire), Err(FrameError::Protocol("fragmented control frame")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_control_rejected() {
|
||||
let wire = [0x89, 0x80 | 126, 0x00, 0x7E]; // ping, 16-bit len 126
|
||||
assert_eq!(dec(&wire), Err(FrameError::Protocol("control payload > 125")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_minimal_lengths_rejected() {
|
||||
// 16-bit form carrying 5.
|
||||
let w16 = [0x81, 0x80 | 126, 0x00, 0x05];
|
||||
assert_eq!(dec(&w16), Err(FrameError::Protocol("non-minimal 16-bit length")));
|
||||
// 64-bit form carrying 5.
|
||||
let w64 = [0x81, 0x80 | 127, 0, 0, 0, 0, 0, 0, 0, 0x05];
|
||||
assert_eq!(dec(&w64), Err(FrameError::Protocol("non-minimal 64-bit length")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msb_set_64bit_length_rejected() {
|
||||
let wire = [0x81, 0x80 | 127, 0x80, 0, 0, 0, 0, 0, 0, 0];
|
||||
assert_eq!(dec(&wire), Err(FrameError::Protocol("64-bit length MSB set")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversize_fails_before_payload_arrives() {
|
||||
// Header promises 1 MiB + 1 with a 1 MiB cap: must fail with just
|
||||
// the header in hand, not Ok(None).
|
||||
let mut wire = vec![0x82, 0x80 | 127];
|
||||
wire.extend_from_slice(&((MAX as u64) + 1).to_be_bytes());
|
||||
assert_eq!(dec(&wire), Err(FrameError::TooLarge));
|
||||
}
|
||||
|
||||
// ----- close payloads. -----
|
||||
|
||||
#[test]
|
||||
fn close_payload_parsing() {
|
||||
assert_eq!(parse_close_payload(&[]), Ok(None));
|
||||
assert_eq!(
|
||||
parse_close_payload(&[0x03]),
|
||||
Err(FrameError::Protocol("1-byte close payload"))
|
||||
);
|
||||
let normal = close_payload(1000, "bye");
|
||||
assert_eq!(parse_close_payload(&normal), Ok(Some((1000, "bye".into()))));
|
||||
// Signalling-only and reserved codes are invalid on the wire.
|
||||
for bad in [999u16, 1004, 1005, 1006, 1015, 1100, 2999, 5000] {
|
||||
assert!(
|
||||
parse_close_payload(&bad.to_be_bytes()).is_err(),
|
||||
"code {bad}"
|
||||
);
|
||||
}
|
||||
for good in [1000u16, 1001, 1002, 1003, 1007, 1011, 3000, 4999] {
|
||||
assert!(
|
||||
parse_close_payload(&good.to_be_bytes()).is_ok(),
|
||||
"code {good}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_payload_reason_truncates_on_char_boundary() {
|
||||
let reason = "é".repeat(100); // 200 bytes of 2-byte chars
|
||||
let p = close_payload(1000, &reason);
|
||||
assert!(p.len() <= 125);
|
||||
assert_eq!(p.len() % 2, 0, "must not split the 2-byte char");
|
||||
assert!(std::str::from_utf8(&p[2..]).is_ok());
|
||||
}
|
||||
|
||||
// ----- assembler. -----
|
||||
|
||||
#[test]
|
||||
fn assembler_rejects_orphan_continuation() {
|
||||
let mut a = Assembler::new(MAX);
|
||||
let f = Frame { fin: true, opcode: Opcode::Continuation, payload: vec![] };
|
||||
assert_eq!(
|
||||
a.push(f),
|
||||
Err(FrameError::Protocol("continuation without a message"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_rejects_interleaved_data_message() {
|
||||
let mut a = Assembler::new(MAX);
|
||||
let start = Frame { fin: false, opcode: Opcode::Text, payload: b"a".to_vec() };
|
||||
a.push(start).unwrap();
|
||||
let intruder = Frame::new(Opcode::Binary, b"b".to_vec());
|
||||
assert_eq!(
|
||||
a.push(intruder),
|
||||
Err(FrameError::Protocol("new data frame mid-fragmentation"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_message_size_cap_spans_fragments() {
|
||||
let mut a = Assembler::new(10);
|
||||
let f1 = Frame { fin: false, opcode: Opcode::Binary, payload: vec![0; 6] };
|
||||
let f2 = Frame { fin: true, opcode: Opcode::Continuation, payload: vec![0; 6] };
|
||||
assert_eq!(a.push(f1).unwrap(), None);
|
||||
assert_eq!(a.push(f2), Err(FrameError::TooLarge));
|
||||
// And the unfragmented fast path is capped too.
|
||||
assert_eq!(
|
||||
a.push(Frame::new(Opcode::Binary, vec![0; 11])),
|
||||
Err(FrameError::TooLarge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_text_utf8_validated_on_completion() {
|
||||
let mut a = Assembler::new(MAX);
|
||||
// A 2-byte UTF-8 char split across the fragment boundary must
|
||||
// still assemble — validation is whole-message.
|
||||
let bytes = "héllo".as_bytes();
|
||||
let f1 = Frame { fin: false, opcode: Opcode::Text, payload: bytes[..2].to_vec() };
|
||||
let f2 = Frame { fin: true, opcode: Opcode::Continuation, payload: bytes[2..].to_vec() };
|
||||
assert_eq!(a.push(f1).unwrap(), None);
|
||||
assert_eq!(a.push(f2).unwrap(), Some(Message::Text("héllo".into())));
|
||||
|
||||
// Invalid UTF-8 → BadUtf8 (close 1007).
|
||||
let bad = Frame::new(Opcode::Text, vec![0xFF, 0xFE]);
|
||||
assert_eq!(a.push(bad), Err(FrameError::BadUtf8));
|
||||
assert_eq!(FrameError::BadUtf8.close_code(), 1007);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assembler_reusable_after_message() {
|
||||
let mut a = Assembler::new(MAX);
|
||||
for _ in 0..3 {
|
||||
let f1 = Frame { fin: false, opcode: Opcode::Text, payload: b"He".to_vec() };
|
||||
let f2 = Frame { fin: true, opcode: Opcode::Continuation, payload: b"llo".to_vec() };
|
||||
assert_eq!(a.push(f1).unwrap(), None);
|
||||
assert_eq!(a.push(f2).unwrap(), Some(Message::Text("Hello".into())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! RFC 6455 §4.2 — the opening handshake, server side.
|
||||
//!
|
||||
//! Pure functions over an already-parsed request; no io. The connection
|
||||
//! actor owns the wire.
|
||||
|
||||
use crate::conn::{Conn, HttpVersion, Method};
|
||||
|
||||
/// RFC 6455 §1.3 — the fixed GUID appended to the client key.
|
||||
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||||
|
||||
/// Why a handshake was rejected. Maps onto a response via
|
||||
/// [`Rejection::status`]: everything is a `400` except a version
|
||||
/// mismatch, which RFC 6455 §4.2.2 answers with `426` + a
|
||||
/// `sec-websocket-version: 13` header so the client can retry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Rejection {
|
||||
/// Handshake requests must be GET (§4.2.1).
|
||||
NotGet,
|
||||
/// HTTP/1.1 or higher required (§4.2.1).
|
||||
BadHttpVersion,
|
||||
/// `Host` header missing (§4.2.1 item 2).
|
||||
MissingHost,
|
||||
/// `Upgrade` header missing or lacks the `websocket` token.
|
||||
NotAnUpgrade,
|
||||
/// `Connection` header missing or lacks the `upgrade` token.
|
||||
ConnectionNotUpgrade,
|
||||
/// `Sec-WebSocket-Key` missing or not base64 of exactly 16 bytes.
|
||||
BadKey,
|
||||
/// `Sec-WebSocket-Version` is not 13 → 426 Upgrade Required.
|
||||
UnsupportedVersion,
|
||||
}
|
||||
|
||||
impl Rejection {
|
||||
pub fn status(self) -> u16 {
|
||||
match self {
|
||||
Rejection::UnsupportedVersion => 426,
|
||||
_ => 400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap routing predicate: does this request *ask* for a WebSocket
|
||||
/// upgrade? (Token checks only — full validation happens in
|
||||
/// [`validate`].) Lets a handler branch without committing to the
|
||||
/// handshake.
|
||||
pub fn is_upgrade_request(conn: &Conn) -> bool {
|
||||
has_token(conn.headers.get("upgrade"), "websocket")
|
||||
&& has_token(conn.headers.get("connection"), "upgrade")
|
||||
}
|
||||
|
||||
/// Full §4.2.1 server-side validation. `Ok` carries the computed
|
||||
/// `Sec-WebSocket-Accept` value.
|
||||
pub fn validate(conn: &Conn) -> Result<String, Rejection> {
|
||||
if conn.method != Method::Get {
|
||||
return Err(Rejection::NotGet);
|
||||
}
|
||||
if conn.version != HttpVersion::Http11 {
|
||||
return Err(Rejection::BadHttpVersion);
|
||||
}
|
||||
if conn.headers.get("host").is_none() {
|
||||
return Err(Rejection::MissingHost);
|
||||
}
|
||||
if !has_token(conn.headers.get("upgrade"), "websocket") {
|
||||
return Err(Rejection::NotAnUpgrade);
|
||||
}
|
||||
if !has_token(conn.headers.get("connection"), "upgrade") {
|
||||
return Err(Rejection::ConnectionNotUpgrade);
|
||||
}
|
||||
match conn.headers.get("sec-websocket-version") {
|
||||
Some(v) if v.trim() == "13" => {}
|
||||
_ => return Err(Rejection::UnsupportedVersion),
|
||||
}
|
||||
let key = conn
|
||||
.headers
|
||||
.get("sec-websocket-key")
|
||||
.map(str::trim)
|
||||
.ok_or(Rejection::BadKey)?;
|
||||
if !key_is_b64_16_bytes(key) {
|
||||
return Err(Rejection::BadKey);
|
||||
}
|
||||
Ok(accept_key(key))
|
||||
}
|
||||
|
||||
/// `base64(SHA1(key ++ GUID))` — §4.2.2 item 5.4.
|
||||
pub fn accept_key(key: &str) -> String {
|
||||
let mut h = sha1_smol::Sha1::new();
|
||||
h.update(key.as_bytes());
|
||||
h.update(WS_GUID.as_bytes());
|
||||
b64(&h.digest().bytes())
|
||||
}
|
||||
|
||||
/// Case-insensitive token search in a comma-separated header value.
|
||||
/// `Connection: keep-alive, Upgrade` must match token `upgrade`.
|
||||
fn has_token(value: Option<&str>, token: &str) -> bool {
|
||||
match value {
|
||||
None => false,
|
||||
Some(v) => v
|
||||
.split(',')
|
||||
.any(|t| t.trim().eq_ignore_ascii_case(token)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The client key must be base64 of exactly 16 bytes: 24 chars, last two
|
||||
/// `==`, the rest in the standard alphabet (RFC 4648 §4). We never
|
||||
/// decode it — §4.2.2 only concatenates the encoded form.
|
||||
fn key_is_b64_16_bytes(key: &str) -> bool {
|
||||
let b = key.as_bytes();
|
||||
b.len() == 24
|
||||
&& b[22] == b'='
|
||||
&& b[23] == b'='
|
||||
&& b[..22]
|
||||
.iter()
|
||||
.all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/')
|
||||
}
|
||||
|
||||
/// RFC 4648 base64 *encoding* (standard alphabet, padded). Encode-only
|
||||
/// and not cryptographic — kept in-tree by design; do not grow a decoder
|
||||
/// here without a design conversation (decoding is where the parsing
|
||||
/// bugs live).
|
||||
pub(crate) fn b64(input: &[u8]) -> String {
|
||||
const ALPHA: &[u8; 64] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
|
||||
for chunk in input.chunks(3) {
|
||||
let b0 = chunk[0] as u32;
|
||||
let b1 = *chunk.get(1).unwrap_or(&0) as u32;
|
||||
let b2 = *chunk.get(2).unwrap_or(&0) as u32;
|
||||
let n = (b0 << 16) | (b1 << 8) | b2;
|
||||
out.push(ALPHA[(n >> 18) as usize & 63] as char);
|
||||
out.push(ALPHA[(n >> 12) as usize & 63] as char);
|
||||
out.push(if chunk.len() > 1 { ALPHA[(n >> 6) as usize & 63] as char } else { '=' });
|
||||
out.push(if chunk.len() > 2 { ALPHA[n as usize & 63] as char } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::conn::HeaderMap;
|
||||
|
||||
// ----- base64: RFC 4648 §10 test vectors. -----
|
||||
#[test]
|
||||
fn b64_rfc4648_vectors() {
|
||||
assert_eq!(b64(b""), "");
|
||||
assert_eq!(b64(b"f"), "Zg==");
|
||||
assert_eq!(b64(b"fo"), "Zm8=");
|
||||
assert_eq!(b64(b"foo"), "Zm9v");
|
||||
assert_eq!(b64(b"foob"), "Zm9vYg==");
|
||||
assert_eq!(b64(b"fooba"), "Zm9vYmE=");
|
||||
assert_eq!(b64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
|
||||
// ----- accept key: the RFC 6455 §1.3 worked example. -----
|
||||
#[test]
|
||||
fn accept_key_rfc6455_example() {
|
||||
assert_eq!(
|
||||
accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
|
||||
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
);
|
||||
}
|
||||
|
||||
fn ws_conn() -> Conn {
|
||||
let mut c = Conn::new();
|
||||
c.method = Method::Get;
|
||||
c.version = HttpVersion::Http11;
|
||||
let mut h = HeaderMap::new();
|
||||
h.append("host", "example.com");
|
||||
h.append("upgrade", "websocket");
|
||||
h.append("connection", "Upgrade");
|
||||
h.append("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==");
|
||||
h.append("sec-websocket-version", "13");
|
||||
c.headers = h;
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_happy_path() {
|
||||
assert_eq!(
|
||||
validate(&ws_conn()).unwrap(),
|
||||
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_post() {
|
||||
let mut c = ws_conn();
|
||||
c.method = Method::Post;
|
||||
assert_eq!(validate(&c), Err(Rejection::NotGet));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_http10() {
|
||||
let mut c = ws_conn();
|
||||
c.version = HttpVersion::Http10;
|
||||
assert_eq!(validate(&c), Err(Rejection::BadHttpVersion));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_requires_host() {
|
||||
let mut c = ws_conn();
|
||||
let mut h = HeaderMap::new();
|
||||
for (n, v) in c.headers.iter() {
|
||||
if n != "host" {
|
||||
h.append(n, v);
|
||||
}
|
||||
}
|
||||
c.headers = h;
|
||||
assert_eq!(validate(&c), Err(Rejection::MissingHost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_connection_token_in_list() {
|
||||
let mut c = ws_conn();
|
||||
c.headers.set("connection", "keep-alive, Upgrade");
|
||||
assert!(validate(&c).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_wrong_ws_version() {
|
||||
let mut c = ws_conn();
|
||||
c.headers.set("sec-websocket-version", "8");
|
||||
assert_eq!(validate(&c), Err(Rejection::UnsupportedVersion));
|
||||
assert_eq!(Rejection::UnsupportedVersion.status(), 426);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_bad_keys() {
|
||||
for bad in [
|
||||
"tooshort==",
|
||||
"dGhlIHNhbXBsZSBub25jZQ=!", // bad padding char
|
||||
"dGhlIHNhbXBsZSBub25jZSE=", // single pad = 17 bytes, not 16
|
||||
"dGhlIHNhbXBsZSBub25jZQQQ", // unpadded 24 chars (18 bytes)
|
||||
"",
|
||||
] {
|
||||
let mut c = ws_conn();
|
||||
c.headers.set("sec-websocket-key", bad);
|
||||
assert_eq!(validate(&c), Err(Rejection::BadKey), "key: {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_upgrade_request_predicate() {
|
||||
assert!(is_upgrade_request(&ws_conn()));
|
||||
let mut c = ws_conn();
|
||||
c.headers.set("upgrade", "h2c");
|
||||
assert!(!is_upgrade_request(&c));
|
||||
assert!(!is_upgrade_request(&Conn::new()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! WebSocket support (RFC 6455).
|
||||
//!
|
||||
//! v0.4 chunk 1: the upgrade handshake. A handler that wants to speak
|
||||
//! WebSocket calls [`Conn::upgrade`](crate::Conn::upgrade); on a valid
|
||||
//! handshake the connection actor writes the `101 Switching Protocols`
|
||||
//! response and leaves the HTTP request loop. (Until the duplex wiring
|
||||
//! lands — chunk 3 — leaving the loop closes the socket; the 101 itself
|
||||
//! is correct and tested.)
|
||||
//!
|
||||
//! Dependency note: SHA-1 comes from `sha1_smol` (zero transitive deps),
|
||||
//! spending urus dependency slot #3 — agreed in the v0.4 design pass over
|
||||
//! vendoring. Base64 here is the 20-byte *encode* direction only and is
|
||||
//! not cryptographic, so that stays in-tree (`handshake::b64`).
|
||||
|
||||
pub mod duplex;
|
||||
pub mod frame;
|
||||
pub mod handshake;
|
||||
|
||||
pub use duplex::{WsClosed, WsHandler, WsSender};
|
||||
pub use frame::{Frame, FrameError, Message, Opcode};
|
||||
pub use handshake::Rejection;
|
||||
|
||||
/// Payload carried on a [`Conn`](crate::Conn) whose handler accepted a
|
||||
/// WebSocket upgrade: the boxed [`WsHandler`] the duplex loop will drive
|
||||
/// (the chunk-3 growth this was reserved for). Still opaque outside the
|
||||
/// crate.
|
||||
pub struct WsUpgrade {
|
||||
pub(crate) handler: Box<dyn WsHandler>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WsUpgrade {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("WsUpgrade { handler: .. }")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user