Skip to content

Commit aa6d77f

Browse files
committed
fix(connection): settle responses before removing pending requests
1 parent 11f8a4c commit aa6d77f

2 files changed

Lines changed: 239 additions & 12 deletions

File tree

‎src/acp/connection.py‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -305,19 +305,23 @@ def _handle_response(self, message: dict[str, Any]) -> None:
305305
# with the integer ``1`` -- the same aliasing ``_track_incoming`` guards against.
306306
if not _is_request_id(request_id):
307307
return
308-
future = self._pending.pop(request_id, None)
309-
if future is None or future.done():
308+
future = self._pending.get(request_id)
309+
if future is None:
310310
return
311-
if "result" in message:
312-
future.set_result(message.get("result"))
313-
return
314-
if "error" in message:
315-
error_obj = message.get("error") or {}
316-
future.set_exception(
317-
RequestError(error_obj.get("code", -32603), error_obj.get("message", "Error"), error_obj.get("data"))
318-
)
319-
return
320-
future.set_result(None)
311+
# Keep the request tracked until its response has been settled.
312+
if not future.done():
313+
if "result" in message:
314+
future.set_result(message.get("result"))
315+
elif "error" in message:
316+
error_obj = message.get("error") or {}
317+
future.set_exception(
318+
RequestError(
319+
error_obj.get("code", -32603), error_obj.get("message", "Error"), error_obj.get("data")
320+
)
321+
)
322+
else:
323+
future.set_result(None)
324+
self._pending.pop(request_id, None)
321325

322326
def _on_receive_error(self, task: asyncio.Task[Any], exc: BaseException) -> None:
323327
logging.exception("Receive loop failed", exc_info=exc)

