|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
3 | 3 | import asyncio |
| 4 | +import contextlib |
4 | 5 | import json |
5 | 6 | from typing import Any |
6 | 7 | from unittest.mock import AsyncMock, MagicMock |
@@ -149,3 +150,58 @@ def tracking_process(message: dict[str, Any]) -> None: |
149 | 150 | await conn.close() |
150 | 151 |
|
151 | 152 | 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