Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/acp/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions tests/test_connection_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]