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:
+34
-13
@@ -657,24 +657,45 @@ fn pump_stream(
|
||||
chunked: bool,
|
||||
write_timeout: Duration,
|
||||
) -> io::Result<()> {
|
||||
let write_chunk = |payload: &[u8]| -> io::Result<()> {
|
||||
let deadline = Instant::now() + write_timeout;
|
||||
if chunked {
|
||||
let mut framed = Vec::with_capacity(payload.len() + 20);
|
||||
framed.extend_from_slice(format!("{:x}\r\n", payload.len()).as_bytes());
|
||||
framed.extend_from_slice(payload);
|
||||
framed.extend_from_slice(b"\r\n");
|
||||
write_all(fd, &framed, deadline)
|
||||
} else {
|
||||
write_all(fd, payload, deadline)
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
match stream.rx.recv() {
|
||||
Ok(chunk) => {
|
||||
// With a heartbeat configured (SSE), the wait between chunks is the
|
||||
// heartbeat interval; expiry emits the ping and keeps waiting. A
|
||||
// ping write that stalls past write_timeout errors out below —
|
||||
// that is the dead-client detector. Without one, plain recv():
|
||||
// stoppable by the draining registry either way.
|
||||
let msg = match &stream.heartbeat {
|
||||
Some((interval, payload)) => match stream.rx.recv_timeout(*interval) {
|
||||
Ok(chunk) => Some(chunk),
|
||||
Err(smarm::RecvTimeoutError::Timeout) => {
|
||||
write_chunk(payload)?;
|
||||
continue;
|
||||
}
|
||||
Err(smarm::RecvTimeoutError::Disconnected) => None,
|
||||
},
|
||||
None => stream.rx.recv().ok(),
|
||||
};
|
||||
|
||||
match msg {
|
||||
Some(chunk) => {
|
||||
if chunk.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let deadline = Instant::now() + write_timeout;
|
||||
if chunked {
|
||||
let mut framed = Vec::with_capacity(chunk.len() + 20);
|
||||
framed.extend_from_slice(format!("{:x}\r\n", chunk.len()).as_bytes());
|
||||
framed.extend_from_slice(&chunk);
|
||||
framed.extend_from_slice(b"\r\n");
|
||||
write_all(fd, &framed, deadline)?;
|
||||
} else {
|
||||
write_all(fd, &chunk, deadline)?;
|
||||
}
|
||||
write_chunk(&chunk)?;
|
||||
}
|
||||
Err(_) => {
|
||||
None => {
|
||||
// All senders dropped: end of stream.
|
||||
if chunked {
|
||||
write_all(fd, b"0\r\n\r\n", Instant::now() + write_timeout)?;
|
||||
|
||||
Reference in New Issue
Block a user