Skip to content

Commit eddc8c6

Browse files
Brutus5000claude
andcommitted
fix(ws): send binary frames so QWebSocket clients receive them
The Python desktop client (FAForever/client, PyQt6 QWebSocket) listens on binaryMessageReceived only, so server-to-client traffic was silently dropped on the floor after the WS handshake. The Kotlin client reads the raw byte stream as UTF-8 regardless of frame type, so binary works for both clients. Switch write_raw from send_str to send_bytes. Tests follow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0a27e07 commit eddc8c6

3 files changed

Lines changed: 21 additions & 16 deletions

File tree

server/protocol/websocket.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""WebSocket wire protocol: one JSON message per text frame, no extra framing."""
1+
"""WebSocket wire protocol: newline-terminated JSON inside binary WS frames."""
22

33
import asyncio
44
import contextlib
@@ -48,8 +48,13 @@ def write_raw(self, data: bytes) -> None:
4848
if not self.is_connected():
4949
raise DisconnectedError("Protocol is not connected!")
5050

51-
text = data.decode() if isinstance(data, (bytes, bytearray)) else data
52-
task = asyncio.create_task(self.ws.send_str(text))
51+
# Send as a binary frame: the legacy Python desktop client (PyQt6
52+
# QWebSocket) only listens on binaryMessageReceived, and the Kotlin
53+
# client reads the raw byte stream as UTF-8 regardless of frame type,
54+
# so binary is the lowest common denominator.
55+
if isinstance(data, str):
56+
data = data.encode()
57+
task = asyncio.create_task(self.ws.send_bytes(data))
5358
self._pending.add(task)
5459
task.add_done_callback(self._pending.discard)
5560

tests/integration_tests/test_servercontext.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async def test_serverside_abort(
9393
ctx = mock_context
9494
async with aiohttp.ClientSession() as session:
9595
async with session.ws_connect(ws_url(ctx)) as ws:
96-
await ws.send_str('{"some_junk": true}')
96+
await ws.send_bytes(b'{"some_junk": true}\n')
9797
await exhaust_callbacks()
9898

9999
# Allow server-side disconnect handler to run.

tests/unit_tests/test_websocket_protocol.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,15 @@ async def test_write_raw_when_disconnected_raises():
7171
proto.write_raw(b'{"command":"ping"}')
7272

7373

74-
async def test_send_message_routes_through_send_str():
74+
async def test_send_message_routes_through_send_bytes():
7575
ws = mock.MagicMock()
7676
ws.closed = False
77-
ws.send_str = mock.AsyncMock()
77+
ws.send_bytes = mock.AsyncMock()
7878

7979
proto = WebSocketProtocol(ws)
8080
await proto.send_message({"command": "ping"})
8181

82-
ws.send_str.assert_awaited_once_with('{"command":"ping"}\n')
82+
ws.send_bytes.assert_awaited_once_with(b'{"command":"ping"}\n')
8383

8484

8585
async def test_write_message_when_disconnected_raises():
@@ -91,28 +91,28 @@ async def test_write_message_when_disconnected_raises():
9191
proto.write_message({"command": "ping"})
9292

9393

94-
async def test_write_message_routes_through_send_str():
94+
async def test_write_message_routes_through_send_bytes():
9595
ws = mock.MagicMock()
9696
ws.closed = False
97-
ws.send_str = mock.AsyncMock()
97+
ws.send_bytes = mock.AsyncMock()
9898

9999
proto = WebSocketProtocol(ws)
100100
proto.write_message({"command": "ping"})
101101
await proto.drain()
102102

103-
ws.send_str.assert_awaited_once_with('{"command":"ping"}\n')
103+
ws.send_bytes.assert_awaited_once_with(b'{"command":"ping"}\n')
104104

105105

106106
async def test_write_messages_sends_each():
107107
ws = mock.MagicMock()
108108
ws.closed = False
109-
ws.send_str = mock.AsyncMock()
109+
ws.send_bytes = mock.AsyncMock()
110110

111111
proto = WebSocketProtocol(ws)
112112
proto.write_messages([{"command": "ping"}, {"command": "pong"}])
113113
await proto.drain()
114114

115-
assert ws.send_str.await_count == 2
115+
assert ws.send_bytes.await_count == 2
116116

117117

118118
async def test_write_messages_when_disconnected_raises():
@@ -135,7 +135,7 @@ async def test_drain_no_pending_returns_immediately():
135135
async def test_drain_propagates_failure_as_disconnected():
136136
ws = mock.MagicMock()
137137
ws.closed = False
138-
ws.send_str = mock.AsyncMock(side_effect=RuntimeError("boom"))
138+
ws.send_bytes = mock.AsyncMock(side_effect=RuntimeError("boom"))
139139
ws.close = mock.AsyncMock()
140140

141141
proto = WebSocketProtocol(ws)
@@ -153,7 +153,7 @@ async def test_abort_cancels_pending_and_closes_ws():
153153
async def slow_send(*_args, **_kwargs):
154154
await asyncio.sleep(10)
155155

156-
ws.send_str = mock.AsyncMock(side_effect=slow_send)
156+
ws.send_bytes = mock.AsyncMock(side_effect=slow_send)
157157
ws.close = mock.AsyncMock()
158158

159159
proto = WebSocketProtocol(ws)
@@ -219,9 +219,9 @@ async def handler(request: web.Request) -> web.WebSocketResponse:
219219
import aiohttp
220220
async with aiohttp.ClientSession() as session:
221221
async with session.ws_connect(f"http://127.0.0.1:{port}/ws") as ws:
222-
await ws.send_str('{"command":"ping"}')
222+
await ws.send_bytes(b'{"command":"ping"}\n')
223223
reply = await ws.receive()
224-
assert reply.type == WSMsgType.TEXT
224+
assert reply.type == WSMsgType.BINARY
225225
assert json.loads(reply.data) == {"command": "pong"}
226226
finally:
227227
await runner.cleanup()

0 commit comments

Comments
 (0)