From e245d1f444e716c7dfe6cc21cb40c4a924c96cd9 Mon Sep 17 00:00:00 2001 From: Xin Deng <317994691+xdeng-dev@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:37:15 +0800 Subject: [PATCH] fix(sse): normalize CR/LF line endings in frame parser The W3C SSE spec allows \r\n, \r, or \n as line separators. Some upstream providers (notably Azure-hosted OpenAI models) send \r\n\r\n as the frame delimiter instead of \n\n. Without normalization the parser never detects the frame boundary and buffers the entire response until the connection closes, yielding nothing to the protocol adapter. Normalize the buffer to \n after each append so all three line-ending forms are handled correctly. Add regression tests covering CRLF, mixed, and bare-CR frame boundaries. --- app/protocols/sse.py | 5 +++ tests/unit/test_sse_crlf_parsing.py | 65 +++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 tests/unit/test_sse_crlf_parsing.py diff --git a/app/protocols/sse.py b/app/protocols/sse.py index 721a765..c45284f 100644 --- a/app/protocols/sse.py +++ b/app/protocols/sse.py @@ -130,6 +130,11 @@ async def _iter(self) -> AsyncGenerator[dict, None]: if isinstance(piece, bytes): piece = piece.decode("utf-8") buffer += piece + # The SSE spec (W3C) allows \r\n, \r, or \n as line endings. + # Some upstream providers (notably Azure-hosted models) send + # \r\n\r\n as the frame delimiter instead of \n\n. Normalize + # to \n so the split below catches all three forms. + buffer = buffer.replace("\r\n", "\n").replace("\r", "\n") while "\n\n" in buffer: frame, buffer = buffer.split("\n\n", 1) for line in frame.splitlines(): diff --git a/tests/unit/test_sse_crlf_parsing.py b/tests/unit/test_sse_crlf_parsing.py new file mode 100644 index 0000000..2479135 --- /dev/null +++ b/tests/unit/test_sse_crlf_parsing.py @@ -0,0 +1,65 @@ +"""Regression: SSE frame parser must handle \\r\\n line endings. + +The W3C SSE spec allows \\r\\n, \\r, or \\n as line separators. Some upstream +providers (notably Azure-hosted OpenAI models) send \\r\\n\\r\\n as the frame +delimiter. Without normalization the parser never finds the \\n\\n boundary +and buffers the entire response in memory until the connection closes, +at which point it yields nothing.""" + +from __future__ import annotations + +import pytest + +from app.protocols.sse import OpenAIFrameStream + + +async def _collect(stream: OpenAIFrameStream) -> list[dict]: + frames = [] + async for frame in stream: + frames.append(frame) + return frames + + +@pytest.mark.asyncio +async def test_sse_parses_crlf_frame_boundaries(): + """Frames separated by \\r\\n\\r\\n must parse identically to \\n\\n.""" + async def source(): + yield b"data: {\"id\":\"1\",\"content\":\"hello\"}\r\n\r\n" + yield b"data: [DONE]\r\n\r\n" + + stream = OpenAIFrameStream(source()) + frames = await _collect(stream) + assert len(frames) == 1 + assert frames[0]["id"] == "1" + assert frames[0]["content"] == "hello" + assert stream._done is True + + +@pytest.mark.asyncio +async def test_sse_parses_mixed_line_endings(): + """A stream mixing \\r\\n and \\n must still parse correctly.""" + async def source(): + yield "data: {\"id\":\"a\"}\r\n\r\n" + yield "data: {\"id\":\"b\"}\n\n" + yield "data: [DONE]\r\n\r\n" + + stream = OpenAIFrameStream(source()) + frames = await _collect(stream) + assert len(frames) == 2 + assert frames[0]["id"] == "a" + assert frames[1]["id"] == "b" + assert stream._done is True + + +@pytest.mark.asyncio +async def test_sse_parses_cr_only_line_endings(): + """Bare \\r (classic Mac-style) is a valid SSE line ending.""" + async def source(): + yield "data: {\"id\":\"x\"}\r\r" + yield "data: [DONE]\r\r" + + stream = OpenAIFrameStream(source()) + frames = await _collect(stream) + assert len(frames) == 1 + assert frames[0]["id"] == "x" + assert stream._done is True