Skip to content

Commit 450fab5

Browse files
frostmingclaude
andauthored
fix(ws): warn when the server ignores binary frames (#166)
The WebSocket server silently dropped binary frames, so a client that sends JSON-RPC as binary saw initialize hang with no diagnostic. Keep ignoring them per the transport RFD, but log a warning once per connection. Refs #162 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 37682e1 commit 450fab5

3 files changed

Lines changed: 33 additions & 1 deletion

File tree

‎docs/web-transport.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ The SDK can run ACP over two remote connectivity profiles in addition to stdio:
1010
session), and `DELETE` to terminate. `initialize` returns `200 OK` with a JSON
1111
body; all other POSTs return `202 Accepted`. **Requires HTTP/2.**
1212
- **WebSocket** — a `GET` upgrade on the same endpoint carrying full-duplex
13-
JSON-RPC text frames.
13+
JSON-RPC text frames, one message per frame. Binary frames are ignored, as
14+
the transport RFD specifies; the server logs a warning on the first one.
1415

1516
Both reuse the existing JSON-RPC message format and ACP lifecycle
1617
(`initialize` → session methods → close).

‎src/acp/ws/server.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import logging
67
import uuid
78
from typing import TYPE_CHECKING, Any
89

@@ -22,6 +23,7 @@ class _WebSocketTransport:
2223

2324
def __init__(self, websocket: WebSocket) -> None:
2425
self._ws = websocket
26+
self._warned_binary = False
2527

2628
async def send(self, message: dict[str, Any]) -> None:
2729
if self._ws.client_state == WebSocketState.DISCONNECTED:
@@ -34,6 +36,9 @@ async def receive(self) -> dict[str, Any] | None:
3436
if event["type"] == "websocket.disconnect":
3537
return None
3638
if event.get("text") is None:
39+
if event.get("bytes") is not None and not self._warned_binary:
40+
self._warned_binary = True
41+
logging.warning("Ignoring binary WebSocket frame; ACP messages must be sent as text frames")
3742
continue
3843
try:
3944
message = json.loads(event["text"])

‎tests/http/test_websocket.py‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import asyncio
66
import json
7+
import logging
78
from typing import Any
89

910
import pytest
@@ -202,6 +203,31 @@ async def test_asgi_websocket_ignores_non_message_frames() -> None:
202203
await asyncio.wait_for(handler, timeout=1)
203204

204205

206+
@pytest.mark.asyncio
207+
async def test_asgi_websocket_warns_once_on_binary_frames(caplog: pytest.LogCaptureFixture) -> None:
208+
socket = _FakeAsgiSocket()
209+
socket.client_connect()
210+
for _ in range(2):
211+
socket._incoming.put_nowait({"type": "websocket.receive", "bytes": b'{"jsonrpc":"2.0"}\n'})
212+
handler = asyncio.create_task(
213+
handle_websocket(lambda conn: _Agent(), WebSocket({"type": "websocket"}, socket.receive, socket.send))
214+
)
215+
try:
216+
with caplog.at_level(logging.WARNING):
217+
socket.client_send_text({
218+
"jsonrpc": "2.0",
219+
"id": 0,
220+
"method": "initialize",
221+
"params": {"protocolVersion": 1},
222+
})
223+
await socket.wait_for_send(lambda m: m.get("id") == 0)
224+
warnings = [r for r in caplog.records if "binary WebSocket frame" in r.getMessage()]
225+
assert len(warnings) == 1
226+
finally:
227+
socket.client_disconnect()
228+
await asyncio.wait_for(handler, timeout=1)
229+
230+
205231
@pytest.mark.asyncio
206232
async def test_asgi_websocket_full_flow() -> None:
207233
socket = _FakeAsgiSocket()

0 commit comments

Comments
 (0)