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:
2026-08-29 08:35:33 +00:00
parent e4284a0d3c
commit cc34c08e4e
3 changed files with 314 additions and 75 deletions
+52 -7
View File
@@ -1,7 +1,7 @@
---
id: review-001-sse-parser
name: Incremental byte-level SSE parser (FWD-06)
status: pending
status: completed
depends_on: []
scope: narrow
risk: high
@@ -33,11 +33,11 @@ chunks (decode UTF-8 once over the reassembled buffer, not per chunk).
## Acceptance Criteria
- [ ] Multi-chunk test: event split across two TCP chunks is delivered (the review's empirically-verified case — the acceptance gate)
- [ ] Split multi-byte UTF-8 across chunks parses (test)
- [ ] Pending event dispatched at EOF; trailing partial line length-capped (tests)
- [ ] Existing single-chunk SSE tests unchanged and green
- [ ] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
- [x] Multi-chunk test: event split across two TCP chunks is delivered (the review's empirically-verified case — the acceptance gate)
- [x] Split multi-byte UTF-8 across chunks parses (test)
- [x] Pending event dispatched at EOF; trailing partial line length-capped (tests)
- [x] Existing single-chunk SSE tests unchanged and green
- [x] `cargo test` and `cargo clippy --all-targets -- -D warnings` pass
## References
@@ -52,4 +52,49 @@ chunks (decode UTF-8 once over the reassembled buffer, not per chunk).
## Summary
> Filled on completion.
> Filled on completion.
**Completed** — FWD-06 fixed via an incremental byte-level SSE parser.
### What changed
- `src/adapters/forward.rs`: replaced the per-chunk `parse_sse_frames`
function with `SseParser`, a stateful byte-level parser. It carries
the raw undecoded byte buffer across chunks, so a frame split at a
TCP boundary reassembles and a multi-byte UTF-8 character split at a
chunk boundary is no longer corrupted (UTF-8 is decoded per complete
line, after reassembly, not per chunk). Framing kept to what the call
protocol needs: lines split on `\n` with optional `\r`, `data:`
accumulation joined with `\n` on dispatch, `event:`/`id:`/`retry:`
accepted and ignored, comment lines skipped, blank line dispatches,
leading BOM stripped, pending event with data dispatched at EOF. A
documented `SSE_EVENT_BUFFER_CAP` (1 MiB) caps the reassembly buffer;
exceeding it yields `SseParseError::BufferOverflow`, which
`forward_stream` converts to a terminal error envelope instead of
buffering without bound.
- `forward_stream` now threads one `SseParser` through the
`stream::unfold` state (plus a `broken` flag so a parse/transport
error ends the stream) and dispatches the EOF-pending event when the
upstream byte stream ends.
- `src/adapters/from_openapi.rs`: the five existing single-chunk SSE
test assertions were preserved (same expected event shapes) and
ported to the new `feed` API; added the review's multi-chunk loss
case (`"data: {\"n\":1}\n"` + `"\ndata: {\"n\":2}\n\n"` → both
events), a split multi-byte UTF-8 test, an EOF-dispatch test, and an
oversized-partial-line cap test.
### Verification
- `cargo test` — 219 passed, 0 failed
- `cargo clippy --all-targets -- -D warnings` — clean
- `cargo fmt --check` — clean
### Notes for the sequential FWD-07/08/10/12 task
- The parser rejects nothing on malformed UTF-8 inside a well-formed
frame (invalid bytes in a `data:` value are ignored — no error); the
only error is the buffer cap. If FWD-10/12 adds error-body handling,
the `SseParseError` enum in forward.rs is the place to extend.
- `SseParser::feed` is synchronous and pure; it holds no I/O, so the
unfold-state shape `(bytes_stream, parser, broken)` can be reshaped
freely without touching parser logic.