Skip to content

Commit bec6a12

Browse files
committed
fix(transport): ignore JSON-RPC frames that are not objects
1 parent 9d07d78 commit bec6a12

2 files changed

Lines changed: 42 additions & 1 deletion

File tree

‎src/acp/_transport.py‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,23 @@ async def receive(self) -> dict[str, Any] | None:
7979
if not line:
8080
continue
8181
try:
82-
message: dict[str, Any] = json.loads(line)
82+
message = json.loads(line)
8383
except Exception:
8484
logging.exception("Error parsing JSON-RPC message")
8585
continue
86+
if not isinstance(message, dict):
87+
# A line can parse as JSON and still not be a JSON-RPC message: a batch
88+
# array, a bare number or string, or ``null``. Returning it is fatal one
89+
# frame later -- ``Connection._process_message`` calls ``message.get(...)``
90+
# and the AttributeError escapes ``_receive_loop``, so ``_disconnect()``
91+
# never runs and the process dies. ``null`` is worse than fatal-by-accident:
92+
# it parses to ``None``, which this method uses as its EOF signal, so it is
93+
# read as "the peer hung up". Malformed JSON is already tolerated above, and
94+
# the web transports already refuse non-object frames (``ws/server.py``
95+
# returns only dicts; ``http/server.py`` answers 501/400), so ignore these
96+
# here too rather than tearing down a live connection.
97+
logging.warning("Ignoring non-object JSON-RPC message")
98+
continue
8699
return message
87100

88101
async def close(self) -> None:

‎tests/test_connection_recovery.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,31 @@ async def test_receive_loop_does_not_swallow_unrelated_reader_error() -> None:
121121
with pytest.raises(ValueError, match="reader failed"):
122122
await conn._receive_loop()
123123
await conn.close()
124+
125+
126+
@pytest.mark.asyncio
127+
async def test_receive_loop_ignores_frames_that_are_not_json_objects() -> None:
128+
"""A line can be valid JSON and still not be a JSON-RPC message.
129+
130+
Unparsable input is already skipped by ``NdjsonTransport.receive``. These frames parse
131+
fine but are not objects: an array, a number, a string and ``null``. Without the object
132+
guard they reach ``Connection._process_message`` -- which calls ``message.get(...)`` --
133+
or, for ``null``, are read as the transport's EOF signal. Either way the connection dies
134+
and the valid frame queued behind them is never handled.
135+
"""
136+
conn, reader = _make_connection()
137+
processed: list[str] = []
138+
139+
def tracking_process(message: dict[str, Any]) -> None:
140+
processed.append(message["method"])
141+
142+
conn._process_message = tracking_process # type: ignore[method-assign]
143+
non_objects = b"\n".join([b"[]", b"123", b'"x"', b"null", b"", b"not json at all"])
144+
survivor = {"jsonrpc": "2.0", "method": "survivor"}
145+
reader.feed_data(non_objects + b"\n" + json.dumps(survivor).encode() + b"\n")
146+
reader.feed_eof()
147+
148+
await conn._receive_loop()
149+
await conn.close()
150+
151+
assert processed == ["survivor"]

0 commit comments

Comments
 (0)