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:
+28
-11
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user