Skip to main content

microvms_app/session/
sse.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Incremental Server-Sent Events parsing, and the typed events the daemon emits.
3//!
4//! # Incremental because the transport picks the read boundaries
5//!
6//! A single `data:` line routinely arrives split across two chunks, and a parser that
7//! assumes a read boundary is a frame boundary loses output at exactly the moment the
8//! stream gets busy. So bytes are buffered until a blank line proves a frame is
9//! complete, and a trailing partial frame stays in the buffer.
10//!
11//! Only the subset the daemon emits is implemented: named events with one `data:` line
12//! of JSON, plus the `:` keepalive comments axum sends every fifteen seconds. `id:` and
13//! `retry:` are ignored rather than half-supported — the daemon does not send them, and
14//! the byte offset is the resume cursor rather than `Last-Event-ID`.
15//!
16//! # Why not an eventsource crate
17//!
18//! The daemon's SSE is two field names and three event names, and the interesting part
19//! of a client's stream handling is the cursor rather than the framing. An eventsource
20//! crate would bring a reconnect policy keyed on `Last-Event-ID`, which is the wrong
21//! cursor: it resumes at an event, and this protocol resumes at a byte. The framing
22//! parser below is first-party for that reason, not from any aversion to dependencies.
23
24use base64::Engine as _;
25
26use crate::error::{Error, ErrorKind, WireKind};
27
28/// One complete SSE frame: its event name and its raw `data:` text.
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct Frame {
31    pub event: String,
32    pub data: String,
33}
34
35/// The longest frame terminator, in bytes. See [`find_frame_end`].
36const MAX_TERMINATOR_LEN: usize = 4;
37
38/// The ceiling on bytes held for a frame that has not terminated (BIND-22).
39///
40/// # Why a ceiling at all
41///
42/// Without one, a peer that sends bytes and never a blank line makes this buffer grow
43/// until the process dies. That is not hypothetical for this client: the thing on the
44/// other end of the stream is a *proxy* in front of the daemon, and a proxy that answers
45/// a stream request with a non-SSE body — an HTML error page, a redirect chain, a chunked
46/// response it keeps writing to — produces exactly that shape. There is no frame boundary
47/// coming, so no other guard in the parser ever fires.
48///
49/// # Why 4 MiB
50///
51/// Sized from the daemon's actual frame sizes, with a large multiple of headroom.
52///
53/// One `output` event carries one read from a child's pipe, and the daemon reads into a
54/// 16 KiB scratch buffer (`Capped::new` in `crates/agentd/src/exec.rs`). Base64 inflates that by
55/// 4/3, so the biggest `data:` line the daemon emits is about 22 KiB plus a short JSON
56/// envelope — call it 24 KiB. A reattach that replays the 1 MiB stream ring does not
57/// change the number: the ring is replayed as its individual chunks, each its own frame,
58/// each still one 16 KiB read. `gap` and `exit` frames are tens of bytes.
59///
60/// 4 MiB is roughly 170 frames' worth of that, which leaves room for a proxy that
61/// coalesces reads, for a future daemon with a larger scratch buffer, and for the
62/// `stream_buffer_bytes` ring being raised to a few MiB — while staying small enough to be
63/// irrelevant against the 512 MiB baseline VM this client talks to. A caller that hits it
64/// is not reading a big frame; it is reading something that is not SSE.
65///
66/// The bound is on the *residue*: [`SseParser::feed`] drains every complete frame first, so
67/// a single read that legitimately carries many megabytes of finished frames is not
68/// refused. What is refused is a residue that keeps growing across feeds.
69pub const MAX_PENDING_BYTES: usize = 4 * 1024 * 1024;
70
71/// Feed bytes, get whole frames out. Holds a partial frame across feeds.
72#[derive(Debug, Default)]
73pub struct SseParser {
74    buffer: Vec<u8>,
75    /// How much of `buffer` the terminator scan has already ruled out.
76    ///
77    /// The scan resumes here rather than at byte zero. Rescanning from zero on every feed
78    /// is quadratic in the size of a partial frame, and the buffer is largest exactly when
79    /// the stream is busiest: 800 unterminated 8 KiB reads took 31 seconds to scan and now
80    /// take milliseconds.
81    scanned: usize,
82}
83
84impl SseParser {
85    pub fn new() -> Self {
86        Self::default()
87    }
88
89    /// Every frame `chunk` completed. Never returns a partial one.
90    ///
91    /// Fails with [`ErrorKind::Protocol`] once the undelimited residue passes
92    /// [`MAX_PENDING_BYTES`] — see that constant for why, and for why the check is on what
93    /// is left after draining rather than on what arrived.
94    pub fn feed(&mut self, chunk: &[u8]) -> Result<Vec<Frame>, Error> {
95        self.buffer.extend_from_slice(chunk);
96        let mut frames = Vec::new();
97        loop {
98            // Back up by one byte less than the longest terminator, because a terminator
99            // straddling a chunk boundary has its first bytes inside the already-scanned
100            // region. Backing up to zero instead is what made this quadratic.
101            let from = self.scanned.saturating_sub(MAX_TERMINATOR_LEN - 1);
102            let Some((body, consumed)) = find_frame_end(&self.buffer, from) else {
103                self.scanned = self.buffer.len();
104                break;
105            };
106            let raw = self.buffer[..body].to_vec();
107            self.buffer.drain(..consumed);
108            // Nothing past the terminator has been looked at yet — the search stopped at
109            // the first match — so the next pass starts over on what is left.
110            self.scanned = 0;
111            if let Some(frame) = parse_frame(&raw) {
112                frames.push(frame);
113            }
114        }
115
116        if self.buffer.len() > MAX_PENDING_BYTES {
117            let held = self.buffer.len();
118            // Dropped rather than kept: the stream is over, and holding megabytes that no
119            // longer have a reader is the second half of the same bug.
120            self.buffer = Vec::new();
121            self.scanned = 0;
122            return Err(Error::new(
123                ErrorKind::Protocol,
124                format!(
125                    "the event stream sent {held} bytes with no frame terminator, over the \
126                     {MAX_PENDING_BYTES}-byte ceiling on undelimited data. The daemon's largest \
127                     frame is one 16 KiB pipe read base64-encoded, about 24 KiB, so a residue \
128                     this size means the response body is not server-sent events — most often \
129                     a proxy answering with an error page instead of the stream."
130                ),
131            ));
132        }
133        Ok(frames)
134    }
135
136    /// Bytes held for an incomplete frame. Diagnostic; a healthy stream sits at 0.
137    pub fn pending(&self) -> usize {
138        self.buffer.len()
139    }
140}
141
142/// Locates the first frame terminator at or after `from`, returning the body length and
143/// bytes consumed.
144///
145/// A frame ends at a blank line, and the wire spelling of that varies: `\n\n`,
146/// `\r\n\r\n`, `\r\r`. All three are accepted because a proxy in the path may rewrite
147/// line endings, and picking one spelling would work right up until it did not.
148fn find_frame_end(buffer: &[u8], from: usize) -> Option<(usize, usize)> {
149    let tail = buffer.get(from..)?;
150    let mut earliest: Option<(usize, usize)> = None;
151    for terminator in [
152        b"\r\n\r\n".as_slice(),
153        b"\n\n".as_slice(),
154        b"\r\r".as_slice(),
155    ] {
156        let Some(idx) = find(tail, terminator) else {
157            continue;
158        };
159        let idx = from + idx;
160        // A shorter terminator can be found at a lower index inside a longer one
161        // (`\n\n` sits at index+1 of `\r\n\r\n`), so the earliest start wins and ties
162        // break toward the longer match.
163        if earliest.is_none_or(|(start, _)| idx < start) {
164            earliest = Some((idx, terminator.len()));
165        }
166    }
167    let (start, width) = earliest?;
168    Some((start, start + width))
169}
170
171fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
172    haystack
173        .windows(needle.len())
174        .position(|window| window == needle)
175}
176
177/// Turns one frame's lines into a [`Frame`], or `None` if it carries no data.
178///
179/// `None` for a keepalive comment and for a frame with no `data:` line. A frame whose
180/// data is not JSON is *not* dropped here — that judgement belongs to [`decode`], which
181/// knows which event names carry JSON.
182fn parse_frame(raw: &[u8]) -> Option<Frame> {
183    let mut event = String::new();
184    let mut data_lines: Vec<String> = Vec::new();
185    let normalized: Vec<u8> = {
186        // `\r\n` and a bare `\r` both become `\n`, so one split handles every spelling.
187        let mut out = Vec::with_capacity(raw.len());
188        let mut iter = raw.iter().copied().peekable();
189        while let Some(byte) = iter.next() {
190            if byte == b'\r' {
191                if iter.peek() == Some(&b'\n') {
192                    iter.next();
193                }
194                out.push(b'\n');
195            } else {
196                out.push(byte);
197            }
198        }
199        out
200    };
201
202    for line in normalized.split(|byte| *byte == b'\n') {
203        // A leading colon is a comment. axum's keepalive is exactly this.
204        if line.is_empty() || line.starts_with(b":") {
205            continue;
206        }
207        let (name, value) = match find(line, b":") {
208            Some(idx) => (&line[..idx], &line[idx + 1..]),
209            None => (line, b"".as_slice()),
210        };
211        // "If value starts with a single space, remove it" — one space only, since a
212        // payload may legitimately begin with whitespace.
213        let value = value.strip_prefix(b" ").unwrap_or(value);
214        match String::from_utf8_lossy(name).as_ref() {
215            "event" => event = String::from_utf8_lossy(value).into_owned(),
216            "data" => data_lines.push(String::from_utf8_lossy(value).into_owned()),
217            _ => {}
218        }
219    }
220
221    if data_lines.is_empty() {
222        return None;
223    }
224    Some(Frame {
225        event,
226        data: data_lines.join("\n"),
227    })
228}
229
230/// One decoded stream event.
231///
232/// The payloads are the `protocol` crate's, so a field renamed on the daemon side
233/// breaks this build. The one thing added on top is the decoded bytes of an output
234/// event: base64 is how arbitrary bytes cross a JSON string, and a caller wants the
235/// bytes.
236///
237/// Not `Clone`, because [`protocol::exec::ExitEvent`] is not — and adding the derive
238/// there would be an edit to a crate this lane does not own. No consumer needs it: a
239/// stream item is moved to its handler once.
240#[derive(Debug)]
241pub enum ExecEvent {
242    /// Output bytes, with the offset they start at.
243    Output {
244        stream: protocol::exec::StreamKind,
245        offset: u64,
246        data: Vec<u8>,
247    },
248    /// A byte range that is gone for good.
249    ///
250    /// Surfaced as a typed event rather than logged, because the alternative is reading
251    /// a truncated log as a complete one. `from` is inclusive and `to` exclusive, so
252    /// `to` is where a cursor resumes.
253    Gap { from: u64, to: u64 },
254    /// The terminal event. Its absence is what distinguishes a cut connection from a
255    /// finished command — the byte sequences are otherwise identical.
256    Exit(protocol::exec::ExitEvent),
257}
258
259/// Maps one frame onto a typed event.
260///
261/// Three outcomes, and the middle one is the reason this returns a nested `Result`:
262///
263/// * `Ok(Some(event))` — a recognized event.
264/// * `Ok(None)` — a frame this client does not dispatch. An unknown event name, or a
265///   payload that will not parse. Dropped rather than raised, matching the daemon,
266///   which degrades a serialization failure to `{}` on purpose instead of taking the
267///   connection down: one bad frame must not end an otherwise live stream.
268/// * `Err(_)` — an `output` event whose base64 will not decode. Distinct from the
269///   above because it is unambiguously corruption of bytes a caller asked for, and
270///   silently dropping output is the failure this whole protocol is shaped to prevent.
271pub fn decode(frame: &Frame) -> Result<Option<ExecEvent>, Error> {
272    match frame.event.as_str() {
273        protocol::exec::EVENT_OUTPUT => {
274            let Ok(payload) = serde_json::from_str::<protocol::exec::OutputEvent>(&frame.data)
275            else {
276                return Ok(None);
277            };
278            let data = base64::engine::general_purpose::STANDARD
279                .decode(&payload.output)
280                .map_err(|err| {
281                    Error::wire(
282                        WireKind::ProtocolError,
283                        format!(
284                            "an output event at offset {} carried undecodable base64, so \
285                             those bytes cannot be delivered: {err}",
286                            payload.offset
287                        ),
288                    )
289                })?;
290            Ok(Some(ExecEvent::Output {
291                stream: payload.stream,
292                offset: payload.offset,
293                data,
294            }))
295        }
296        protocol::exec::EVENT_GAP => {
297            let Ok(payload) = serde_json::from_str::<protocol::exec::GapEvent>(&frame.data) else {
298                return Ok(None);
299            };
300            Ok(Some(ExecEvent::Gap {
301                from: payload.from,
302                to: payload.to,
303            }))
304        }
305        protocol::exec::EVENT_EXIT => {
306            let Ok(payload) = serde_json::from_str::<protocol::exec::ExitEvent>(&frame.data) else {
307                return Ok(None);
308            };
309            Ok(Some(ExecEvent::Exit(payload)))
310        }
311        _ => Ok(None),
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn output_frame(offset: u64, bytes: &[u8]) -> String {
320        let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
321        format!(
322            "event: output\ndata: {{\"offset\":{offset},\"stream\":\"stdout\",\
323             \"output\":\"{encoded}\"}}\n\n"
324        )
325    }
326
327    /// The property the whole parser exists for: a frame split across reads survives.
328    ///
329    /// Byte-by-byte is the strongest version of the split — every possible boundary is
330    /// exercised at once, including inside the `event:` name and inside the base64.
331    #[test]
332    fn a_frame_split_across_every_possible_read_boundary_still_parses() {
333        let wire = output_frame(0, b"hello");
334        let mut parser = SseParser::new();
335        let mut frames = Vec::new();
336        for byte in wire.as_bytes() {
337            frames.extend(parser.feed(&[*byte]).expect("under the pending ceiling"));
338        }
339        assert_eq!(frames.len(), 1, "the split frame did not reassemble");
340        assert_eq!(parser.pending(), 0, "a complete stream left bytes behind");
341
342        let event = decode(&frames[0]).expect("decodes").expect("is an event");
343        match event {
344            ExecEvent::Output { offset, data, .. } => {
345                assert_eq!(offset, 0);
346                assert_eq!(data, b"hello");
347            }
348            other => panic!("expected output, got {other:?}"),
349        }
350    }
351
352    /// Several frames in one read all come out, in order.
353    #[test]
354    fn one_read_carrying_three_frames_yields_three() {
355        let wire = format!(
356            "{}{}{}",
357            output_frame(0, b"a"),
358            output_frame(1, b"b"),
359            "event: exit\ndata: {\"exit_code\":0,\"signal\":null,\"truncated\":false,\
360             \"writers_may_be_alive\":false,\"offset\":2}\n\n"
361        );
362        let mut parser = SseParser::new();
363        let frames = parser
364            .feed(wire.as_bytes())
365            .expect("under the pending ceiling");
366        assert_eq!(frames.len(), 3);
367        assert!(matches!(
368            decode(&frames[2]).expect("decodes"),
369            Some(ExecEvent::Exit(_))
370        ));
371    }
372
373    /// A trailing partial frame is held, not emitted and not lost.
374    #[test]
375    fn a_trailing_partial_frame_is_held_until_it_completes() {
376        let wire = output_frame(0, b"xyz");
377        let (head, tail) = wire.split_at(wire.len() - 4);
378        let mut parser = SseParser::new();
379        assert!(
380            parser
381                .feed(head.as_bytes())
382                .expect("under the pending ceiling")
383                .is_empty(),
384            "an incomplete frame was emitted"
385        );
386        assert!(parser.pending() > 0);
387        let frames = parser
388            .feed(tail.as_bytes())
389            .expect("under the pending ceiling");
390        assert_eq!(frames.len(), 1);
391    }
392
393    /// The scan cursor does not skip a terminator that straddles a chunk boundary.
394    ///
395    /// This is the way a resumed scan gets it wrong: the previous feed ended part-way
396    /// through the blank line, so the terminator's first bytes are inside the region
397    /// already ruled out. Every split of every spelling is exercised, which is what makes
398    /// the back-up-by-terminator-length-minus-one arithmetic pinned rather than sampled.
399    ///
400    /// **Falsification** — resume the scan at `self.scanned` with no back-up and the
401    /// `\r\n\r\n` and `\r\r` splits stop producing a frame at all.
402    #[test]
403    fn a_terminator_straddling_a_chunk_boundary_is_still_found() {
404        for terminator in ["\n\n", "\r\n\r\n", "\r\r"] {
405            let wire = format!("event: gap\ndata: {{\"from\":0,\"to\":4}}{terminator}");
406            for split in 1..wire.len() {
407                let mut parser = SseParser::new();
408                let mut frames = Vec::new();
409                frames.extend(
410                    parser
411                        .feed(&wire.as_bytes()[..split])
412                        .expect("under the pending ceiling"),
413                );
414                frames.extend(
415                    parser
416                        .feed(&wire.as_bytes()[split..])
417                        .expect("under the pending ceiling"),
418                );
419                assert_eq!(
420                    frames.len(),
421                    1,
422                    "{terminator:?} split at {split} did not reassemble"
423                );
424                assert_eq!(
425                    parser.pending(),
426                    0,
427                    "{terminator:?} split at {split} left bytes behind"
428                );
429                assert!(matches!(
430                    decode(&frames[0]).expect("decodes"),
431                    Some(ExecEvent::Gap { from: 0, to: 4 })
432                ));
433            }
434        }
435    }
436
437    /// An undelimited stream is refused at the ceiling rather than buffered forever (BIND-22).
438    ///
439    /// [`ErrorKind::Protocol`] and therefore **not retryable**, which is the half that
440    /// matters to `exec::Attach`: a reconnect into the same non-SSE body would refill the
441    /// buffer `max_reconnects` times over.
442    ///
443    /// **Falsification** — drop the ceiling check and the parser accepts every chunk, so
444    /// `expect_err` panics.
445    #[test]
446    fn an_unterminated_stream_is_refused_at_the_pending_ceiling() {
447        let mut parser = SseParser::new();
448        // 8 KiB with no blank line anywhere, which is what a proxy's HTML error page or a
449        // redirect chain looks like to this parser.
450        let chunk = vec![b'x'; 8 * 1024];
451        let mut fed = 0usize;
452        loop {
453            match parser.feed(&chunk) {
454                Ok(frames) => {
455                    assert!(frames.is_empty(), "undelimited data produced a frame");
456                    fed += chunk.len();
457                    assert!(
458                        parser.pending() <= MAX_PENDING_BYTES,
459                        "the buffer passed the ceiling without erroring: {}",
460                        parser.pending()
461                    );
462                    assert!(
463                        fed <= MAX_PENDING_BYTES + chunk.len(),
464                        "the ceiling never fired"
465                    );
466                }
467                Err(err) => {
468                    assert_eq!(err.kind(), ErrorKind::Protocol);
469                    assert!(
470                        !err.retryable(),
471                        "a reconnect would refill the buffer from the same body"
472                    );
473                    assert_eq!(
474                        err.wire_kind(),
475                        None,
476                        "no status produced this; it is a client-side ceiling"
477                    );
478                    let message = err.to_string();
479                    assert!(
480                        message.contains(&MAX_PENDING_BYTES.to_string()),
481                        "the message must name the ceiling: {message}"
482                    );
483                    assert!(
484                        message.contains("no frame terminator"),
485                        "the message must say what was wrong: {message}"
486                    );
487                    assert_eq!(
488                        parser.pending(),
489                        0,
490                        "the refused bytes are dropped, not held for a reader that is gone"
491                    );
492                    return;
493                }
494            }
495        }
496    }
497
498    /// Scanning is linear in the buffer, not quadratic.
499    ///
500    /// The measured shape of the defect: 800 unterminated 8 KiB reads — 6.4 MiB, one read
501    /// under the ceiling per feed — rescanned from byte zero every time.
502    ///
503    /// **The budget is 10 s for a workload that takes ~0.75 s fixed and ~160 s broken.**
504    /// Deliberately nowhere near the fixed figure, because the two populations are 200x
505    /// apart and the only way to lose that discrimination is to make the threshold tight
506    /// enough for load to cross it. It was 500 ms and did exactly that: measured
507    /// 2026-08-09 on a 16-core box at load average 26, the fixed path took 706-847 ms
508    /// across nine runs and this assertion failed every time — a red gate over code with
509    /// no defect in it, which is the failure mode that teaches people to skip a gate.
510    /// A 13x margin over the fixed path still leaves a 16x margin under the broken one.
511    ///
512    /// **Falsification** — replace `from` with `0` in `feed` and this takes 160 s
513    /// (verified 2026-08-09), so the guard fails by two orders of magnitude rather than
514    /// by a hair.
515    #[test]
516    fn scanning_an_unterminated_buffer_stays_linear() {
517        let chunk = vec![b'x'; 8 * 1024];
518        let started = tokio::time::Instant::now();
519        let mut parser = SseParser::new();
520        for _ in 0..800 {
521            // A feed that trips the ceiling resets the buffer, which would hide the
522            // quadratic scan — so the parser is rebuilt instead of being allowed to grow
523            // past it, and each iteration still scans a buffer the old code rescanned
524            // whole.
525            if parser.pending() + chunk.len() > MAX_PENDING_BYTES {
526                parser = SseParser::new();
527            }
528            parser.feed(&chunk).expect("under the pending ceiling");
529        }
530        let elapsed = started.elapsed();
531        assert!(
532            elapsed < std::time::Duration::from_secs(10),
533            "800 x 8 KiB unterminated feeds took {elapsed:?}, over the 10s budget; the \
534             quadratic rescan is back (the linear path is ~0.75s even under heavy load, \
535             and the quadratic one is ~160s)"
536        );
537    }
538
539    /// All three blank-line spellings terminate a frame, because a proxy may rewrite
540    /// line endings and picking one spelling works right up until it does not.
541    #[test]
542    fn every_blank_line_spelling_terminates_a_frame() {
543        for terminator in ["\n\n", "\r\n\r\n", "\r\r"] {
544            let wire = format!("event: gap\r\ndata: {{\"from\":0,\"to\":4}}{terminator}");
545            let mut parser = SseParser::new();
546            let frames = parser
547                .feed(wire.as_bytes())
548                .expect("under the pending ceiling");
549            assert_eq!(frames.len(), 1, "{terminator:?} did not terminate a frame");
550            assert!(matches!(
551                decode(&frames[0]).expect("decodes"),
552                Some(ExecEvent::Gap { from: 0, to: 4 })
553            ));
554        }
555    }
556
557    /// A keepalive is a comment with no data, and it must not surface as an event —
558    /// otherwise fifteen seconds of silence looks like output.
559    #[test]
560    fn a_keepalive_comment_is_not_a_frame() {
561        let mut parser = SseParser::new();
562        assert!(
563            parser
564                .feed(b":\n\n")
565                .expect("under the pending ceiling")
566                .is_empty()
567        );
568        assert!(
569            parser
570                .feed(b": keep-alive-text\n\n")
571                .expect("under the pending ceiling")
572                .is_empty()
573        );
574        assert_eq!(parser.pending(), 0);
575
576        // And it does not disturb a real frame that follows it on the same read.
577        let wire = format!(":\n\n{}", output_frame(0, b"real"));
578        let mut parser = SseParser::new();
579        assert_eq!(
580            parser
581                .feed(wire.as_bytes())
582                .expect("under the pending ceiling")
583                .len(),
584            1
585        );
586    }
587
588    /// One space after the colon is stripped, a second is payload.
589    ///
590    /// The daemon writes `data: {...}`, and a parser that stripped all leading
591    /// whitespace would corrupt any payload that meant to start with a space.
592    #[test]
593    fn exactly_one_leading_space_is_stripped_from_a_field_value() {
594        let mut parser = SseParser::new();
595        let frames = parser
596            .feed(b"event:  gap\ndata:  {\"from\":1,\"to\":2}\n\n")
597            .expect("under the pending ceiling");
598        assert_eq!(frames.len(), 1);
599        assert_eq!(frames[0].event, " gap", "more than one space was stripped");
600        assert_eq!(frames[0].data, " {\"from\":1,\"to\":2}");
601        // serde_json tolerates the leading space, so the event still decodes — but the
602        // event *name* no longer matches, which is the failure this would cause.
603        assert!(
604            decode(&frames[0]).expect("decodes").is_none(),
605            "a mis-stripped name must not resolve to a known event"
606        );
607    }
608
609    /// An unknown event name and unparseable data are both dropped, matching the
610    /// daemon's own degradation. One bad frame must not end a live stream (BIND-22).
611    #[test]
612    fn an_unrecognized_or_unparseable_frame_is_dropped_rather_than_raised() {
613        let mut parser = SseParser::new();
614        let frames = parser
615            .feed(
616                b"event: something-new\ndata: {}\n\n\
617              event: gap\ndata: not json at all\n\n",
618            )
619            .expect("under the pending ceiling");
620        assert_eq!(frames.len(), 2);
621        for frame in &frames {
622            assert!(
623                decode(frame).expect("no error").is_none(),
624                "{frame:?} should be dropped"
625            );
626        }
627    }
628
629    /// Undecodable base64 in an *output* event is an error, not a drop or a panic (BIND-22).
630    ///
631    /// The one place a drop would be wrong: those are bytes the caller asked for, and
632    /// silently losing output is the failure the gap event exists to make impossible.
633    #[test]
634    fn undecodable_output_base64_is_an_error_rather_than_a_silent_drop() {
635        let mut parser = SseParser::new();
636        let frames = parser.feed(
637            b"event: output\ndata: {\"offset\":7,\"stream\":\"stdout\",\"output\":\"!!!!\"}\n\n",
638        ).expect("under the pending ceiling");
639        let err = decode(&frames[0]).expect_err("bad base64 must be reported");
640        assert!(err.to_string().contains('7'), "{err}");
641    }
642
643    /// A multi-line `data:` payload joins with newlines, as the spec says.
644    #[test]
645    fn a_multi_line_data_payload_joins_with_newlines() {
646        let mut parser = SseParser::new();
647        let frames = parser
648            .feed(b"event: gap\ndata: {\"from\":0,\ndata: \"to\":5}\n\n")
649            .expect("under the pending ceiling");
650        assert_eq!(frames.len(), 1);
651        assert_eq!(frames[0].data, "{\"from\":0,\n\"to\":5}");
652        assert!(matches!(
653            decode(&frames[0]).expect("decodes"),
654            Some(ExecEvent::Gap { from: 0, to: 5 })
655        ));
656    }
657}