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}