Compare commits
22
Commits
| 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
|
target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
|
smarm_trace.json
|
||||||
|
|||||||
+13
@@ -9,9 +9,17 @@ description = "Cowboy/bandit-style HTTP library for the smarm actor runtime"
|
|||||||
smarm = { path = "../smarm" }
|
smarm = { path = "../smarm" }
|
||||||
httparse = "1.9"
|
httparse = "1.9"
|
||||||
libc = "0.2"
|
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]
|
[features]
|
||||||
smarm-trace = ["smarm/smarm-trace"]
|
smarm-trace = ["smarm/smarm-trace"]
|
||||||
|
channels = []
|
||||||
|
phoenix = ["channels", "dep:serde", "dep:serde_json"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -28,3 +36,8 @@ codegen-units = 1
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "crud"
|
name = "crud"
|
||||||
path = "examples/crud.rs"
|
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
|
```rust
|
||||||
use urus::{serve_with, Config};
|
use urus::{serve_with, Config};
|
||||||
use std::net::SocketAddr;
|
use std::time::Duration;
|
||||||
|
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
listener_pool: 2, // Number of OS threads handling accept()
|
listener_pool: 2, // Supervised accept-loop actors
|
||||||
scheduler_threads: Some(2), // Number of smarm worker threads
|
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())
|
..Config::new("127.0.0.1:8080".parse().unwrap())
|
||||||
};
|
};
|
||||||
|
|
||||||
serve_with(cfg, pipeline).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
|
## Examples
|
||||||
|
|
||||||
### CRUD with Actor Ownership
|
### CRUD with Actor Ownership
|
||||||
@@ -165,11 +452,12 @@ cargo test
|
|||||||
```
|
```
|
||||||
|
|
||||||
Tests in `tests/integration.rs` cover:
|
Tests in `tests/integration.rs` cover:
|
||||||
- Basic routing (GET, POST, PUT, DELETE)
|
- Basic routing, request bodies, headers, path parameters, status codes
|
||||||
- Request body echoing
|
- Keep-alive and pipelining
|
||||||
- HTTP header parsing
|
- Listener supervision (a panicking listener restarts without dropping a pending accept)
|
||||||
- Path parameter extraction
|
- Graceful shutdown (idle close, in-flight drain, force-stop at the drain deadline)
|
||||||
- Status code responses
|
- Timeouts (idle keep-alive reaping, slowloris-style slow headers/body)
|
||||||
|
- Registry names (`urus.server`, `urus.listener.{i}` resolve via `whereis`)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -205,11 +493,11 @@ This avoids the complexity of async ecosystems while maintaining full concurrenc
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
v1 covers HTTP/1.1, the plug pipeline, and a built-in router. Future versions may include:
|
See [`ROADMAP.md`](ROADMAP.md). Summary: v0.2 (supervised listener pool,
|
||||||
- Middleware ecosystem (auth, logging, compression, etc.).
|
graceful shutdown, enforced timeouts, registry names), v0.3 (streaming
|
||||||
- WebSocket support.
|
bodies, chunked requests, SSE), v0.4 (WebSocket: handshake, frame
|
||||||
- Benchmarking suite (see `urus-bench-spec.md`).
|
codec, duplex handler API), v0.5 (PubSub) and v0.6 (Phoenix-style
|
||||||
- Additional utilities and examples.
|
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.
|
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)
|
## 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
|
- **Builds clean, 24/24 tests pass.** The scheduler surface urus consumes
|
||||||
(`spawn`, `wait_readable`, `wait_writable`, `sleep`, `init`, `Config`,
|
(`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
|
- **No streaming response path.** `RespBody` is `Empty | Bytes`. Blocks SSE
|
||||||
(a spec v1 goal that slipped) and chunked responses.
|
(a spec v1 goal that slipped) and chunked responses.
|
||||||
- **Full-duplex constraint (matters for WebSocket).** smarm's epoll model is
|
- **Full-duplex constraint (matters for WebSocket).** smarm's epoll model is
|
||||||
one waiter per RawFd (`AlreadyExists` on the second registration) and one
|
one waiter per RawFd (`AlreadyExists`, now surfaced as `Err` via the
|
||||||
direction per parked actor. Concurrent read+write on one connection needs
|
`try_select` twins) and one direction per parked actor. RFC 008 phase 1
|
||||||
either the dup-fd trick (serve.rs already uses it for the listener pool)
|
(landed `393cdd0`) gives first-of(fd, channel) via fd arms in select —
|
||||||
or a new smarm primitive. Decide in v0.2 design, before the frame codec.
|
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`
|
Adopt the primitives urus predates. No protocol surface changes; `Plug`/`Conn`
|
||||||
untouched. Chunks are independently landable, in order:
|
untouched. Chunks are independently landable, in order:
|
||||||
|
|
||||||
### 1. Supervised listener pool
|
### 1. Supervised listener pool ✅
|
||||||
Replace bare spawns in `serve_with` with a supervisor:
|
Landed: `serve_with` runs a `Strategy::OneForOne` supervisor on the root
|
||||||
`Strategy::OneForOne`, one `ChildSpec` per dup'd listener fd,
|
actor, one `ChildSpec` per dup'd listener fd, `Restart::Permanent`. A
|
||||||
`Restart::Permanent`. A listener panic restarts that listener; the pool
|
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
|
never silently shrinks. Connection actors stay unsupervised bare spawns
|
||||||
(per spec: a connection is cheap and its failure is local — a 500 path,
|
(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
|
Open question resolved as leaned: the ChildSpec factory owns the fd via
|
||||||
own its fd across restarts via the ChildSpec closure? Leaning: closure
|
`Arc<OwnedFd>` (`OwnedFd` gained `Sync` for this) and every (re)start
|
||||||
captures the `OwnedFd`, re-registers on restart. No re-dup needed.
|
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()`,
|
`serve_with` gains a shutdown signal (an `urus::Handle` with `.shutdown()`,
|
||||||
backed by a channel + `request_stop` fan-out):
|
backed by a channel + `request_stop` fan-out):
|
||||||
1. Stop listeners (no new accepts; listener fds close).
|
1. Stop listeners (no new accepts; listener fds close).
|
||||||
2. Stop connection actors — either drain-then-stop with a deadline, or
|
2. **Decided: drain-then-stop with a deadline** — in-flight requests get
|
||||||
immediate `request_stop` (unwind closes fds cleanly via `OwnedFd::drop`).
|
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.
|
3. Supervisor winds down; `rt.run` returns; `serve_with` returns.
|
||||||
|
|
||||||
Requires tracking live connection pids. Cheapest honest version: a
|
Requires tracking live connection pids. Cheapest honest version: a
|
||||||
`gen_server` connection registry — listener casts `{Started, pid}`, conn
|
`gen_server` connection registry — listener casts `{Started, pid}`, conn
|
||||||
actor monitors-free self-deregisters via a drop guard cast. This registry
|
actor monitors-free self-deregisters via a drop guard cast. Pid set only
|
||||||
is also chunk 3's reaper and the future ws/channels introspection point,
|
(no activity stamps — chunk 3 went per-conn timed waits, no reaper); still
|
||||||
so build it once here.
|
the future ws/channels introspection point, so build it once here.
|
||||||
|
|
||||||
### 3. Enforce the timeouts we advertise
|
### 3. Enforce the timeouts we advertise ✅
|
||||||
**Decision fork — pick one before implementing:**
|
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
|
`read_some` parks in `wait_readable_timeout` instead of `wait_readable`;
|
||||||
`last_activity`; a ticker (`smarm::sleep` loop) sweeps and
|
`Ok(false)` means the deadline passed and the conn actor closes. Per-conn
|
||||||
`request_stop`s expired conns. Works today, zero smarm changes.
|
semantics, no activity stamping, no sweep ticker. Semantics note: a timed
|
||||||
Granularity = sweep interval; conns must stamp activity (one cast per
|
read wait enforces *idle-between-reads* — `keep_alive_timeout` between
|
||||||
request — measurable but small).
|
requests, and slowloris dies because each stalled read has a deadline.
|
||||||
- **(b) `wait_readable_timeout` in smarm.** A first-of(fd, timer) wait.
|
`request_timeout` as a whole-request wall clock is NOT what this gives;
|
||||||
The v0.7 epoch-stamped consuming-unpark machinery exists precisely for
|
decide in implementation whether to track a per-request deadline across
|
||||||
multi-arm waits (`select_timeout` is the channel-flavoured proof), but
|
reads (cheap: one `Instant` in the conn loop, pass the min of both
|
||||||
fd arms aren't `Selectable` today — this is real smarm work in
|
budgets to each wait) or redefine `request_timeout` as per-read. Leaning
|
||||||
`wait_fd`/io thread. Cleaner per-conn semantics, benefits every future
|
the `Instant`: it keeps the advertised config honest.
|
||||||
smarm io consumer, no reaper bookkeeping.
|
|
||||||
|
|
||||||
Leaning **(a) now, (b) later**: (a) ships the security fix this cycle and
|
urus is the first real consumer of the timed wrappers — note the RFC 008
|
||||||
the registry exists anyway; (b) becomes a smarm roadmap item whose landing
|
reviewer flag (`wait_fd` wraps in `NoPreempt`, select arms don't); the
|
||||||
lets the reaper's stamps go away. But if you'd rather not build throwaway:
|
crud example under load doubles as that confirmation.
|
||||||
(b) first is defensible — say the word.
|
|
||||||
|
|
||||||
### 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`
|
- Registry names: `urus.server`, `urus.listener.{n}` via `smarm::register`
|
||||||
(debuggability; `whereis` in tests).
|
(debuggability; `whereis` in tests).
|
||||||
- README: rewrite Roadmap section to point here; document `Handle`.
|
- 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
|
The slipped v1 goal, and the WebSocket prerequisite (an upgraded connection
|
||||||
is "a response that never ends").
|
is "a response that never ends").
|
||||||
|
|
||||||
1. `RespBody::Stream(...)` — handler hands back a writer callback or a
|
1. `RespBody::Stream(StreamBody)` ✅ — **decided: pull** (user call at the
|
||||||
`Receiver<Vec<u8>>`; conn actor switches to chunked transfer-encoding
|
v0.3 design fork): the handler hands back a `smarm::Receiver<Vec<u8>>`
|
||||||
(HTTP/1.1) and pumps until the stream closes. Keep-alive after a
|
and the producer is an actor the handler spawned; the conn actor pumps
|
||||||
completed chunked response.
|
(`pump_stream`) and keeps sole ownership of the socket and write
|
||||||
2. Chunked **request** bodies (`parser.rs` currently returns 411) — same
|
deadlines. The recv() park between chunks is stoppable by the draining
|
||||||
cycle, the codec knowledge is fresh.
|
registry, so infinite streams die at the drain deadline with no
|
||||||
3. SSE sugar: `Conn::sse()` → an `EventSender` (`.send(event, data)`),
|
polling. HTTP/1.1: chunked TE, keep-alive after the terminator;
|
||||||
sets `content-type: text/event-stream`, disables buffering, heartbeats
|
HTTP/1.0: raw bytes, forced close, EOF-delimited. End of stream =
|
||||||
via comment lines on a timer. An SSE conn is just a Stream body that
|
every Sender dropped = 0-chunk. Timeout split (handoff Q3, as
|
||||||
the reaper exempts from `request_timeout` (keep-alive ping instead).
|
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
|
1. **Handshake** ✅ (49538cc) — `Conn::upgrade()` (handler arg comes
|
||||||
the pipeline; `Conn::upgrade(handler)` short-circuits — conn actor
|
with chunk 3): §4.2.1 validation, 101 + accept key, marker field on
|
||||||
validates `Sec-WebSocket-Key`, emits 101, exits the HTTP loop.
|
`Conn`, actor leaves the HTTP loop (fd drops until chunk 3 takes
|
||||||
(SHA-1 + base64 needed; vendor a tiny SHA-1 rather than pulling a
|
over). Rejections stay plain HTTP (400 / 426 + version header) with
|
||||||
crate — urus has two deps and it should stay that way.)
|
keep-alive intact. **Dep decision changed at the design pass**: SHA-1
|
||||||
2. **Frame codec**: RFC 6455 framing — fragmentation, masking (client
|
is `sha1_smol` (zero transitive deps) — dep #3 SPENT, no hand-rolled
|
||||||
frames are always masked), control frames (ping/pong/close), 125-byte
|
crypto (user call). The v0.6 JSON question is therefore a dep #4
|
||||||
control-payload rule, close-code handshake.
|
question now. Base64 stays in-tree, encode-only.
|
||||||
3. **Duplex topology** (the design decision from the assessment):
|
2. **Frame codec** ✅ (2869153) — pure `ws::frame`: incremental
|
||||||
- **(a) Two actors, dup'd fd**: reader actor on the dup, writer actor
|
`decode` (mask-required server mode), minimal-length + RSV + control
|
||||||
on the original, writer owns a `Receiver<Frame>`; the user handler
|
rules enforced, header-derived cap check before payload buffering,
|
||||||
talks to both via channels. Proven trick (listener pool), works today.
|
close-code wire validation, `Assembler` for fragmentation with
|
||||||
- **(b) One actor, smarm first-of(fd-readable, channel)**: needs fd
|
whole-message UTF-8 check, `FrameError`→close-code map
|
||||||
arms in select — the same smarm work as v0.2-3(b). Simpler topology,
|
(1002/1009/1007). §5.7 examples pinned verbatim.
|
||||||
no dup, but blocked on smarm.
|
3. **Duplex wiring** ✅ — topology decision: **(b) one actor, RFC 008
|
||||||
Default (a); revisit if (b) lands for timeouts anyway.
|
fd arms** (urus exists to put smarm through its paces; first fd-arm
|
||||||
4. Handler API sketch (settle in design pass): trait with
|
consumer is the point). As landed:
|
||||||
`on_message/on_close` + a `WsSender` handle — gen_server-shaped, so a
|
- `ws::duplex::run_duplex` replaces the HTTP loop after the 101:
|
||||||
ws connection *is* an actor you can monitor, link, and register.
|
`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 —
|
`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)`,
|
- **Generic `PubSub<M>`** per instance (Q1); heterogeneous buses are an
|
||||||
`broadcast_from(self, topic, msg)`.
|
app-side enum. `Arc<M>` payloads: one allocation per broadcast.
|
||||||
- Topic table: gen_server owning `HashMap<Topic, Vec<(Pid, Sender<Arc<M>>)>>`;
|
- **`subscribe(topic) -> Receiver<Arc<M>>`** (Q2), fresh channel per
|
||||||
shard N ways by topic hash if the single server measures hot (don't
|
subscription; `subscribe_as(pid, ..)` pins lifetime to an explicit pid
|
||||||
pre-shard; bench first — smarm channel sends are cheap).
|
for relay shapes. Sender-passing fan-in (`subscribe_with`) deliberately
|
||||||
- Dead subscriber cleanup via `monitor` + `handle_down` (this is exactly
|
deferred — compatible addition.
|
||||||
what 06-10's gen_server `handle_down` was built for).
|
- **Unbounded mailboxes** (Q3): broadcast never blocks the table. Cleanup
|
||||||
- `Arc<M>` payloads: one allocation per broadcast, not per subscriber —
|
is monitor `Down` (eager, on subscriber death) + prune-on-send-failure
|
||||||
the shared-heap advantage over BEAM, take it.
|
(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
|
**Discoveries this cycle:**
|
||||||
module now, candidate for crate extraction later.
|
- `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,
|
## v0.6 — Channels — DONE (2026-06-12)
|
||||||
PubSub underneath.
|
|
||||||
|
|
||||||
- `Channel` trait: `join(topic, payload, socket)`, `handle_in(event,
|
Join/leave/event protocol over WebSocket transport, PubSub underneath.
|
||||||
payload, socket)`, `handle_out`, `terminate`.
|
Design decisions ratified pre-code (2026-06-12):
|
||||||
- Topic router (`"room:*"` patterns) → channel actor per (conn, topic),
|
|
||||||
spawned under the ws connection, linked so conn death reaps channels.
|
**Feature flags.** Two additive features:
|
||||||
- Wire format: Phoenix V2 JSON serializer
|
- `"channels"` — the `Channel` trait, `TopicRouter` trait, `PrefixRouter`,
|
||||||
(`[join_ref, ref, topic, event, payload]`) — free interop with
|
`ChannelSocket`, session machinery. Zero new deps; pubsub is already
|
||||||
phoenix.js clients is the killer feature; needs a JSON dep or a
|
always-on (it only exists because channels needs it).
|
||||||
hand-rolled mini-codec (decision point — this would be dep #3).
|
- `"phoenix"` — implies `"channels"` + `serde`/`serde_json` + the V2 frame
|
||||||
- Presence: explicitly out of scope until distribution exists somewhere.
|
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
|
## Later / icebox
|
||||||
|
|
||||||
- **HTTP/2** — spec §2.2 already designs the stream-actor demux (zero-copy
|
- **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`),
|
- **SSE ergonomics round 2** — auto-reconnect support (`Last-Event-ID`),
|
||||||
event-id bookkeeping, backpressure policy on slow consumers.
|
event-id bookkeeping, backpressure policy on slow consumers.
|
||||||
- **TLS** — still deferred; acceptor design still must not preclude it.
|
- **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.
|
- **Middleware ecosystem** — compression, static files, auth plugs.
|
||||||
- **Bench suite** — `urus-bench-spec.md` exists in the artefact store;
|
- **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,
|
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");
|
||||||
|
}
|
||||||
+59
-5
@@ -25,7 +25,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use smarm::{channel, Sender};
|
use smarm::{channel, Sender};
|
||||||
use std::sync::OnceLock;
|
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
|
// 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;
|
let mut next_id: u64 = users.iter().map(|u| u.id).max().unwrap_or(0) + 1;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let req = match rx.recv() {
|
// recv with a timeout rather than a bare recv: the store must be
|
||||||
Ok(r) => r,
|
// 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
|
Err(_) => return, // all senders dropped
|
||||||
};
|
};
|
||||||
match req {
|
match req {
|
||||||
@@ -165,6 +176,12 @@ fn persist(users: &[User]) {
|
|||||||
|
|
||||||
static STORE_TX: OnceLock<Sender<Request>> = OnceLock::new();
|
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> {
|
fn store() -> &'static Sender<Request> {
|
||||||
STORE_TX.get_or_init(|| {
|
STORE_TX.get_or_init(|| {
|
||||||
let (tx, rx) = channel::<Request>();
|
let (tx, rx) = channel::<Request>();
|
||||||
@@ -187,6 +204,28 @@ fn parse_id(s: &str) -> Option<u64> {
|
|||||||
// Handlers
|
// 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 {
|
fn list(conn: Conn, _next: Next) -> Conn {
|
||||||
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
let (tx, rx) = channel::<(u16, Vec<u8>)>();
|
||||||
store().send(Request::List { reply: tx }).ok();
|
store().send(Request::List { reply: tx }).ok();
|
||||||
@@ -261,10 +300,25 @@ fn main() {
|
|||||||
.post( "/users", create)
|
.post( "/users", create)
|
||||||
.get( "/users/:id", get_one)
|
.get( "/users/:id", get_one)
|
||||||
.put( "/users/:id", update)
|
.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());
|
let cfg = Config::new("127.0.0.1:8080".parse().unwrap());
|
||||||
println!("urus-crud: DB at {DB_PATH}");
|
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
|
||||||
|
}
|
||||||
+119
-6
@@ -146,21 +146,73 @@ impl Body {
|
|||||||
// RespBody — what plugs put on the wire.
|
// RespBody — what plugs put on the wire.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Enum, not a trait, so the connection actor can pattern-match. Leaves room
|
// Enum, not a trait, so the connection actor can pattern-match.
|
||||||
// for future variants like Sse, Stream, Chunked without touching the plug
|
//
|
||||||
// API.
|
// `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 {
|
pub enum RespBody {
|
||||||
Empty,
|
Empty,
|
||||||
Bytes(Vec<u8>),
|
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 {
|
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 {
|
pub fn len_hint(&self) -> usize {
|
||||||
match self {
|
match self {
|
||||||
RespBody::Empty => 0,
|
RespBody::Empty => 0,
|
||||||
RespBody::Bytes(b) => b.len(),
|
RespBody::Bytes(b) => b.len(),
|
||||||
|
RespBody::Stream(_) => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +308,11 @@ pub struct Conn {
|
|||||||
pub params: Params,
|
pub params: Params,
|
||||||
pub assigns: Assigns,
|
pub assigns: Assigns,
|
||||||
pub halted: bool,
|
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 {
|
impl Conn {
|
||||||
@@ -277,6 +334,8 @@ impl Conn {
|
|||||||
params: Params::new(),
|
params: Params::new(),
|
||||||
assigns: Assigns::new(),
|
assigns: Assigns::new(),
|
||||||
halted: false,
|
halted: false,
|
||||||
|
|
||||||
|
upgrade: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +365,52 @@ impl Conn {
|
|||||||
self.halted = true;
|
self.halted = true;
|
||||||
self
|
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 {
|
impl Default for Conn {
|
||||||
@@ -326,3 +431,11 @@ impl From<&'static str> for RespBody {
|
|||||||
impl From<&[u8]> for RespBody {
|
impl From<&[u8]> for RespBody {
|
||||||
fn from(s: &[u8]) -> Self { RespBody::Bytes(s.to_vec()) }
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+488
-36
@@ -13,14 +13,17 @@
|
|||||||
//! `wait_readable` between bytes and `wait_writable` during slow writes;
|
//! `wait_readable` between bytes and `wait_writable` during slow writes;
|
||||||
//! during those parks, other connection actors progress freely.
|
//! 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::net::OwnedFd;
|
||||||
use crate::parser::{self, ParseError};
|
use crate::parser::{self, ParseError};
|
||||||
use crate::plug::Pipeline;
|
use crate::plug::Pipeline;
|
||||||
|
|
||||||
|
use smarm::ServerRef;
|
||||||
|
|
||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
use std::os::fd::RawFd;
|
use std::os::fd::RawFd;
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Limits
|
// Limits
|
||||||
@@ -38,7 +41,31 @@ pub struct ConnLimits {
|
|||||||
/// Hard cap on Content-Length we'll accept. 16 MiB is enough for a CRUD
|
/// Hard cap on Content-Length we'll accept. 16 MiB is enough for a CRUD
|
||||||
/// example; configurable in `Config`.
|
/// example; configurable in `Config`.
|
||||||
pub max_body_bytes: usize,
|
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,
|
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 {
|
impl Default for ConnLimits {
|
||||||
@@ -49,6 +76,10 @@ impl Default for ConnLimits {
|
|||||||
max_head_bytes: 64 * 1024,
|
max_head_bytes: 64 * 1024,
|
||||||
max_body_bytes: 16 * 1024 * 1024,
|
max_body_bytes: 16 * 1024 * 1024,
|
||||||
keep_alive_timeout: Duration::from_secs(60),
|
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.
|
// 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
|
// The OwnedFd cleans up via Drop on any exit path (panic, error, or
|
||||||
// normal close). No explicit close calls below.
|
// normal close). No explicit close calls below.
|
||||||
let raw = fd.as_raw();
|
let raw = fd.as_raw();
|
||||||
let mut buf: Vec<u8> = Vec::with_capacity(limits.initial_read_buf);
|
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 {
|
loop {
|
||||||
// ----- 1. Read until we have a full request head. -----
|
// ----- 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,
|
Ok(p) => p,
|
||||||
Err(ReadHeadErr::ClientClosed) => {
|
Err(ReadHeadErr::ClientClosed) => {
|
||||||
// Clean EOF between requests (or before any request). Normal.
|
// Clean EOF between requests (or before any request). Normal.
|
||||||
return;
|
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(_)) => {
|
Err(ReadHeadErr::Io(_)) => {
|
||||||
// Network error or timeout. Best-effort close; we're done.
|
// Network error. Best-effort close; we're done.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Err(ReadHeadErr::Parse(e)) => {
|
Err(ReadHeadErr::Parse(e)) => {
|
||||||
emit_error_response(raw, &e);
|
emit_error_response(raw, &e, Instant::now() + limits.write_timeout);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let _ = registry.cast(Cast::ConnBusy(me));
|
||||||
|
|
||||||
// ----- 2. Read body. -----
|
// ----- 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);
|
let body_len = parsed.content_length.unwrap_or(0);
|
||||||
if body_len > limits.max_body_bytes {
|
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;
|
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
|
// body. RFC 7231 §5.1.1. We don't gate on app logic here; v1 always
|
||||||
// accepts.
|
// accepts.
|
||||||
if parsed.expect_100 {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let body = match read_body(raw, &mut buf, parsed.head_len, body_len) {
|
// `consumed_past_head`: how many RAW bytes of `buf` past the head
|
||||||
Ok(b) => b,
|
// this request's body occupied — for chunked bodies that is framing
|
||||||
Err(_) => return,
|
// 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;
|
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 {
|
let mut response_conn = match result {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => {
|
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
|
// Compose a 500 manually; the original Conn was moved into
|
||||||
// the closure.
|
// the closure.
|
||||||
let mut c = Conn::new();
|
let mut c = Conn::new();
|
||||||
@@ -132,20 +240,71 @@ pub fn run_connection(fd: OwnedFd, pipeline: Pipeline, limits: ConnLimits) {
|
|||||||
.put_body(RespBody::Empty);
|
.put_body(RespBody::Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 4. Write the response. -----
|
// ----- 3.5 WebSocket upgrade. -----
|
||||||
let bytes = parser::serialise_response(&response_conn, keep_alive);
|
// An accepted handshake (payload + 101) ends HTTP on this socket:
|
||||||
if write_all(raw, &bytes).is_err() {
|
// 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;
|
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. -----
|
// ----- 5. Loop or close. -----
|
||||||
if !keep_alive {
|
if !keep_alive {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the request bytes (head + body) from `buf`; anything past
|
// Response is on the wire; nothing is owed. Going idle here makes
|
||||||
// them is the start of the next pipelined request.
|
// us stoppable by a draining registry while we park for the next
|
||||||
let consumed = head_len + body_len;
|
// 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);
|
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
|
#[allow(dead_code)] // io::Error is captured for future logging
|
||||||
enum ReadHeadErr {
|
enum ReadHeadErr {
|
||||||
ClientClosed,
|
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),
|
Io(io::Error),
|
||||||
Parse(ParseError),
|
Parse(ParseError),
|
||||||
}
|
}
|
||||||
@@ -164,17 +329,41 @@ enum ReadHeadErr {
|
|||||||
/// Read until `parse_head` succeeds or fails definitively. `buf` may already
|
/// Read until `parse_head` succeeds or fails definitively. `buf` may already
|
||||||
/// contain leftover bytes from a previous keep-alive cycle; we try to parse
|
/// contain leftover bytes from a previous keep-alive cycle; we try to parse
|
||||||
/// those before reading more from the socket.
|
/// 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(
|
fn read_head(
|
||||||
fd: RawFd,
|
fd: RawFd,
|
||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
limits: &ConnLimits,
|
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 {
|
loop {
|
||||||
// Try to parse what we already have. On the first iteration of a
|
// Try to parse what we already have. On the first iteration of a
|
||||||
// fresh keep-alive cycle, `buf` may already hold the next request.
|
// fresh keep-alive cycle, `buf` may already hold the next request.
|
||||||
if !buf.is_empty() {
|
if !buf.is_empty() {
|
||||||
match parser::parse_head(buf, limits.max_headers) {
|
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(ParseError::Incomplete) => {} // need more bytes
|
||||||
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
Err(e) => return Err(ReadHeadErr::Parse(e)),
|
||||||
}
|
}
|
||||||
@@ -184,10 +373,24 @@ fn read_head(
|
|||||||
return Err(ReadHeadErr::Parse(ParseError::TooManyHeaders));
|
return Err(ReadHeadErr::Parse(ParseError::TooManyHeaders));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read more.
|
// Read more, bounded by whichever budget is active.
|
||||||
match read_some(fd, buf, limits.initial_read_buf) {
|
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(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)),
|
Err(e) => return Err(ReadHeadErr::Io(e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,6 +405,7 @@ fn read_body(
|
|||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
head_len: usize,
|
head_len: usize,
|
||||||
body_len: usize,
|
body_len: usize,
|
||||||
|
deadline: Instant,
|
||||||
) -> io::Result<Vec<u8>> {
|
) -> io::Result<Vec<u8>> {
|
||||||
// Bytes already in `buf` past the head belong to the body.
|
// Bytes already in `buf` past the head belong to the body.
|
||||||
let already = buf.len().saturating_sub(head_len);
|
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());
|
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;
|
let mut total_read = already;
|
||||||
while total_read < body_len {
|
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(0) => return Err(io::Error::new(ErrorKind::UnexpectedEof, "client closed during body")),
|
||||||
Ok(n) => total_read += n,
|
Ok(n) => total_read += n,
|
||||||
Err(e) => return Err(e),
|
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
|
// Decodes `Transfer-Encoding: chunked` from `buf[head_len..]`, reading more
|
||||||
// last io error.
|
// 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
|
// Loop to absorb EAGAIN: a readable wakeup followed by EAGAIN is
|
||||||
// possible (signal race, etc). Re-park and retry rather than returning
|
// 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 {
|
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();
|
let start = buf.len();
|
||||||
buf.resize(start + chunk, 0);
|
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() {
|
while !buf.is_empty() {
|
||||||
// Park on writability before each syscall.
|
// Park on writability before each syscall, bounded by the budget.
|
||||||
smarm::wait_writable(fd)?;
|
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 {
|
let n = unsafe {
|
||||||
libc::write(fd, buf.as_ptr() as *const _, buf.len())
|
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(())
|
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.
|
// 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 {
|
let resp: &[u8] = match err {
|
||||||
ParseError::TooManyHeaders =>
|
ParseError::TooManyHeaders =>
|
||||||
b"HTTP/1.1 431 Request Header Fields Too Large\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
|
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",
|
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 parser;
|
||||||
pub mod net;
|
pub mod net;
|
||||||
pub mod conn_actor;
|
pub mod conn_actor;
|
||||||
|
pub mod conn_registry;
|
||||||
pub mod serve;
|
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.
|
// 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 plug::{Next, Pipeline, Plug};
|
||||||
pub use router::Router;
|
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.
|
// becomes the unique closer.
|
||||||
unsafe impl Send for OwnedFd {}
|
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
|
// bind_and_listen
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+132
-20
@@ -4,14 +4,13 @@
|
|||||||
//! copy, battle-tested). Body framing, keep-alive logic and response writing
|
//! copy, battle-tested). Body framing, keep-alive logic and response writing
|
||||||
//! are ours.
|
//! are ours.
|
||||||
//!
|
//!
|
||||||
//! v1 body framing:
|
//! Body framing:
|
||||||
//! - `Content-Length: N` — read exactly N bytes.
|
//! - `Content-Length: N` — read exactly N bytes.
|
||||||
//! - No body header — empty body.
|
//! - No body header — empty body.
|
||||||
//! - `Transfer-Encoding: chunked` — deferred. Returns ParseError::Unsupported
|
//! - `Transfer-Encoding: chunked` (HTTP/1.1) — flagged in `ParsedHead`;
|
||||||
//! and the connection actor responds 411 Length Required + close.
|
//! the connection actor decodes incrementally (`read_chunked_body`).
|
||||||
//!
|
//! Chunked + Content-Length together, or chunked on HTTP/1.0, is
|
||||||
//! Keep it stupid simple. Chunked decoding lands when something actually
|
//! Malformed (request-smuggling ambiguity; RFC 7230 §3.3.3).
|
||||||
//! requests it.
|
|
||||||
|
|
||||||
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
|
use crate::conn::{Body, Conn, HeaderMap, HttpVersion, Method, RespBody};
|
||||||
|
|
||||||
@@ -30,8 +29,9 @@ pub enum ParseError {
|
|||||||
TooManyHeaders,
|
TooManyHeaders,
|
||||||
/// `Content-Length` header could not be parsed as an integer.
|
/// `Content-Length` header could not be parsed as an integer.
|
||||||
BadContentLength,
|
BadContentLength,
|
||||||
/// A wire feature we haven't implemented yet (e.g. chunked encoding).
|
/// A wire feature we haven't implemented. Currently unconstructed
|
||||||
/// Connection actor responds 411 + close.
|
/// (chunked decoding landed in v0.3); kept for future unsupported
|
||||||
|
/// framings. Connection actor responds 411 + close.
|
||||||
Unsupported,
|
Unsupported,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +50,10 @@ pub struct ParsedHead {
|
|||||||
pub version: HttpVersion,
|
pub version: HttpVersion,
|
||||||
pub headers: HeaderMap,
|
pub headers: HeaderMap,
|
||||||
pub content_length: Option<usize>,
|
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 keep_alive: bool,
|
||||||
pub expect_100: bool,
|
pub expect_100: bool,
|
||||||
}
|
}
|
||||||
@@ -130,7 +134,13 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
}
|
}
|
||||||
|
|
||||||
if chunked {
|
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:
|
// Keep-alive logic, RFC 7230 §6.3:
|
||||||
@@ -149,6 +159,7 @@ pub fn parse_head(buf: &[u8], max_headers: usize) -> Result<ParsedHead, ParseErr
|
|||||||
version,
|
version,
|
||||||
headers,
|
headers,
|
||||||
content_length,
|
content_length,
|
||||||
|
chunked,
|
||||||
keep_alive,
|
keep_alive,
|
||||||
expect_100,
|
expect_100,
|
||||||
})
|
})
|
||||||
@@ -187,8 +198,9 @@ pub fn build_conn(head: ParsedHead, body: Body) -> Conn {
|
|||||||
|
|
||||||
pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
||||||
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
|
// Pre-size: status line ~30 + headers ~50/each + body. Good enough.
|
||||||
let body_len = conn.resp_body.len_hint();
|
let body_len = conn.resp_body.len_hint();
|
||||||
let mut out = Vec::with_capacity(64 + conn.resp_headers.len() * 40 + body_len);
|
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);
|
let status = conn.status.unwrap_or(200);
|
||||||
let reason = reason_phrase(status);
|
let reason = reason_phrase(status);
|
||||||
@@ -202,11 +214,17 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
|
|||||||
out.extend_from_slice(b"\r\n");
|
out.extend_from_slice(b"\r\n");
|
||||||
|
|
||||||
// User headers — written first so subsequent injection can skip them.
|
// 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_content_length = false;
|
||||||
let mut wrote_connection = false;
|
let mut wrote_connection = false;
|
||||||
|
|
||||||
for (name, value) in conn.resp_headers.iter() {
|
for (name, value) in conn.resp_headers.iter() {
|
||||||
match name {
|
match name {
|
||||||
|
"content-length" if is_stream => continue,
|
||||||
|
"transfer-encoding" if is_stream => continue,
|
||||||
"content-length" => wrote_content_length = true,
|
"content-length" => wrote_content_length = true,
|
||||||
"connection" => wrote_connection = 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");
|
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(b"content-length: ");
|
||||||
out.extend_from_slice(body_len.to_string().as_bytes());
|
out.extend_from_slice(body_len.to_string().as_bytes());
|
||||||
out.extend_from_slice(b"\r\n");
|
out.extend_from_slice(b"\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
if !wrote_connection && !keep_alive {
|
if !wrote_connection {
|
||||||
out.extend_from_slice(b"connection: close\r\n");
|
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");
|
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 {
|
match &conn.resp_body {
|
||||||
RespBody::Empty => {}
|
RespBody::Empty => {}
|
||||||
RespBody::Bytes(b) => out.extend_from_slice(b),
|
RespBody::Bytes(b) => out.extend_from_slice(b),
|
||||||
|
RespBody::Stream(_) => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
out
|
out
|
||||||
@@ -340,11 +380,28 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 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) {
|
match parse_head(req, 64) {
|
||||||
Err(ParseError::Unsupported) => {}
|
Err(ParseError::Malformed) => {}
|
||||||
_ => panic!("expected Unsupported for chunked"),
|
_ => 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"));
|
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]
|
#[test]
|
||||||
fn serialise_user_content_length_is_respected() {
|
fn serialise_user_content_length_is_respected() {
|
||||||
let conn = Conn::new().put_status(200)
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+273
-30
@@ -13,12 +13,17 @@
|
|||||||
//! waiting on "the same fd").
|
//! waiting on "the same fd").
|
||||||
|
|
||||||
use crate::conn_actor::{run_connection, ConnLimits};
|
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::net::{accept_nonblocking, bind_and_listen, OwnedFd};
|
||||||
use crate::plug::Pipeline;
|
use crate::plug::Pipeline;
|
||||||
|
|
||||||
|
use smarm::{ChildSpec, OneForOne, Restart, ServerRef, Strategy};
|
||||||
|
|
||||||
use std::io::{self, ErrorKind};
|
use std::io::{self, ErrorKind};
|
||||||
use std::net::{SocketAddr, ToSocketAddrs};
|
use std::net::{SocketAddr, ToSocketAddrs};
|
||||||
use std::os::fd::RawFd;
|
use std::os::fd::RawFd;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -33,7 +38,21 @@ pub struct Config {
|
|||||||
pub max_header_count: usize,
|
pub max_header_count: usize,
|
||||||
pub read_buf_size: usize,
|
pub read_buf_size: usize,
|
||||||
pub request_timeout: Duration,
|
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,
|
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
|
/// 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
|
/// (one per CPU). Set this to a small fixed number in tests so multiple
|
||||||
/// concurrent test servers don't oversubscribe the host.
|
/// concurrent test servers don't oversubscribe the host.
|
||||||
@@ -53,7 +72,11 @@ impl Config {
|
|||||||
max_header_count: 64,
|
max_header_count: 64,
|
||||||
read_buf_size: 8 * 1024,
|
read_buf_size: 8 * 1024,
|
||||||
request_timeout: Duration::from_secs(30),
|
request_timeout: Duration::from_secs(30),
|
||||||
|
write_timeout: Duration::from_secs(30),
|
||||||
max_body_bytes: 16 * 1024 * 1024,
|
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,
|
scheduler_threads: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,6 +88,10 @@ impl Config {
|
|||||||
max_head_bytes: 64 * 1024,
|
max_head_bytes: 64 * 1024,
|
||||||
max_body_bytes: self.max_body_bytes,
|
max_body_bytes: self.max_body_bytes,
|
||||||
keep_alive_timeout: self.keep_alive_timeout,
|
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
|
// 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();
|
let fd = listener.as_raw();
|
||||||
|
|
||||||
loop {
|
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) {
|
match accept_nonblocking(fd) {
|
||||||
Ok(client) => {
|
Ok(client) => {
|
||||||
// Hand the fd off to a new connection actor. spawn() is
|
// 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.
|
// shared lock.
|
||||||
let p = pipeline.clone();
|
let p = pipeline.clone();
|
||||||
let l = limits;
|
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 => {
|
Err(e) if e.kind() == ErrorKind::WouldBlock => {
|
||||||
// No pending connection. Park until the listener is
|
// No pending connection. Park until the listener is
|
||||||
// readable again, then retry.
|
// readable or the tick elapses; either way we come back
|
||||||
if let Err(we) = smarm::wait_readable(fd) {
|
// around through the shutdown-flag check above.
|
||||||
// epoll registration failed — fatal for this listener.
|
match smarm::wait_readable_timeout(fd, LISTENER_TICK) {
|
||||||
eprintln!("urus: listener wait_readable failed: {we}");
|
Ok(_ready) => {} // ready or tick — loop re-checks, retries accept
|
||||||
return;
|
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 => {
|
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
|
// 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
|
// `Config::default()`). The root actor starts the connection registry and
|
||||||
// graceful-shutdown signal in v1; the runtime exits when all actors exit,
|
// a one-for-one supervisor over the listener pool, then blocks on the
|
||||||
// which they don't (listener loops are infinite). Ctrl-C is your friend.
|
// 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)?;
|
let listener = bind_and_listen(config.addr)?;
|
||||||
println!("urus: listening on {}", config.addr);
|
println!("urus: listening on {}", config.addr);
|
||||||
|
|
||||||
// We want one connection-actor-spawning loop per listener pool slot.
|
// One connection-actor-spawning loop per listener pool slot. Each gets
|
||||||
// Each gets its own dup'd fd so epoll registrations don't collide.
|
// 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);
|
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 {
|
for _ in 1..config.listener_pool {
|
||||||
let dup = dup_fd(listener_fds[0].as_raw())?;
|
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 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 {
|
let smarm_cfg = match config.scheduler_threads {
|
||||||
Some(n) => smarm::Config::exact(n),
|
Some(n) => smarm::Config::exact(n),
|
||||||
None => smarm::Config::default(),
|
None => smarm::Config::default(),
|
||||||
};
|
};
|
||||||
let rt = smarm::init(smarm_cfg);
|
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 || {
|
rt.run(move || {
|
||||||
let n = listener_fds.len();
|
// Registry first: listeners and conns cast into it from birth.
|
||||||
let mut handles = Vec::with_capacity(n);
|
let registry = conn_registry::start();
|
||||||
|
|
||||||
|
let mut sup = OneForOne::new().strategy(Strategy::OneForOne);
|
||||||
for (i, lfd) in listener_fds.into_iter().enumerate() {
|
for (i, lfd) in listener_fds.into_iter().enumerate() {
|
||||||
let p = pipeline.clone();
|
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);
|
println!("urus: listener {} starting", i);
|
||||||
listener_loop(lfd, p, limits);
|
// Named for whereis-style introspection. On a restart the
|
||||||
});
|
// old binding points at a dead pid; smarm's registry
|
||||||
handles.push(h);
|
// 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
|
// Default intensity (3 per 5s) applies; a listener crash-looping
|
||||||
// never exit on their own in v1.
|
// faster than that trips the cap and tears the pool down — loud
|
||||||
for h in handles {
|
// failure over a zombie server.
|
||||||
let _ = h.join();
|
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(())
|
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.
|
// 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