fix: normalize multi-round gateway streaming#136
Conversation
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
Signed-off-by: harivilasp <harivilasp@gmail.com>
ashwing
left a comment
There was a problem hiding this comment.
Reworked cleanly — the accumulator is owned now (no Arc<Mutex> guard held across .await), error frames route through error_chunk so they stay numbered, the synthetic/deferred events share one EventFrame/process_event path, and the multi-round test covers single-lifecycle + monotonic sequence + cross-round output_index. One question on normalize_sse_line below; non-blocking.
| .map_or(SSEEventType::Other, classify_event_type); | ||
|
|
||
| let sequence_number = json.get("sequence_number").and_then(Value::as_u64); | ||
| let event_type = SSEEventType::from(json.get("type")?.as_str()?); |
There was a problem hiding this comment.
The switch to SSEEventType::from(json.get("type")?.as_str()?) drops any data: frame that parses as JSON but has no string type — previously map_or(SSEEventType::Other, ...) kept it and forwarded it as Other. Given the "preserve lossless upstream frames" goal, is dropping intended here? Responses SSE always carries type in practice, so likely benign, but a typeless keepalive/data frame from a provider would now vanish silently instead of passing through. Either keeping the Other fallback for the type-missing case (distinct from malformed JSON, which should still drop) or a one-line comment noting the deliberate drop would make the intent clear — plus an assert!(normalize_sse_line("data: {\"foo\":1}").is_none()) (or .is_some()) to lock it in; the normalizer tests currently cover [DONE], malformed JSON, and unknown type, but not valid-JSON-without-type.
There was a problem hiding this comment.
Fixed in ae0f5b1. A missing or non-string type maps to Other; WireEvent preserves the absent wire field during serialization, so the frame relays losslessly. Added test_typeless_json_event_is_preserved.
maralbahari
left a comment
There was a problem hiding this comment.
@harivilasp thanks for the changes. I added a few concerns inline comments.
| let ch = exec_ctx.conv_handler.clone(); | ||
| let rh = exec_ctx.resp_handler.clone(); | ||
| if let Err(e) = persist_if_needed(payload, ctx, ch, rh).await { | ||
| if let Err(e) = persist_if_needed(payload.clone(), ctx, ch, rh).await { |
There was a problem hiding this comment.
why there is a need for payload.clone() ? the older version didnt require it.
There was a problem hiding this comment.
Removed in ae0f5b1. The terminal SSE chunk is now prepared before persistence, then the payload moves into persist_if_needed. This keeps persistence before client-visible emission without cloning the payload.
| .entry(item_id.clone()) | ||
| .or_default() | ||
| .push(line.to_owned()); | ||
| .push(frame.clone()); |
There was a problem hiding this comment.
the entire frame.clone() seems too expensive. see how can be it be avoided.
There was a problem hiding this comment.
Removed in ae0f5b1. ResponseAccumulator now processes the frame before stream handling, and deferred EventFrames are moved into the pending queue and moved out again on flush. No deep frame clone remains.
| pub(crate) fn terminal_response_chunk(&mut self, payload: &ResponsePayload) -> ExecutorResult<Option<String>> { | ||
| let mut frame = terminal_response_frame(payload)?; | ||
| if !self.process_event(&mut frame, 0) { | ||
| return Ok(None); |
There was a problem hiding this comment.
A payload with final status in_progress maps to SSEEventType::ResponseInProgress (line 95), which the lifecycle dedup has usually already consumed from the relayed stream, so this returns Ok(None) and the client gets [DONE] with no terminal response snapshot. The terminal event is not the same thing as the early lifecycle announcement; the previous revision always emitted it, and no test covers this case.
There was a problem hiding this comment.
Fixed in ae0f5b1. Terminal frames now bypass lifecycle dedup but still use the shared sequence stamper, so a final in_progress response is always emitted with the next sequence number. Added emits_in_progress_terminal_event_after_lifecycle_event.
| yield terminal_event; | ||
| match stream_accumulator.terminal_response_chunk(&payload) { | ||
| Ok(Some(chunk)) => yield chunk, | ||
| Ok(None) => {} |
There was a problem hiding this comment.
Ok(None) seems to be regression in behavior compared to the older version. Silently dropping the terminal chunk means the stream ends with bare [DONE] and no final response state. If Ok(None) is really possible here, it needs a log and a test; otherwise make the terminal chunk unsuppressible and delete this arm.
There was a problem hiding this comment.
Fixed in ae0f5b1 alongside the terminal lifecycle fix. terminal_response_chunk now always returns a serialized terminal frame, so the bare DONE path and the Ok(None) arm are gone.
Signed-off-by: harivilasp <harivilasp@gmail.com>
|
Thank you for the detailed review. All five points are addressed in ae0f5b1, with focused regressions plus the full local test and lint suite. |
Summary
Test Plan
cargo fmt -- --checkcargo clippy --all-targets -- -D warningscargo testpre-commit run --all-files