feat(ws): duplex wiring + handler API + echo example (v0.4 chunk 3)

Topology (b) as agreed: ONE actor per connection via smarm RFC 008 fd
arms. After the 101, ws::duplex::run_duplex replaces the HTTP loop:
try_select(&[&outbound_rx, &FdArm::readable(fd)]) per iteration,
outbound at index 0 (priority order = owed writes drain before reads;
honest backpressure). Accepted gap documented: mid-write of a large
outbound frame the actor isn't reading.

Handler API (deferred questions, as answered this session):
- WsHandler { on_message, on_close } runs IN the conn actor's select
  loop; concurrency = spawn an actor with a WsSender clone (the SSE
  producer pattern). Conn::upgrade(self, handler) flat signature;
  WsUpgrade grows from marker to Box<dyn WsHandler> payload.
- WsSender: Clone; send/text/binary/ping/close -> Err(WsClosed).
  Unbounded channel like SSE; slow client bounded by write_timeout.
- Control frames invisible v1: auto-pong inline (5.5.2), peer close
  auto-echoed (5.5.1; server closes TCP first per 7.1.1) surfacing
  only as on_close(Some(code), reason); wordless endings (EOF, write
  failure, drain timeout) = on_close(None, ""). on_ping/on_pong can
  land later as default methods, non-breaking.
- Server-initiated close (WsSender::close, FrameError->1002/1009/1007,
  handler panic->1011): close out, bounded drain (one write_timeout
  budget) for the peer echo, data discarded (1.4). Handler panics
  re-raise smarm's stop sentinel first (the pipeline catch_unwind
  dance, replicated).
- Caps: Config.max_frame_payload (1 MiB) / max_message_bytes (4 MiB).

Conn actor: 101 branch now hands fd + leftover buf (pipelined first
frame carries over; tested) + boxed handler to run_duplex; registry
entry stays Busy for the ws lifetime, so graceful shutdown force-stops
the conn out of the select park at the drain deadline (tested — the
in-runtime request_stop wake covers select parks too).

Tests: chunk-1 EOF test rewritten into a 9-test duplex suite (echo,
pipelined first frame, ping/pong, both close directions, 1002 unmasked,
1009 header-cap, fragmentation with interleaved ping, shutdown
force-stop). Suite 59u+40i+2d. Hammer: 35x lifecycle+ws subset + 3 full
+ 1 full under smarm-trace, all green; no AlreadyExists out of
try_select (the fresh eager-cleanup path held). Validated against
python websocket-client (echo, pong payload, close 1000).
examples/ws_echo.rs: /echo in-actor + /clock producer-spawning.
This commit is contained in:
Claude
2026-06-12 09:44:35 +00:00
parent 64137cccf0
commit ffc579c500
10 changed files with 902 additions and 116 deletions
+28 -11
View File
@@ -59,6 +59,13 @@ pub struct ConnLimits {
/// 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 {
@@ -71,6 +78,8 @@ impl Default for ConnLimits {
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,
}
}
}
@@ -231,17 +240,25 @@ pub fn run_connection(
.put_body(RespBody::Empty);
}
// ----- 3.5 WebSocket upgrade short-circuit. -----
// An accepted handshake (marker + 101) ends HTTP on this socket:
// write the 101 head and leave the request loop. Chunk 3 (duplex
// wiring) takes the socket over right here; until then leaving
// the loop closes it via OwnedFd::drop — the 101 is still the
// honest, testable wire artefact. The status check is defensive:
// a post-handler plug that clobbered the 101 forfeits the
// upgrade and falls through to plain HTTP.
// ----- 3.5 WebSocket upgrade. -----
// An accepted handshake (payload + 101) ends HTTP on this socket:
// write the 101 head, hand the fd to the duplex loop with the
// boxed handler and any bytes already read past this request (a
// client may pipeline its first frame behind the handshake — those
// bytes are ws bytes now). The registry entry stays Busy for the
// whole ws lifetime: an open WebSocket is in-flight work, not
// reapable idle HTTP; graceful shutdown force-stops it out of the
// select park at the drain deadline. The status check is
// defensive: a post-handler plug that clobbered the 101 forfeits
// the upgrade and falls through to plain HTTP.
if response_conn.upgrade.is_some() && response_conn.status == Some(101) {
let head = parser::serialise_response(&response_conn, true);
let _ = write_all(raw, &head, Instant::now() + limits.write_timeout);
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;
}
@@ -550,7 +567,7 @@ fn read_chunked_body(
// `ErrorKind::TimedOut` when `deadline` passes before the fd turns
// readable, or the last io error.
fn read_some(
pub(crate) fn read_some(
fd: RawFd,
buf: &mut Vec<u8>,
chunk: usize,
@@ -616,7 +633,7 @@ fn try_write_once(fd: RawFd, buf: &[u8]) {
// are down — a client that stops reading must not pin this actor in
// wait_writable forever (the write-side twin of slowloris).
fn write_all(fd: RawFd, mut buf: &[u8], deadline: Instant) -> io::Result<()> {
pub(crate) fn write_all(fd: RawFd, mut buf: &[u8], deadline: Instant) -> io::Result<()> {
while !buf.is_empty() {
// Park on writability before each syscall, bounded by the budget.
let remaining = deadline.saturating_duration_since(Instant::now());