feat(sse): Conn::sse() sugar — EventSender + pump heartbeats (v0.3 chunk 3)
New src/sse.rs. Conn::sse() (and sse_with_heartbeat) turns the response
into a text/event-stream: status 200 if unset, content-type +
cache-control: no-cache, and a RespBody::Stream whose heartbeat is wired
to the new StreamBody.heartbeat field. Returns (Conn, EventSender);
EventSender is Clone (stream ends when the LAST clone drops) with
send(event, data) / data(data) / comment(text), all returning
Err(SseClosed) once the conn actor has dropped the stream — the producer
exit signal. Multi-line data becomes one data: line per line (pure
format_event, unit-tested).
Heartbeat lives in the pump, as the roadmap leaned: with
StreamBody.heartbeat set, pump_stream waits recv_timeout(interval) and on
expiry writes the payload (": keep-alive" comment chunk) and keeps
waiting. Liveness inversion is deliberate: no request clock governs an
SSE response (request_timeout is read-phase only since chunk 1); a dead
client is detected when an event or heartbeat write stalls past
write_timeout, and a draining shutdown force-stops the recv park like any
stream.
Tests: 4 unit (3 framing, 1 sse() wiring) + 2 integration (event round
trip over decoded chunked stream incl. named + unnamed events and clean
0-chunk EOS on sender drop; >=2 heartbeats observed across 450ms of
producer silence at a 100ms interval).
This commit is contained in:
@@ -905,3 +905,65 @@ fn chunked_request_stall_killed_at_request_timeout() {
|
||||
assert!(resp.is_empty(), "expected silent close, got: {:?}", String::from_utf8_lossy(&resp));
|
||||
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE (v0.3 chunk 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Conn::sse(): the response is a chunked text/event-stream; events sent
|
||||
/// through the EventSender arrive framed; the stream ends (0-chunk) when
|
||||
/// the producer drops the sender.
|
||||
#[test]
|
||||
fn sse_events_round_trip() {
|
||||
let pipe = Pipeline::new().plug(Router::new().get(
|
||||
"/events",
|
||||
|c: Conn, _n: Next| {
|
||||
let (c, events) = c.sse();
|
||||
smarm::spawn(move || {
|
||||
let _ = events.send("tick", "1");
|
||||
let _ = events.data("plain");
|
||||
smarm::sleep(Duration::from_millis(10));
|
||||
// events drops here: end of stream.
|
||||
});
|
||||
c
|
||||
},
|
||||
));
|
||||
let port = spawn_server(pipe);
|
||||
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 /events HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||
|
||||
let (head, body) = read_chunked_response(&mut s);
|
||||
assert!(head.contains("content-type: text/event-stream"), "head: {head}");
|
||||
assert!(head.contains("transfer-encoding: chunked"), "head: {head}");
|
||||
let body = String::from_utf8(body).unwrap();
|
||||
assert!(body.contains("event: tick\ndata: 1\n\n"), "body: {body}");
|
||||
assert!(body.contains("data: plain\n\n"), "body: {body}");
|
||||
}
|
||||
|
||||
/// With a silent producer, the conn actor emits keep-alive comment chunks
|
||||
/// at the configured heartbeat interval.
|
||||
#[test]
|
||||
fn sse_heartbeats_on_silence() {
|
||||
let pipe = Pipeline::new().plug(Router::new().get(
|
||||
"/events",
|
||||
|c: Conn, _n: Next| {
|
||||
let (c, events) = c.sse_with_heartbeat(Duration::from_millis(100));
|
||||
smarm::spawn(move || {
|
||||
// Hold the sender open, silently, then end the stream.
|
||||
smarm::sleep(Duration::from_millis(450));
|
||||
drop(events);
|
||||
});
|
||||
c
|
||||
},
|
||||
));
|
||||
let port = spawn_server(pipe);
|
||||
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 /events HTTP/1.1\r\nHost: x\r\n\r\n").unwrap();
|
||||
|
||||
let (_head, body) = read_chunked_response(&mut s);
|
||||
let body = String::from_utf8(body).unwrap();
|
||||
let beats = body.matches(": keep-alive").count();
|
||||
assert!(beats >= 2, "expected >=2 heartbeats in 450ms at 100ms interval, got {beats}: {body:?}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user