Skip to content

Commit fc43acb

Browse files
authored
fix(connection): ignore responses whose id is not a RequestId (#167)
Validate response IDs before matching pending requests.
1 parent ad48012 commit fc43acb

2 files changed

Lines changed: 62 additions & 0 deletions

File tree

‎src/acp/connection.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,12 @@ async def _run_notification(self, message: dict[str, Any]) -> None:
299299

300300
def _handle_response(self, message: dict[str, Any]) -> None:
301301
request_id = message["id"]
302+
# A response can only answer a request this side sent, so its id must be a valid
303+
# ``RequestId``. An unhashable one would raise on the lookup below and escape the
304+
# receive loop, and Python would alias an invalid one such as ``true`` or ``1.0``
305+
# with the integer ``1`` -- the same aliasing ``_track_incoming`` guards against.
306+
if not _is_request_id(request_id):
307+
return
302308
future = self._pending.pop(request_id, None)
303309
if future is None or future.done():
304310
return

‎tests/test_connection_recovery.py‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import contextlib
45
import json
56
from typing import Any
67
from unittest.mock import AsyncMock, MagicMock
@@ -149,3 +150,58 @@ def tracking_process(message: dict[str, Any]) -> None:
149150
await conn.close()
150151

151152
assert processed == ["survivor"]
153+
154+
155+
@pytest.mark.asyncio
156+
async def test_receive_loop_ignores_responses_with_an_invalid_id() -> None:
157+
"""A response whose ``id`` is not a ``RequestId`` must not answer anyone.
158+
159+
``_handle_response`` looks the id up in ``_pending``. An array or an object raises there,
160+
which escapes the receive loop, disconnects, and leaves the valid frame behind it
161+
unhandled. ``true`` and ``1.0`` are worse than a crash: they are equal to the integer
162+
``1``, so they resolve a different request's future -- the aliasing ``_track_incoming``
163+
already refuses on the way out.
164+
"""
165+
conn, reader = _make_connection()
166+
original = asyncio.get_running_loop().create_future()
167+
conn._pending[1] = original
168+
# The real dispatch is left in place: a response reaches ``_handle_response``, which is
169+
# where the id is used. The loop is not fed EOF, so it stays alive and ``_disconnect``
170+
# does not reject the pending future before it can be inspected.
171+
handled: list[str] = []
172+
real_track = conn._track_incoming
173+
174+
def tracking_track(request_id: Any, task: asyncio.Task[Any]) -> None:
175+
handled.append("request")
176+
real_track(request_id, task)
177+
178+
conn._track_incoming = tracking_track # type: ignore[method-assign]
179+
180+
invalid = b"\n".join([
181+
json.dumps({"jsonrpc": "2.0", "id": [], "result": "array"}).encode(),
182+
json.dumps({"jsonrpc": "2.0", "id": {}, "result": "object"}).encode(),
183+
json.dumps({"jsonrpc": "2.0", "id": True, "result": "aliased"}).encode(),
184+
json.dumps({"jsonrpc": "2.0", "id": 1.0, "result": "aliased"}).encode(),
185+
])
186+
survivor = {"jsonrpc": "2.0", "method": "survivor", "id": 2}
187+
reader.feed_data(invalid + b"\n" + json.dumps(survivor).encode() + b"\n")
188+
189+
loop_task = asyncio.create_task(conn._receive_loop())
190+
for _ in range(200):
191+
await asyncio.sleep(0)
192+
if handled and 1 not in conn._incoming:
193+
break
194+
195+
# None of the four bad ids answered the integer-1 request, and the loop kept going: the
196+
# valid request behind them was dispatched. Request 2 answers nothing and is cleaned up,
197+
# so only the unanswered request 1 remains pending.
198+
assert not original.done()
199+
assert sorted(conn._pending) == [1]
200+
assert handled == ["request"]
201+
202+
loop_task.cancel()
203+
with contextlib.suppress(asyncio.CancelledError):
204+
await loop_task
205+
206+
conn._pending.clear()
207+
await conn.close()

0 commit comments

Comments
 (0)