Topology (b) as agreed: ONE actor per connection via smarm RFC 008 fd
arms. After the 101, ws::duplex::run_duplex replaces the HTTP loop:
try_select(&[&outbound_rx, &FdArm::readable(fd)]) per iteration,
outbound at index 0 (priority order = owed writes drain before reads;
honest backpressure). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.
Handler API (deferred questions, as answered this session):
- WsHandler { on_message, on_close } runs IN the conn actor's select
loop; concurrency = spawn an actor with a WsSender clone (the SSE
producer pattern). Conn::upgrade(self, handler) flat signature;
WsUpgrade grows from marker to Box<dyn WsHandler> payload.
- WsSender: Clone; send/text/binary/ping/close -> Err(WsClosed).
Unbounded channel like SSE; slow client bounded by write_timeout.
- Control frames invisible v1: auto-pong inline (5.5.2), peer close
auto-echoed (5.5.1; server closes TCP first per 7.1.1) surfacing
only as on_close(Some(code), reason); wordless endings (EOF, write
failure, drain timeout) = on_close(None, ""). on_ping/on_pong can
land later as default methods, non-breaking.
- Server-initiated close (WsSender::close, FrameError->1002/1009/1007,
handler panic->1011): close out, bounded drain (one write_timeout
budget) for the peer echo, data discarded (1.4). Handler panics
re-raise smarm's stop sentinel first (the pipeline catch_unwind
dance, replicated).
- Caps: Config.max_frame_payload (1 MiB) / max_message_bytes (4 MiB).
Conn actor: 101 branch now hands fd + leftover buf (pipelined first
frame carries over; tested) + boxed handler to run_duplex; registry
entry stays Busy for the ws lifetime, so graceful shutdown force-stops
the conn out of the select park at the drain deadline (tested — the
in-runtime request_stop wake covers select parks too).
Tests: chunk-1 EOF test rewritten into a 9-test duplex suite (echo,
pipelined first frame, ping/pong, both close directions, 1002 unmasked,
1009 header-cap, fragmentation with interleaved ping, shutdown
force-stop). Suite 59u+40i+2d. Hammer: 35x lifecycle+ws subset + 3 full
+ 1 full under smarm-trace, all green; no AlreadyExists out of
try_select (the fresh eager-cleanup path held). Validated against
python websocket-client (echo, pong payload, close 1000).
examples/ws_echo.rs: /echo in-actor + /clock producer-spawning.
442 lines
14 KiB
Rust
442 lines
14 KiB
Rust
//! `Conn` — the value that flows through the entire plug pipeline.
|
|
//!
|
|
//! Carries request data, response state being built, and arbitrary user
|
|
//! data. Owned and moved at each step. Plugs are unaware of the underlying
|
|
//! HTTP version (v1.1 today, /2 later).
|
|
|
|
use std::any::{Any, TypeId};
|
|
use std::collections::HashMap;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Method
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum Method {
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Patch,
|
|
Head,
|
|
Options,
|
|
Other(String),
|
|
}
|
|
|
|
impl Method {
|
|
pub fn parse(s: &str) -> Self {
|
|
match s {
|
|
"GET" => Method::Get,
|
|
"POST" => Method::Post,
|
|
"PUT" => Method::Put,
|
|
"DELETE" => Method::Delete,
|
|
"PATCH" => Method::Patch,
|
|
"HEAD" => Method::Head,
|
|
"OPTIONS" => Method::Options,
|
|
_ => Method::Other(s.to_string()),
|
|
}
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
match self {
|
|
Method::Get => "GET",
|
|
Method::Post => "POST",
|
|
Method::Put => "PUT",
|
|
Method::Delete => "DELETE",
|
|
Method::Patch => "PATCH",
|
|
Method::Head => "HEAD",
|
|
Method::Options => "OPTIONS",
|
|
Method::Other(s) => s.as_str(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HttpVersion
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum HttpVersion {
|
|
Http10,
|
|
Http11,
|
|
}
|
|
|
|
impl HttpVersion {
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
HttpVersion::Http10 => "HTTP/1.0",
|
|
HttpVersion::Http11 => "HTTP/1.1",
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HeaderMap — small-vec of (name, value) string pairs.
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Per spec: most requests have <20 headers, so a linear-scan Vec is cheaper
|
|
// than a hash map. Names are case-insensitive (HTTP/1.1), normalised to
|
|
// lowercase on insert.
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct HeaderMap {
|
|
inner: Vec<(String, String)>,
|
|
}
|
|
|
|
impl HeaderMap {
|
|
pub fn new() -> Self { Self::default() }
|
|
|
|
pub fn with_capacity(n: usize) -> Self {
|
|
Self { inner: Vec::with_capacity(n) }
|
|
}
|
|
|
|
/// Append a header. Does not deduplicate (HTTP allows repeats; e.g.
|
|
/// `Set-Cookie`).
|
|
pub fn append(&mut self, name: &str, value: impl Into<String>) {
|
|
self.inner.push((name.to_ascii_lowercase(), value.into()));
|
|
}
|
|
|
|
/// Replace any existing values for `name` with a single value.
|
|
pub fn set(&mut self, name: &str, value: impl Into<String>) {
|
|
let lower = name.to_ascii_lowercase();
|
|
self.inner.retain(|(n, _)| n != &lower);
|
|
self.inner.push((lower, value.into()));
|
|
}
|
|
|
|
/// First value for `name`, if any.
|
|
pub fn get(&self, name: &str) -> Option<&str> {
|
|
let lower = name.to_ascii_lowercase();
|
|
self.inner.iter()
|
|
.find(|(n, _)| n == &lower)
|
|
.map(|(_, v)| v.as_str())
|
|
}
|
|
|
|
/// Iterate over (name, value) pairs in insertion order.
|
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
|
self.inner.iter().map(|(n, v)| (n.as_str(), v.as_str()))
|
|
}
|
|
|
|
pub fn len(&self) -> usize { self.inner.len() }
|
|
pub fn is_empty(&self) -> bool { self.inner.is_empty() }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Body — request body, exposed as owned bytes.
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// v1: bodies are read in full by the connection actor before the pipeline
|
|
// runs, when `Content-Length` is set. Streaming and chunked encoding will
|
|
// come later; for now this is the simplest correct thing.
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Body {
|
|
bytes: Vec<u8>,
|
|
}
|
|
|
|
impl Body {
|
|
pub fn empty() -> Self { Self::default() }
|
|
pub fn from_bytes(b: Vec<u8>) -> Self { Self { bytes: b } }
|
|
pub fn as_bytes(&self) -> &[u8] { &self.bytes }
|
|
pub fn into_bytes(self) -> Vec<u8> { self.bytes }
|
|
pub fn len(&self) -> usize { self.bytes.len() }
|
|
pub fn is_empty(&self) -> bool { self.bytes.is_empty() }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RespBody — what plugs put on the wire.
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Enum, not a trait, so the connection actor can pattern-match.
|
|
//
|
|
// `Stream` is the pull-shaped streaming body (v0.3 design decision): the
|
|
// handler hands back a `smarm::Receiver<Vec<u8>>` — typically the read end
|
|
// of a channel whose `Sender` lives in a producer actor the handler
|
|
// spawned — and the connection actor pumps it onto the wire. The conn
|
|
// actor keeps sole ownership of the socket and of write deadlines; the
|
|
// producer never touches the fd. End-of-stream is signalled by dropping
|
|
// every `Sender` (the conn actor then emits the terminating 0-chunk).
|
|
// Empty `Vec`s are skipped by the pump (a zero-length chunk would
|
|
// terminate chunked framing early), so they are safe to send but useless.
|
|
|
|
pub enum RespBody {
|
|
Empty,
|
|
Bytes(Vec<u8>),
|
|
Stream(StreamBody),
|
|
}
|
|
|
|
/// A streaming response body. Construct with [`StreamBody::new`] (or
|
|
/// `RespBody::from(rx)`) and hand it to [`Conn::put_body`].
|
|
///
|
|
/// On HTTP/1.1 the connection actor sends it with
|
|
/// `Transfer-Encoding: chunked` and the connection stays reusable after
|
|
/// the stream completes. On HTTP/1.0 (no chunked framing) the bytes are
|
|
/// written raw and the connection closes at end of stream to delimit the
|
|
/// body.
|
|
pub struct StreamBody {
|
|
pub rx: smarm::Receiver<Vec<u8>>,
|
|
/// If set, the connection actor's pump waits for each chunk with
|
|
/// `recv_timeout(interval)` instead of a bare `recv`, and on expiry
|
|
/// writes `payload` as its own chunk and keeps waiting. This is how
|
|
/// SSE keep-alive comments work: liveness comes from the ping (a dead
|
|
/// client is detected when a heartbeat write stalls past
|
|
/// `write_timeout`), not from any request clock.
|
|
pub heartbeat: Option<(std::time::Duration, Vec<u8>)>,
|
|
}
|
|
|
|
impl StreamBody {
|
|
pub fn new(rx: smarm::Receiver<Vec<u8>>) -> Self {
|
|
Self { rx, heartbeat: None }
|
|
}
|
|
|
|
pub fn with_heartbeat(mut self, interval: std::time::Duration, payload: Vec<u8>) -> Self {
|
|
self.heartbeat = Some((interval, payload));
|
|
self
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for RespBody {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
RespBody::Empty => f.write_str("Empty"),
|
|
RespBody::Bytes(b) => f.debug_tuple("Bytes").field(&b.len()).finish(),
|
|
RespBody::Stream(_) => f.write_str("Stream(..)"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl RespBody {
|
|
/// Body length for fixed bodies; 0 for `Stream` (a stream has no
|
|
/// length up front — that is the point — and is never serialised with
|
|
/// a `content-length`).
|
|
pub fn len_hint(&self) -> usize {
|
|
match self {
|
|
RespBody::Empty => 0,
|
|
RespBody::Bytes(b) => b.len(),
|
|
RespBody::Stream(_) => 0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for RespBody {
|
|
fn default() -> Self { RespBody::Empty }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Params — path parameters extracted by the router.
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Small-vec semantics: routes have a handful of params; a Vec is the right
|
|
// shape. Empty until the router plug populates it.
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct Params {
|
|
inner: Vec<(String, String)>,
|
|
}
|
|
|
|
impl Params {
|
|
pub fn new() -> Self { Self::default() }
|
|
|
|
pub fn put(&mut self, name: impl Into<String>, value: impl Into<String>) {
|
|
self.inner.push((name.into(), value.into()));
|
|
}
|
|
|
|
pub fn get(&self, name: &str) -> Option<&str> {
|
|
self.inner.iter()
|
|
.find(|(n, _)| n == name)
|
|
.map(|(_, v)| v.as_str())
|
|
}
|
|
|
|
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
|
|
self.inner.iter().map(|(n, v)| (n.as_str(), v.as_str()))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Assigns — type-erased map for arbitrary user data.
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Same pattern as Phoenix's `conn.assigns`. The `Option<HashMap>` keeps the
|
|
// common case (no assigns) at zero allocation cost.
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct Assigns {
|
|
inner: Option<HashMap<TypeId, Box<dyn Any + Send>>>,
|
|
}
|
|
|
|
impl Assigns {
|
|
pub fn new() -> Self { Self::default() }
|
|
|
|
pub fn put<T: Any + Send>(&mut self, value: T) {
|
|
self.inner
|
|
.get_or_insert_with(HashMap::new)
|
|
.insert(TypeId::of::<T>(), Box::new(value));
|
|
}
|
|
|
|
pub fn get<T: Any + Send>(&self) -> Option<&T> {
|
|
self.inner
|
|
.as_ref()?
|
|
.get(&TypeId::of::<T>())?
|
|
.downcast_ref::<T>()
|
|
}
|
|
|
|
pub fn take<T: Any + Send>(&mut self) -> Option<T> {
|
|
let boxed = self.inner.as_mut()?.remove(&TypeId::of::<T>())?;
|
|
boxed.downcast::<T>().ok().map(|b| *b)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Conn — the pipeline value.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug)]
|
|
pub struct Conn {
|
|
// Request (populated before the pipeline runs).
|
|
pub method: Method,
|
|
pub path: String,
|
|
pub query: Option<String>,
|
|
pub version: HttpVersion,
|
|
pub headers: HeaderMap,
|
|
pub body: Body,
|
|
|
|
// Response (built up by plugs, written after the pipeline returns).
|
|
pub status: Option<u16>,
|
|
pub resp_headers: HeaderMap,
|
|
pub resp_body: RespBody,
|
|
|
|
// Pipeline state.
|
|
pub params: Params,
|
|
pub assigns: Assigns,
|
|
pub halted: bool,
|
|
|
|
/// Set by [`Conn::upgrade`] on an accepted WebSocket handshake; the
|
|
/// connection actor sees it (with status 101) and leaves the HTTP
|
|
/// loop after writing the response. `None` for plain HTTP.
|
|
pub upgrade: Option<crate::ws::WsUpgrade>,
|
|
}
|
|
|
|
impl Conn {
|
|
/// Construct an empty `Conn`. Tests use this; the connection actor uses
|
|
/// `From<RequestParts>` (see parser.rs).
|
|
pub fn new() -> Self {
|
|
Self {
|
|
method: Method::Get,
|
|
path: String::new(),
|
|
query: None,
|
|
version: HttpVersion::Http11,
|
|
headers: HeaderMap::new(),
|
|
body: Body::empty(),
|
|
|
|
status: None,
|
|
resp_headers: HeaderMap::new(),
|
|
resp_body: RespBody::Empty,
|
|
|
|
params: Params::new(),
|
|
assigns: Assigns::new(),
|
|
halted: false,
|
|
|
|
upgrade: None,
|
|
}
|
|
}
|
|
|
|
// ----- Fluent builders. Most handler code uses these. -----
|
|
|
|
pub fn put_status(mut self, status: u16) -> Self {
|
|
self.status = Some(status);
|
|
self
|
|
}
|
|
|
|
pub fn put_header(mut self, name: &str, value: impl Into<String>) -> Self {
|
|
self.resp_headers.set(name, value);
|
|
self
|
|
}
|
|
|
|
pub fn put_body(mut self, body: impl Into<RespBody>) -> Self {
|
|
self.resp_body = body.into();
|
|
self
|
|
}
|
|
|
|
pub fn put_params(mut self, params: Params) -> Self {
|
|
self.params = params;
|
|
self
|
|
}
|
|
|
|
pub fn halt(mut self) -> Self {
|
|
self.halted = true;
|
|
self
|
|
}
|
|
|
|
/// Accept a WebSocket upgrade on this request (RFC 6455 §4.2),
|
|
/// handing `handler` to the duplex loop that takes the socket over.
|
|
///
|
|
/// On a valid handshake: status `101`, the `upgrade`/`connection`/
|
|
/// `sec-websocket-accept` response headers, the [`WsUpgrade`] payload
|
|
/// the connection actor acts on, and the pipeline is halted. After
|
|
/// the 101 the connection actor leaves HTTP and drives `handler` —
|
|
/// see [`WsHandler`](crate::ws::WsHandler) for the callback contract.
|
|
///
|
|
/// On an invalid handshake: `handler` is dropped untouched and the
|
|
/// appropriate rejection goes out (`400`, or `426` +
|
|
/// `sec-websocket-version: 13` for a version mismatch), empty body,
|
|
/// pipeline halted; the connection stays plain HTTP with normal
|
|
/// keep-alive semantics. Pre-check with
|
|
/// [`ws::handshake::is_upgrade_request`](crate::ws::handshake::is_upgrade_request)
|
|
/// to branch before committing.
|
|
///
|
|
/// [`WsUpgrade`]: crate::ws::WsUpgrade
|
|
pub fn upgrade(mut self, handler: impl crate::ws::WsHandler) -> Self {
|
|
use crate::ws::{handshake, Rejection};
|
|
match handshake::validate(&self) {
|
|
Ok(accept) => {
|
|
self.upgrade = Some(crate::ws::WsUpgrade {
|
|
handler: Box::new(handler),
|
|
});
|
|
self.put_status(101)
|
|
.put_header("upgrade", "websocket")
|
|
.put_header("connection", "Upgrade")
|
|
.put_header("sec-websocket-accept", accept)
|
|
.put_body(RespBody::Empty)
|
|
.halt()
|
|
}
|
|
Err(rej) => {
|
|
let c = self
|
|
.put_status(rej.status())
|
|
.put_body(RespBody::Empty)
|
|
.halt();
|
|
if rej == Rejection::UnsupportedVersion {
|
|
c.put_header("sec-websocket-version", "13")
|
|
} else {
|
|
c
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Default for Conn {
|
|
fn default() -> Self { Self::new() }
|
|
}
|
|
|
|
// ----- Ergonomic conversions into RespBody. -----
|
|
|
|
impl From<Vec<u8>> for RespBody {
|
|
fn from(v: Vec<u8>) -> Self { RespBody::Bytes(v) }
|
|
}
|
|
impl From<String> for RespBody {
|
|
fn from(s: String) -> Self { RespBody::Bytes(s.into_bytes()) }
|
|
}
|
|
impl From<&'static str> for RespBody {
|
|
fn from(s: &'static str) -> Self { RespBody::Bytes(s.as_bytes().to_vec()) }
|
|
}
|
|
impl From<&[u8]> for RespBody {
|
|
fn from(s: &[u8]) -> Self { RespBody::Bytes(s.to_vec()) }
|
|
}
|
|
impl From<StreamBody> for RespBody {
|
|
fn from(s: StreamBody) -> Self { RespBody::Stream(s) }
|
|
}
|
|
impl From<smarm::Receiver<Vec<u8>>> for RespBody {
|
|
fn from(rx: smarm::Receiver<Vec<u8>>) -> Self {
|
|
RespBody::Stream(StreamBody::new(rx))
|
|
}
|
|
}
|