Skip to content

Commit ad48012

Browse files
CHLIN0BRlin-oPsiACE
authored
fix(ws): ignore non-object JSON frames in client transport (#169)
* fix(ws): ignore non-object JSON frames in client transport * fix(ws): report invalid frames without losing buffered messages --------- Co-authored-by: br-mbp <brend.main@gmail.com> Co-authored-by: Chojan Shang <psiace@apache.org>
1 parent 37290ca commit ad48012

3 files changed

Lines changed: 218 additions & 8 deletions

File tree

‎src/acp/ws/client.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from typing import TYPE_CHECKING, Any
1414

1515
from .._cookies import MemoryAcpCookieStore
16+
from ..exceptions import RequestError
1617

1718
try:
1819
import websockets
@@ -53,9 +54,21 @@ async def receive(self) -> dict[str, Any] | None:
5354
if isinstance(frame, bytes):
5455
continue
5556
try:
56-
return json.loads(frame)
57+
message = json.loads(frame)
5758
except json.JSONDecodeError:
58-
continue
59+
error = RequestError.parse_error()
60+
else:
61+
if isinstance(message, dict) and message:
62+
return message
63+
if isinstance(message, list) and message:
64+
# Batch support is outside this message-level transport.
65+
continue
66+
error = RequestError.invalid_request()
67+
# Sending while closing waits for EOF, which may require draining
68+
# buffered frames first. Keep receiving instead of waiting here.
69+
if self._ws.state == websockets.State.OPEN:
70+
with contextlib.suppress(websockets.ConnectionClosed):
71+
await self.send({"jsonrpc": "2.0", "id": None, "error": error.to_error_obj()})
5972

6073
async def close(self) -> None:
6174
if self._closed:

‎src/acp/ws/server.py‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22

33
from __future__ import annotations
44

5+
import contextlib
56
import json
67
import logging
78
import uuid
89
from typing import TYPE_CHECKING, Any
910

10-
from starlette.websockets import WebSocket, WebSocketState
11+
from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState
1112

1213
from ..agent.connection import AgentSideConnection
14+
from ..exceptions import RequestError
1315
from ..http.protocol import CONNECTION_ID_HEADER
1416

1517
if TYPE_CHECKING:
@@ -44,9 +46,18 @@ async def receive(self) -> dict[str, Any] | None:
4446
try:
4547
message = json.loads(event["text"])
4648
except json.JSONDecodeError:
47-
continue
48-
if isinstance(message, dict):
49-
return message
49+
error = RequestError.parse_error()
50+
else:
51+
if isinstance(message, dict) and message:
52+
return message
53+
if isinstance(message, list) and message:
54+
# Batch support is outside this message-level transport.
55+
continue
56+
error = RequestError.invalid_request()
57+
# A failed write must not discard valid messages buffered by ASGI.
58+
if self._ws.application_state == WebSocketState.CONNECTED:
59+
with contextlib.suppress(WebSocketDisconnect):
60+
await self.send({"jsonrpc": "2.0", "id": None, "error": error.to_error_obj()})
5061
return None
5162

5263
async def close(self) -> None:

‎tests/http/test_websocket.py‎

Lines changed: 188 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import contextlib
67
import json
78
import logging
89
from typing import Any
910

1011
import pytest
1112
from starlette.websockets import WebSocket
1213
from websockets.asyncio.server import serve
14+
from websockets.protocol import State
1315

16+
from acp.connection import Connection
1417
from acp.http.protocol import CONNECTION_ID_HEADER
1518
from acp.schema import NewSessionResponse, PromptResponse
1619
from acp.ws.client import create_websocket_stream
@@ -89,6 +92,155 @@ async def send_binary_then_text(ws: Any) -> None:
8992
await transport.close()
9093

9194

95+
@pytest.mark.asyncio
96+
@pytest.mark.parametrize("frame", ["null", "[]", "{}", "123", "1.5", '"x"', "true", "false", "invalid json", b"binary"])
97+
async def test_client_transport_receives_valid_message_after_invalid_frame(frame: str | bytes) -> None:
98+
message = {"jsonrpc": "2.0", "id": 0, "result": {"items": [], "value": None}}
99+
100+
async def send_frames(ws: Any) -> None:
101+
await ws.send(frame)
102+
await ws.send(json.dumps(message))
103+
await ws.close()
104+
105+
async with serve(send_frames, "127.0.0.1", 0) as server:
106+
port = server.sockets[0].getsockname()[1]
107+
transport = await create_websocket_stream(f"ws://127.0.0.1:{port}")
108+
try:
109+
assert await asyncio.wait_for(transport.receive(), timeout=1) == message
110+
assert await asyncio.wait_for(transport.receive(), timeout=1) is None
111+
finally:
112+
await transport.close()
113+
114+
115+
@pytest.mark.asyncio
116+
@pytest.mark.parametrize("frame", ["null", "[]", "{}"])
117+
async def test_client_connection_recovers_after_invalid_frame(frame: str) -> None:
118+
async def respond(ws: Any) -> None:
119+
request = json.loads(await ws.recv())
120+
await ws.send(frame)
121+
await ws.send(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": {"protocolVersion": 1}}))
122+
await ws.wait_closed()
123+
124+
async def handler(method: str, params: Any, is_notification: bool) -> None:
125+
pass
126+
127+
async with serve(respond, "127.0.0.1", 0) as server:
128+
port = server.sockets[0].getsockname()[1]
129+
transport = await create_websocket_stream(f"ws://127.0.0.1:{port}")
130+
async with Connection(handler, transport) as conn:
131+
result = await asyncio.wait_for(conn.send_request("initialize", {"protocolVersion": 1}), timeout=1)
132+
assert result == {"protocolVersion": 1}
133+
134+
135+
@pytest.mark.asyncio
136+
@pytest.mark.parametrize(
137+
("frame", "code", "message"),
138+
[
139+
("invalid json", -32700, "Parse error"),
140+
("null", -32600, "Invalid request"),
141+
("[]", -32600, "Invalid request"),
142+
("{}", -32600, "Invalid request"),
143+
("123", -32600, "Invalid request"),
144+
("1.5", -32600, "Invalid request"),
145+
('"x"', -32600, "Invalid request"),
146+
("true", -32600, "Invalid request"),
147+
("false", -32600, "Invalid request"),
148+
],
149+
)
150+
async def test_client_reports_protocol_error_then_receives_message(frame: str, code: int, message: str) -> None:
151+
valid = {"jsonrpc": "2.0", "id": "response", "result": None}
152+
replies = []
153+
154+
async def respond(ws: Any) -> None:
155+
await ws.send(frame)
156+
replies.append(json.loads(await ws.recv()))
157+
await ws.send(json.dumps(valid))
158+
await ws.wait_closed()
159+
160+
async with serve(respond, "127.0.0.1", 0) as server:
161+
port = server.sockets[0].getsockname()[1]
162+
transport = await create_websocket_stream(f"ws://127.0.0.1:{port}")
163+
try:
164+
assert await asyncio.wait_for(transport.receive(), timeout=1) == valid
165+
assert replies == [
166+
{"jsonrpc": "2.0", "id": None, "error": {"code": code, "message": message, "data": None}}
167+
]
168+
finally:
169+
await transport.close()
170+
171+
172+
@pytest.mark.asyncio
173+
@pytest.mark.parametrize("code", [1000, 1011])
174+
async def test_client_drains_buffered_response_when_error_reply_cannot_be_sent(code: int) -> None:
175+
closed = asyncio.Event()
176+
valid = {"jsonrpc": "2.0", "id": 0, "result": {"items": [], "value": None}}
177+
178+
async def respond(ws: Any) -> None:
179+
await ws.send("invalid json")
180+
await ws.send("null")
181+
await ws.send(json.dumps(valid))
182+
await ws.close(code=code)
183+
closed.set()
184+
185+
async with serve(respond, "127.0.0.1", 0) as server:
186+
port = server.sockets[0].getsockname()[1]
187+
transport = await create_websocket_stream(f"ws://127.0.0.1:{port}")
188+
try:
189+
await asyncio.wait_for(closed.wait(), timeout=1)
190+
assert await asyncio.wait_for(transport.receive(), timeout=1) == valid
191+
assert await asyncio.wait_for(transport.receive(), timeout=1) is None
192+
finally:
193+
await transport.close()
194+
195+
196+
@pytest.mark.asyncio
197+
async def test_client_drains_buffered_response_before_close_handshake_finishes(monkeypatch: pytest.MonkeyPatch) -> None:
198+
from websockets.asyncio.client import connect
199+
200+
closing = asyncio.Event()
201+
release = asyncio.Event()
202+
valid = {"jsonrpc": "2.0", "id": 0, "result": None}
203+
connection: Any = None
204+
205+
async def open_socket(url: str, **kwargs: Any) -> Any:
206+
nonlocal connection
207+
connection = await connect(url, **kwargs)
208+
return connection
209+
210+
monkeypatch.setattr("acp.ws.client.ws_connect", open_socket)
211+
212+
async def respond(ws: Any) -> None:
213+
await ws.send("null")
214+
await ws.send(json.dumps(valid))
215+
ws.transport.pause_reading()
216+
close = asyncio.create_task(ws.close())
217+
try:
218+
while ws.state == State.OPEN:
219+
await asyncio.sleep(0)
220+
closing.set()
221+
await release.wait()
222+
finally:
223+
ws.transport.resume_reading()
224+
await close
225+
226+
async with serve(respond, "127.0.0.1", 0) as server:
227+
port = server.sockets[0].getsockname()[1]
228+
transport = await create_websocket_stream(f"ws://127.0.0.1:{port}")
229+
try:
230+
231+
async def wait_for_closing() -> None:
232+
await closing.wait()
233+
while connection.state == State.OPEN:
234+
await asyncio.sleep(0)
235+
236+
await asyncio.wait_for(wait_for_closing(), timeout=1)
237+
assert await asyncio.wait_for(transport.receive(), timeout=1) == valid
238+
finally:
239+
release.set()
240+
with contextlib.suppress(Exception):
241+
await transport.close()
242+
243+
92244
# -- ASGI websocket server handler ---------------------------------------------
93245

94246

@@ -186,10 +338,12 @@ async def prompt(self, session_id: str, prompt: Any = None, **kwargs: Any) -> Pr
186338

187339

188340
@pytest.mark.asyncio
189-
async def test_asgi_websocket_ignores_non_message_frames() -> None:
341+
async def test_asgi_websocket_reports_invalid_frames_and_ignores_binary() -> None:
190342
socket = _FakeAsgiSocket()
191343
socket.client_connect()
192-
for frame in ({"bytes": b"binary"}, {"text": "invalid json"}, {"text": "[]"}, {"text": "null"}):
344+
frames = [b"binary", "invalid json", "[]", "{}", "null", "123", "1.5", '"x"', "true", "false"]
345+
for frame in frames:
346+
frame = {"bytes": frame} if isinstance(frame, bytes) else {"text": frame}
193347
socket._incoming.put_nowait({"type": "websocket.receive", **frame})
194348
handler = asyncio.create_task(
195349
handle_websocket(lambda conn: _Agent(), WebSocket({"type": "websocket"}, socket.receive, socket.send))
@@ -198,6 +352,38 @@ async def test_asgi_websocket_ignores_non_message_frames() -> None:
198352
socket.client_send_text({"jsonrpc": "2.0", "id": 0, "method": "initialize", "params": {"protocolVersion": 1}})
199353
response = await socket.wait_for_send(lambda m: m.get("id") == 0)
200354
assert "result" in response
355+
assert [m["error"]["code"] for m in socket.sent if "error" in m] == [-32700, *([-32600] * 8)]
356+
assert all(m["id"] is None and m["jsonrpc"] == "2.0" for m in socket.sent if "error" in m)
357+
finally:
358+
socket.client_disconnect()
359+
await asyncio.wait_for(handler, timeout=1)
360+
361+
362+
@pytest.mark.asyncio
363+
async def test_asgi_websocket_delivers_notification_after_error_reply_fails() -> None:
364+
received = asyncio.Event()
365+
366+
class Agent(_Agent):
367+
async def cancel(self, session_id: str, **kwargs: Any) -> None:
368+
assert session_id == "sess-ws"
369+
received.set()
370+
371+
socket = _FakeAsgiSocket()
372+
socket.client_connect()
373+
374+
async def failed_send(message: dict[str, Any]) -> None:
375+
if message["type"] == "websocket.send":
376+
raise OSError("Peer closed")
377+
await socket.send(message)
378+
379+
for text in ("invalid json", "null", "{}"):
380+
socket._incoming.put_nowait({"type": "websocket.receive", "text": text})
381+
socket.client_send_text({"jsonrpc": "2.0", "method": "session/cancel", "params": {"sessionId": "sess-ws"}})
382+
handler = asyncio.create_task(
383+
handle_websocket(lambda conn: Agent(), WebSocket({"type": "websocket"}, socket.receive, failed_send))
384+
)
385+
try:
386+
await asyncio.wait_for(received.wait(), timeout=1)
201387
finally:
202388
socket.client_disconnect()
203389
await asyncio.wait_for(handler, timeout=1)

0 commit comments

Comments
 (0)