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:
+246
-70
@@ -1019,88 +1019,264 @@ fn sse_heartbeats_on_silence() {
|
||||
// closes the socket; the tests assert the wire artefact, not ws traffic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WS_HANDSHAKE: &[u8] =
|
||||
b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n";
|
||||
|
||||
/// Echo handler: data messages come straight back; the text "bye" makes
|
||||
/// the SERVER initiate the close handshake (1000/"goodbye").
|
||||
struct Echo;
|
||||
|
||||
impl urus::WsHandler for Echo {
|
||||
fn on_message(&mut self, msg: urus::Message, sender: &urus::WsSender) {
|
||||
if matches!(&msg, urus::Message::Text(t) if t == "bye") {
|
||||
let _ = sender.close(1000, "goodbye");
|
||||
return;
|
||||
}
|
||||
let _ = sender.send(msg);
|
||||
}
|
||||
}
|
||||
|
||||
fn ws_pipeline() -> Pipeline {
|
||||
Pipeline::new().plug(Router::new().get("/ws", |c: Conn, _n: Next| c.upgrade()))
|
||||
Pipeline::new().plug(Router::new().get("/ws", |c: Conn, _n: Next| c.upgrade(Echo)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_handshake_101_with_accept_key() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let resp = send_request(
|
||||
port,
|
||||
b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
|
||||
);
|
||||
assert_eq!(http_status(&resp), 101);
|
||||
let head = String::from_utf8_lossy(&resp).to_lowercase();
|
||||
assert!(head.contains("upgrade: websocket"), "{head}");
|
||||
assert!(head.contains("connection: upgrade"), "{head}");
|
||||
// RFC 6455 §1.3 worked example key -> known accept value.
|
||||
assert!(
|
||||
head.contains("sec-websocket-accept: s3pplmbitxaq9kygzzhzrbk+xoo="),
|
||||
"{head}"
|
||||
);
|
||||
// 1xx carries no body framing.
|
||||
assert!(!head.contains("content-length"), "{head}");
|
||||
assert!(!head.contains("transfer-encoding"), "{head}");
|
||||
assert_eq!(http_body(&resp), b"", "no body after a 101");
|
||||
}
|
||||
// ----- client-side helpers (the test IS the ws client) -----
|
||||
|
||||
#[test]
|
||||
fn ws_handshake_wrong_version_426() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let resp = send_request(
|
||||
port,
|
||||
b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 8\r\n\r\n",
|
||||
);
|
||||
assert_eq!(http_status(&resp), 426);
|
||||
let head = String::from_utf8_lossy(&resp).to_lowercase();
|
||||
assert!(head.contains("sec-websocket-version: 13"), "{head}");
|
||||
}
|
||||
use urus::ws::frame::{self as wsframe, Frame, Opcode};
|
||||
|
||||
#[test]
|
||||
fn ws_handshake_missing_key_400_keeps_http_alive() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
// A rejected upgrade is a normal HTTP response: same connection must
|
||||
// serve a second request afterwards.
|
||||
/// Complete the handshake on a fresh socket; panics on anything but 101.
|
||||
fn ws_connect(port: u16) -> TcpStream {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(
|
||||
b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Version: 13\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut buf = [0u8; 1024];
|
||||
let n = s.read(&mut buf).unwrap();
|
||||
let first = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(first.starts_with("HTTP/1.1 400"), "{first}");
|
||||
s.write_all(WS_HANDSHAKE).unwrap();
|
||||
let head = String::from_utf8(read_response_head(&mut s)).unwrap();
|
||||
assert!(head.starts_with("HTTP/1.1 101"), "handshake: {head}");
|
||||
s
|
||||
}
|
||||
|
||||
s.write_all(
|
||||
b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let n = s.read(&mut buf).unwrap();
|
||||
let second = String::from_utf8_lossy(&buf[..n]);
|
||||
assert!(second.starts_with("HTTP/1.1 101"), "{second}");
|
||||
fn ws_send(s: &mut TcpStream, frame: &Frame) {
|
||||
s.write_all(&wsframe::encode_masked(frame, [0x11, 0x22, 0x33, 0x44]))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Read one server frame (server frames are unmasked). `buf` carries
|
||||
/// partial bytes across calls.
|
||||
fn ws_read_frame(s: &mut TcpStream, buf: &mut Vec<u8>) -> Frame {
|
||||
loop {
|
||||
if let Some((f, consumed)) =
|
||||
wsframe::decode(buf, false, 16 * 1024 * 1024).expect("bad server frame")
|
||||
{
|
||||
buf.drain(..consumed);
|
||||
return f;
|
||||
}
|
||||
let mut tmp = [0u8; 4096];
|
||||
let n = s.read(&mut tmp).expect("read mid-frame");
|
||||
assert!(n > 0, "EOF mid-frame; buffered: {buf:?}");
|
||||
buf.extend_from_slice(&tmp[..n]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert the socket yields EOF (the server closed).
|
||||
fn ws_expect_eof(s: &mut TcpStream, buf: &[u8]) {
|
||||
assert!(buf.is_empty(), "unconsumed server bytes at EOF check: {buf:?}");
|
||||
let mut rest = Vec::new();
|
||||
let _ = s.read_to_end(&mut rest); // EOF or reset; both are closed
|
||||
assert!(rest.is_empty(), "unexpected bytes before EOF: {rest:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_accepted_upgrade_leaves_http_loop() {
|
||||
// Chunk-1 semantics: after the 101 the actor exits and the fd drops —
|
||||
// the client sees EOF after the 101 head. (Chunk 3 replaces the EOF
|
||||
// with the ws duplex loop; this test will be rewritten then.)
|
||||
fn ws_echo_roundtrip_text_and_binary() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
s.write_all(
|
||||
b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\
|
||||
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
s.read_to_end(&mut buf).unwrap(); // EOF must arrive, not a hang
|
||||
let head = String::from_utf8_lossy(&buf);
|
||||
|
||||
ws_send(&mut s, &Frame::new(Opcode::Text, "hello"));
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Text);
|
||||
assert_eq!(f.payload, b"hello");
|
||||
|
||||
ws_send(&mut s, &Frame::new(Opcode::Binary, vec![0u8, 159, 146, 150]));
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Binary);
|
||||
assert_eq!(f.payload, vec![0u8, 159, 146, 150]);
|
||||
}
|
||||
|
||||
/// A first frame pipelined in the SAME write as the handshake must be
|
||||
/// served: bytes read past the 101 request head carry into the duplex
|
||||
/// loop (the buf-handover requirement).
|
||||
#[test]
|
||||
fn ws_pipelined_first_frame_served() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
|
||||
let mut wire = WS_HANDSHAKE.to_vec();
|
||||
wire.extend_from_slice(&wsframe::encode_masked(
|
||||
&Frame::new(Opcode::Text, "early"),
|
||||
[9, 9, 9, 9],
|
||||
));
|
||||
s.write_all(&wire).unwrap();
|
||||
|
||||
let head = String::from_utf8(read_response_head(&mut s)).unwrap();
|
||||
assert!(head.starts_with("HTTP/1.1 101"), "{head}");
|
||||
let mut buf = Vec::new();
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.payload, b"early");
|
||||
}
|
||||
|
||||
/// §5.5.2: a ping is answered with a pong echoing the payload, with no
|
||||
/// handler involvement.
|
||||
#[test]
|
||||
fn ws_ping_gets_pong_with_payload() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
ws_send(&mut s, &Frame::new(Opcode::Ping, "abc"));
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Pong);
|
||||
assert_eq!(f.payload, b"abc");
|
||||
|
||||
// The connection is still a working websocket afterwards.
|
||||
ws_send(&mut s, &Frame::new(Opcode::Text, "still here"));
|
||||
assert_eq!(ws_read_frame(&mut s, &mut buf).payload, b"still here");
|
||||
}
|
||||
|
||||
/// Client-initiated close: the server echoes the status code (§5.5.1)
|
||||
/// and closes the TCP connection (§7.1.1 — server closes first).
|
||||
#[test]
|
||||
fn ws_client_close_is_echoed_then_eof() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
ws_send(
|
||||
&mut s,
|
||||
&Frame::new(Opcode::Close, wsframe::close_payload(1000, "done")),
|
||||
);
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Close);
|
||||
let (code, _) = wsframe::parse_close_payload(&f.payload).unwrap().unwrap();
|
||||
assert_eq!(code, 1000);
|
||||
ws_expect_eof(&mut s, &buf);
|
||||
}
|
||||
|
||||
/// Server-initiated close via WsSender::close: the close frame carries
|
||||
/// our code/reason; after the client echoes, the socket closes.
|
||||
#[test]
|
||||
fn ws_server_initiated_close_handshake() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
ws_send(&mut s, &Frame::new(Opcode::Text, "bye"));
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Close);
|
||||
let (code, reason) = wsframe::parse_close_payload(&f.payload).unwrap().unwrap();
|
||||
assert_eq!(code, 1000);
|
||||
assert_eq!(reason, "goodbye");
|
||||
|
||||
// Echo the close; server completes the handshake and drops the fd.
|
||||
ws_send(
|
||||
&mut s,
|
||||
&Frame::new(Opcode::Close, wsframe::close_payload(1000, "")),
|
||||
);
|
||||
ws_expect_eof(&mut s, &buf);
|
||||
}
|
||||
|
||||
/// A protocol violation (unmasked client frame) starts the close
|
||||
/// handshake with 1002.
|
||||
#[test]
|
||||
fn ws_unmasked_client_frame_closes_1002() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Server-style (unmasked) encoding from the client = violation.
|
||||
s.write_all(&Frame::new(Opcode::Text, "naughty").encode()).unwrap();
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Close);
|
||||
let (code, _) = wsframe::parse_close_payload(&f.payload).unwrap().unwrap();
|
||||
assert_eq!(code, 1002);
|
||||
|
||||
// Complete the handshake so the server returns inside the test budget.
|
||||
ws_send(
|
||||
&mut s,
|
||||
&Frame::new(Opcode::Close, wsframe::close_payload(1002, "")),
|
||||
);
|
||||
ws_expect_eof(&mut s, &buf);
|
||||
}
|
||||
|
||||
/// A frame whose HEADER already exceeds max_frame_payload is rejected
|
||||
/// before any payload is buffered: 1009 with no payload bytes sent.
|
||||
#[test]
|
||||
fn ws_oversize_frame_header_closes_1009() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// fin+text, masked, 64-bit length claiming 2 MiB (cap is 1 MiB).
|
||||
let header: &[u8] = &[
|
||||
0x81, 0xFF, 0, 0, 0, 0, 0, 0x20, 0, 0, // len = 0x200000
|
||||
9, 9, 9, 9, // mask key
|
||||
];
|
||||
s.write_all(header).unwrap();
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Close);
|
||||
let (code, _) = wsframe::parse_close_payload(&f.payload).unwrap().unwrap();
|
||||
assert_eq!(code, 1009);
|
||||
|
||||
ws_send(
|
||||
&mut s,
|
||||
&Frame::new(Opcode::Close, wsframe::close_payload(1009, "")),
|
||||
);
|
||||
ws_expect_eof(&mut s, &buf);
|
||||
}
|
||||
|
||||
/// Fragmented text reassembles into one message; control frames may
|
||||
/// interleave mid-fragmentation (§5.4) without disturbing assembly.
|
||||
#[test]
|
||||
fn ws_fragmented_message_with_interleaved_ping() {
|
||||
let port = spawn_server(ws_pipeline());
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
let first = Frame { fin: false, opcode: Opcode::Text, payload: b"hel".to_vec() };
|
||||
ws_send(&mut s, &first);
|
||||
ws_send(&mut s, &Frame::new(Opcode::Ping, "mid"));
|
||||
let cont = Frame { fin: true, opcode: Opcode::Continuation, payload: b"lo".to_vec() };
|
||||
ws_send(&mut s, &cont);
|
||||
|
||||
// Pong for the interleaved ping arrives first (written inline on
|
||||
// receipt), then the reassembled echo.
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Pong);
|
||||
assert_eq!(f.payload, b"mid");
|
||||
let f = ws_read_frame(&mut s, &mut buf);
|
||||
assert_eq!(f.opcode, Opcode::Text);
|
||||
assert_eq!(f.payload, b"hello");
|
||||
}
|
||||
|
||||
/// An open WebSocket is in-flight work: graceful shutdown waits for the
|
||||
/// drain deadline, then force-stops the conn actor parked in the duplex
|
||||
/// select. serve returns; the client sees the socket drop.
|
||||
#[test]
|
||||
fn shutdown_force_stops_open_ws() {
|
||||
let (port, handle, done_rx) =
|
||||
spawn_server_with_handle(ws_pipeline(), Duration::from_millis(300));
|
||||
let mut s = ws_connect(port);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
// Prove the duplex loop is live before shutting down.
|
||||
ws_send(&mut s, &Frame::new(Opcode::Text, "alive?"));
|
||||
assert_eq!(ws_read_frame(&mut s, &mut buf).payload, b"alive?");
|
||||
|
||||
handle.shutdown();
|
||||
done_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("serve did not return: open ws not force-stopped at drain deadline");
|
||||
|
||||
let mut rest = Vec::new();
|
||||
let _ = s.read_to_end(&mut rest); // EOF or reset; both fine
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user