Skip to content

Commit 6e38d7b

Browse files
committed
fix(http): skip non-object JSON in SSE streams
1 parent ad48012 commit 6e38d7b

3 files changed

Lines changed: 30 additions & 1 deletion

File tree

‎src/acp/_sse.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,10 @@ def _decode_event(data_lines: list[str]) -> dict[str, Any] | None:
4242
if not payload:
4343
return None
4444
try:
45-
return json.loads(payload)
45+
message = json.loads(payload)
4646
except json.JSONDecodeError:
4747
return None
48+
return message if isinstance(message, dict) else None
4849

4950

5051
def _append_field(line: str, data_lines: list[str]) -> None:
@@ -60,6 +61,7 @@ async def parse_sse_stream(chunks: AsyncIterator[bytes]) -> AsyncIterator[dict[s
6061
"""Parse an SSE byte stream, yielding decoded JSON-RPC ``data:`` payloads.
6162
6263
Comments (lines starting with ``:``) and non-``data`` fields are ignored.
64+
Invalid JSON and non-object JSON payloads are skipped.
6365
Multi-line ``data:`` fields are concatenated with newlines per the spec. A
6466
blank line dispatches the buffered event.
6567
"""

‎tests/http/test_http_client.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,25 @@ async def test_connection_scoped_sse_delivers_new_session_result() -> None:
135135
await client.aclose()
136136

137137

138+
@pytest.mark.asyncio
139+
@pytest.mark.parametrize("payload", [b"[]", b'"text"', b"42"])
140+
async def test_connection_scoped_sse_skips_non_object_json(payload: bytes) -> None:
141+
server = FakeServer()
142+
transport, client = _make_transport(server)
143+
try:
144+
await transport.send({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {}})
145+
await asyncio.wait_for(transport.receive(), timeout=1)
146+
await transport.send({"jsonrpc": "2.0", "id": 1, "method": "session/new", "params": {}})
147+
server.conn_stream.put_nowait(b"data: " + payload + b"\n\n")
148+
response = {"jsonrpc": "2.0", "id": 1, "result": {"sessionId": "sess-1"}}
149+
server.push_conn(response)
150+
151+
assert await asyncio.wait_for(transport.receive(), timeout=1) == response
152+
finally:
153+
await transport.close()
154+
await client.aclose()
155+
156+
138157
@pytest.mark.asyncio
139158
async def test_session_scoped_sse_opens_after_new_session() -> None:
140159
server = FakeServer()

‎tests/http/test_sse.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,11 @@ async def test_parse_skips_invalid_json() -> None:
7272
stream = _aiter([b'data: not-json\n\ndata: {"id":1}\n\n'])
7373
events = [event async for event in parse_sse_stream(stream)]
7474
assert events == [{"id": 1}]
75+
76+
77+
@pytest.mark.asyncio
78+
@pytest.mark.parametrize("payload", [b"[]", b'[ {"id":2} ]', b'"text"', b"42", b"true", b"false", b"null"])
79+
async def test_parse_skips_non_object_json(payload: bytes) -> None:
80+
stream = _aiter([b"data: " + payload + b'\n\ndata: {"id":1}\n\n'])
81+
events = [event async for event in parse_sse_stream(stream)]
82+
assert events == [{"id": 1}]

0 commit comments

Comments
 (0)