fix(adapters): incremental byte-level SSE parser (FWD-06)
Replace per-chunk parse_sse_frames with SseParser holding raw bytes across chunks: reassembles frames split at TCP boundaries (the review's silently-losing case), decodes UTF-8 per complete line so multi-byte chars split across chunks survive, caps the buffer at 1 MiB (SSE_EVENT_BUFFER_CAP) and dispatches a pending event at EOF. forward_stream threads the parser through its unfold state and emits a terminal error envelope on cap overflow. Existing single-chunk SSE test assertions preserved; added multi-chunk, split-UTF-8, EOF-dispatch, and cap tests. Verified: cargo test (219 pass), clippy -D warnings, fmt --check.
This commit is contained in:
@@ -801,43 +801,125 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn sse_frames_parse_multi_event_buffer() {
|
||||
let (events, remaining) =
|
||||
crate::adapters::forward::parse_sse_frames("data: a\n\ndata: b\n\n");
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let events = parser
|
||||
.feed(b"data: a\n\ndata: b\n\n", false)
|
||||
.expect("parse errors impossible on ascii");
|
||||
assert_eq!(events.len(), 2);
|
||||
assert_eq!(events[0].data, "a");
|
||||
assert_eq!(events[1].data, "b");
|
||||
assert_eq!(remaining, "");
|
||||
let tail = parser.feed(b"", false).expect("tail");
|
||||
assert!(tail.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_frames_handle_partial_trailing_line() {
|
||||
let (events, remaining) =
|
||||
crate::adapters::forward::parse_sse_frames("data: a\n\ndata: par");
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let events = parser
|
||||
.feed(b"data: a\n\ndata: par", false)
|
||||
.expect("parse errors impossible on ascii");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(remaining, "data: par");
|
||||
let rest = parser.feed(b"tial\n\n", false).expect("rest");
|
||||
assert_eq!(rest.len(), 1);
|
||||
assert_eq!(rest[0].data, "partial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_frames_skip_comment_lines() {
|
||||
let (events, _) = crate::adapters::forward::parse_sse_frames(": comment\ndata: x\n\n");
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let events = parser
|
||||
.feed(b": comment\ndata: x\n\n", false)
|
||||
.expect("parse errors impossible on ascii");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_frames_join_multi_line_data() {
|
||||
let (events, _) =
|
||||
crate::adapters::forward::parse_sse_frames("data: line1\ndata: line2\n\n");
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let events = parser
|
||||
.feed(b"data: line1\ndata: line2\n\n", false)
|
||||
.expect("parse errors impossible on ascii");
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data, "line1\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_sse_frames_strips_bom() {
|
||||
let (events, _) = crate::adapters::forward::parse_sse_frames("\u{feff}data: a\n\n");
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let events = parser
|
||||
.feed("\u{feff}data: a\n\n".as_bytes(), false)
|
||||
.expect("parse errors impossible on bom+ascii");
|
||||
assert_eq!(events.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_multichunk_events_reassembled() {
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let first = parser
|
||||
.feed(b"data: {\"n\":1}\n", false)
|
||||
.expect("parse errors impossible on ascii");
|
||||
assert!(first.is_empty(), "no blank line yet, event pending");
|
||||
let second = parser
|
||||
.feed(b"\ndata: {\"n\":2}\n\n", false)
|
||||
.expect("parse errors impossible on ascii");
|
||||
assert_eq!(
|
||||
second.len(),
|
||||
2,
|
||||
"the review's empirically-verified loss case"
|
||||
);
|
||||
assert_eq!(second[0].data, "{\"n\":1}");
|
||||
assert_eq!(second[1].data, "{\"n\":2}");
|
||||
let eof = parser.feed(b"", true).expect("eof");
|
||||
assert!(eof.is_empty(), "no event left pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_multichunk_split_utf8_char() {
|
||||
let payload = "{\"s\":\"héllo\"}";
|
||||
let bytes = format!("data: {payload}\n\n").into_bytes();
|
||||
let split = bytes.len() - payload.len() + 3;
|
||||
assert!(
|
||||
payload.as_bytes()[split - 8..].contains(&0xc3),
|
||||
"split inside multi-byte char"
|
||||
);
|
||||
let (head, tail) = bytes.split_at(split);
|
||||
let head = head.to_vec();
|
||||
let tail = tail.to_vec();
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let first = parser.feed(&head, false).expect("first chunk");
|
||||
assert!(
|
||||
first.is_empty(),
|
||||
"frame incomplete until blank line arrives"
|
||||
);
|
||||
let second = parser.feed(&tail, false).expect("second chunk");
|
||||
assert_eq!(second.len(), 1);
|
||||
assert_eq!(second[0].data, payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_pending_event_dispatched_at_eof() {
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let pending = parser.feed(b"data: tail-event\n", true).expect("eof feed");
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].data, "tail-event");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sse_oversized_partial_line_no_dispatch() {
|
||||
let big_line = "x".repeat(16 * 1024 * 1024);
|
||||
let body = format!("data: {big_line}");
|
||||
let mut parser = crate::adapters::forward::SseParser::new();
|
||||
let result = parser.feed(body.as_bytes(), false);
|
||||
assert!(
|
||||
matches!(
|
||||
result,
|
||||
Err(crate::adapters::forward::SseParseError::BufferOverflow)
|
||||
),
|
||||
"unterminated oversized line must trip the cap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_service_config_struct_fields() {
|
||||
let cfg = config(
|
||||
|
||||
Reference in New Issue
Block a user