‎tests/test_rpc.py‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,3 +975,226 @@ async def test_connection_init_under_eager_task_factory(server):
975975
await asyncio.sleep(0)
976976
assert conn._disconnected is False
977977
await conn.close()
978+
979+
980+
async def _response_handler(method: str, params: Any, is_notification: bool) -> None:
981+
return None
982+
983+
984+
async def _start_response_requests(
985+
conn: Connection, peer: asyncio.StreamReader
986+
) -> tuple[list[asyncio.Task[Any]], list[dict[str, Any]]]:
987+
tasks = [asyncio.create_task(conn.send_request("probe", {"index": index})) for index in range(2)]
988+
frames = [json.loads(await asyncio.wait_for(peer.readline(), timeout=1)) for _ in tasks]
989+
assert [frame["id"] for frame in frames] == [0, 1]
990+
return tasks, frames
991+
992+
993+
async def _send_responses(writer: asyncio.StreamWriter, *messages: dict[str, Any]) -> None:
994+
writer.write("".join(json.dumps(message) + "\n" for message in messages).encode())
995+
await writer.drain()
996+
997+
998+
async def _cleanup_response_requests(
999+
conn: Connection, tasks: list[asyncio.Task[Any]], receive_loop: asyncio.Task[Any] | None = None
1000+
) -> None:
1001+
for task in tasks:
1002+
if not task.done():
1003+
task.cancel()
1004+
await asyncio.gather(*tasks, return_exceptions=True)
1005+
await conn.close()
1006+
if receive_loop is not None:
1007+
if not receive_loop.done():
1008+
receive_loop.cancel()
1009+
await asyncio.gather(receive_loop, return_exceptions=True)
1010+
1011+
1012+
@pytest.mark.asyncio
1013+
@pytest.mark.parametrize("error", [["malformed"], "malformed", 7, True])
1014+
@pytest.mark.parametrize("manual", [False, True], ids=["supervised", "main_loop"])
1015+
async def test_bad_response_rejects_all_requests_before_close(server, error: Any, manual: bool) -> None:
1016+
conn = Connection(_response_handler, server.client_writer, server.client_reader, listening=not manual)
1017+
receive_loop = asyncio.create_task(conn.main_loop()) if manual else None
1018+
tasks: list[asyncio.Task[Any]] = []
1019+
try:
1020+
tasks, frames = await _start_response_requests(conn, server.server_reader)
1021+
await _send_responses(server.server_writer, {"jsonrpc": "2.0", "id": frames[0]["id"], "error": error})
1022+
done, pending = await asyncio.wait(tasks, timeout=1)
1023+
# Observe automatic cleanup before close(), without cancelling requests on timeout.
1024+
assert not pending, f"orphaned requests: {len(pending)}"
1025+
assert len(done) == 2
1026+
for task in tasks:
1027+
assert isinstance(task.exception(), ConnectionError)
1028+
assert conn._disconnected
1029+
assert not conn._pending
1030+
if receive_loop is not None:
1031+
with pytest.raises(AttributeError):
1032+
await receive_loop
1033+
finally:
1034+
await _cleanup_response_requests(conn, tasks, receive_loop)
1035+
1036+
1037+
@pytest.mark.asyncio
1038+
@pytest.mark.parametrize(
1039+
("response", "value", "error"),
1040+
[
1041+
({"result": {"value": 1}}, {"value": 1}, None),
1042+
({"result": None}, None, None),
1043+
({"result": False}, False, None),
1044+
({"result": "ok", "error": ["ignored"]}, "ok", None),
1045+
({}, None, None),
1046+
({"error": {"code": -32602, "message": "bad", "data": {"x": 1}}}, None, (-32602, "bad", {"x": 1})),
1047+
*[({"error": item}, None, (-32603, "Error", None)) for item in [{}, [], "", 0, False, None]],
1048+
({"error": {"code": [], "message": "Error"}}, None, ([], "Error", None)),
1049+
],
1050+
)
1051+
async def test_response_compatibility(server, response: dict[str, Any], value: Any, error: Any) -> None:
1052+
conn = Connection(_response_handler, server.client_writer, server.client_reader)
1053+
tasks: list[asyncio.Task[Any]] = []
1054+
try:
1055+
tasks, frames = await _start_response_requests(conn, server.server_reader)
1056+
await _send_responses(
1057+
server.server_writer,
1058+
{"jsonrpc": "2.0", "id": frames[0]["id"], **response},
1059+
{"jsonrpc": "2.0", "id": frames[1]["id"], "result": "survivor"},
1060+
)
1061+
_, pending = await asyncio.wait(tasks, timeout=1)
1062+
assert not pending
1063+
if error is None:
1064+
assert tasks[0].result() == value
1065+
else:
1066+
exception = tasks[0].exception()
1067+
assert isinstance(exception, RequestError)
1068+
assert (exception.code, str(exception), exception.data) == error
1069+
assert tasks[1].result() == "survivor"
1070+
assert not conn._pending
1071+
assert not conn._disconnected
1072+
finally:
1073+
await _cleanup_response_requests(conn, tasks)
1074+
1075+
1076+
@pytest.mark.asyncio
1077+
@pytest.mark.parametrize("request_id", [[], {}, True, 1.0, 999, "unknown", None])
1078+
async def test_unknown_or_invalid_ids_do_not_parse_bad_error(server, request_id: Any) -> None:
1079+
conn = Connection(_response_handler, server.client_writer, server.client_reader)
1080+
tasks: list[asyncio.Task[Any]] = []
1081+
try:
1082+
tasks, _ = await _start_response_requests(conn, server.server_reader)
1083+
await _send_responses(
1084+
server.server_writer,
1085+
{"jsonrpc": "2.0", "id": request_id, "error": ["malformed"]},
1086+
{"jsonrpc": "2.0", "id": 0, "result": "first"},
1087+
{"jsonrpc": "2.0", "id": 1, "result": "second"},
1088+
)
1089+
_, pending = await asyncio.wait(tasks, timeout=1)
1090+
assert not pending
1091+
assert [task.result() for task in tasks] == ["first", "second"]
1092+
assert not conn._disconnected
1093+
assert not conn._pending
1094+
finally:
1095+
await _cleanup_response_requests(conn, tasks)
1096+
1097+
1098+
@pytest.mark.asyncio
1099+
async def test_cancelled_request_and_late_bad_response(server) -> None:
1100+
conn = Connection(_response_handler, server.client_writer, server.client_reader)
1101+
tasks: list[asyncio.Task[Any]] = []
1102+
try:
1103+
tasks, _ = await _start_response_requests(conn, server.server_reader)
1104+
tasks[0].cancel()
1105+
with pytest.raises(asyncio.CancelledError):
1106+
await tasks[0]
1107+
cancel = json.loads(await asyncio.wait_for(server.server_reader.readline(), timeout=1))
1108+
assert cancel["method"] == "$/cancel_request"
1109+
assert cancel["params"] == {"requestId": 0}
1110+
await _send_responses(
1111+
server.server_writer,
1112+
{"jsonrpc": "2.0", "id": 0, "error": ["malformed"]},
1113+
{"jsonrpc": "2.0", "id": 1, "result": "survivor"},
1114+
)
1115+
_, pending = await asyncio.wait(tasks, timeout=1)
1116+
assert not pending
1117+
assert tasks[0].cancelled()
1118+
assert tasks[1].result() == "survivor"
1119+
assert not conn._disconnected
1120+
finally:
1121+
await _cleanup_response_requests(conn, tasks)
1122+
1123+
1124+
@pytest.mark.asyncio
1125+
async def test_duplicate_bad_response_is_ignored(server) -> None:
1126+
conn = Connection(_response_handler, server.client_writer, server.client_reader)
1127+
tasks: list[asyncio.Task[Any]] = []
1128+
try:
1129+
tasks, _ = await _start_response_requests(conn, server.server_reader)
1130+
await _send_responses(
1131+
server.server_writer,
1132+
{"jsonrpc": "2.0", "id": 0, "result": "first"},
1133+
{"jsonrpc": "2.0", "id": 0, "error": ["malformed"]},
1134+
{"jsonrpc": "2.0", "id": 1, "result": "second"},
1135+
)
1136+
_, pending = await asyncio.wait(tasks, timeout=1)
1137+
assert not pending
1138+
assert [task.result() for task in tasks] == ["first", "second"]
1139+
assert not conn._disconnected
1140+
finally:
1141+
await _cleanup_response_requests(conn, tasks)
1142+
1143+
1144+
@pytest.mark.asyncio
1145+
@pytest.mark.parametrize("action", ["close", "eof"])
1146+
async def test_normal_shutdown_rejects_pending(server, action: str) -> None:
1147+
conn = Connection(_response_handler, server.client_writer, server.client_reader)
1148+
tasks: list[asyncio.Task[Any]] = []
1149+
try:
1150+
tasks, _ = await _start_response_requests(conn, server.server_reader)
1151+
if action == "close":
1152+
await conn.close()
1153+
else:
1154+
server.server_writer.close()
1155+
await server.server_writer.wait_closed()
1156+
_, pending = await asyncio.wait(tasks, timeout=1)
1157+
assert not pending
1158+
assert all(isinstance(task.exception(), ConnectionError) for task in tasks)
1159+
assert not conn._pending
1160+
finally:
1161+
await _cleanup_response_requests(conn, tasks)
1162+
1163+
1164+
@pytest.mark.asyncio
1165+
@pytest.mark.parametrize("status", ["done", "cancelled"])
1166+
async def test_completed_future_is_removed_without_parsing(server, status: str) -> None:
1167+
conn = Connection(_response_handler, server.client_writer, server.client_reader, listening=False)
1168+
future = asyncio.get_running_loop().create_future()
1169+
conn._pending[0] = future
1170+
if status == "done":
1171+
future.set_result("original")
1172+
else:
1173+
future.cancel()
1174+
try:
1175+
conn._handle_response({"id": 0, "error": ["malformed"]})
1176+
assert not conn._pending
1177+
assert not conn._disconnected
1178+
if status == "done":
1179+
assert future.result() == "original"
1180+
else:
1181+
assert future.cancelled()
1182+
finally:
1183+
await conn.close()
1184+
1185+
1186+
@pytest.mark.asyncio
1187+
async def test_done_callback_runs_after_pending_removal(server) -> None:
1188+
conn = Connection(_response_handler, server.client_writer, server.client_reader, listening=False)
1189+
future = asyncio.get_running_loop().create_future()
1190+
observed: list[bool] = []
1191+
conn._pending[0] = future
1192+
future.add_done_callback(lambda _: observed.append(0 in conn._pending))
1193+
try:
1194+
conn._handle_response({"id": 0, "result": "ok"})
1195+
assert observed == []
1196+
assert not conn._pending
1197+
await asyncio.sleep(0)
1198+
assert observed == [False]
1199+
finally:
1200+
await conn.close()

0 commit comments

Comments
 (0)