From bec6a1220ab6a2a8593cc93f2f4fdf097a08dbcb Mon Sep 17 00:00:00 2001 From: PerryLink Date: Wed, 23 Sep 2026 13:17:09 +0800 Subject: [PATCH] fix(transport): ignore JSON-RPC frames that are not objects --- src/acp/_transport.py | 15 ++++++++++++++- tests/test_connection_recovery.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/acp/_transport.py b/src/acp/_transport.py index 9aa7ecc..767fc1e 100644 --- a/src/acp/_transport.py +++ b/src/acp/_transport.py @@ -79,10 +79,23 @@ async def receive(self) -> dict[str, Any] | None: if not line: continue try: - message: dict[str, Any] = json.loads(line) + message = json.loads(line) except Exception: logging.exception("Error parsing JSON-RPC message") continue + if not isinstance(message, dict): + # A line can parse as JSON and still not be a JSON-RPC message: a batch + # array, a bare number or string, or ``null``. Returning it is fatal one + # frame later -- ``Connection._process_message`` calls ``message.get(...)`` + # and the AttributeError escapes ``_receive_loop``, so ``_disconnect()`` + # never runs and the process dies. ``null`` is worse than fatal-by-accident: + # it parses to ``None``, which this method uses as its EOF signal, so it is + # read as "the peer hung up". Malformed JSON is already tolerated above, and + # the web transports already refuse non-object frames (``ws/server.py`` + # returns only dicts; ``http/server.py`` answers 501/400), so ignore these + # here too rather than tearing down a live connection. + logging.warning("Ignoring non-object JSON-RPC message") + continue return message async def close(self) -> None: diff --git a/tests/test_connection_recovery.py b/tests/test_connection_recovery.py index 77059d3..67d58d3 100644 --- a/tests/test_connection_recovery.py +++ b/tests/test_connection_recovery.py @@ -121,3 +121,31 @@ async def test_receive_loop_does_not_swallow_unrelated_reader_error() -> None: with pytest.raises(ValueError, match="reader failed"): await conn._receive_loop() await conn.close() + + +@pytest.mark.asyncio +async def test_receive_loop_ignores_frames_that_are_not_json_objects() -> None: + """A line can be valid JSON and still not be a JSON-RPC message. + + Unparsable input is already skipped by ``NdjsonTransport.receive``. These frames parse + fine but are not objects: an array, a number, a string and ``null``. Without the object + guard they reach ``Connection._process_message`` -- which calls ``message.get(...)`` -- + or, for ``null``, are read as the transport's EOF signal. Either way the connection dies + and the valid frame queued behind them is never handled. + """ + conn, reader = _make_connection() + processed: list[str] = [] + + def tracking_process(message: dict[str, Any]) -> None: + processed.append(message["method"]) + + conn._process_message = tracking_process # type: ignore[method-assign] + non_objects = b"\n".join([b"[]", b"123", b'"x"', b"null", b"", b"not json at all"]) + survivor = {"jsonrpc": "2.0", "method": "survivor"} + reader.feed_data(non_objects + b"\n" + json.dumps(survivor).encode() + b"\n") + reader.feed_eof() + + await conn._receive_loop() + await conn.close() + + assert processed == ["survivor"]