Skip to content

Commit e1abcac

Browse files
monody0007claude
andcommitted
fix(connection): keep request responses out of the handler's cancellation
Two races could still lose the response to a cancelled request: - A `$/cancel_request` read in the same buffer as its request cancelled the request task before it first ran, so the coroutine that would have sent `-32800` never executed. - A late or repeated `$/cancel_request` arriving while the response was being sent cancelled that send, so the peer got no response at all. Each incoming request now runs its handler in its own task, which is the only thing `$/cancel_request` targets. A separate supervised task waits for the handler with `asyncio.wait` and sends its result, or `-32800` if the handler was cancelled. `close()` still cancels both, so shutdown never waits on a blocked send, and the `Task.uncancel()` / closed-send special cases are no longer needed. Also document request cancellation in the quickstart and state that a cancelled handler answers with its result or `-32800`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e74cbc6 commit e1abcac

5 files changed

Lines changed: 189 additions & 27 deletions

File tree

‎docs/experimental-v2.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,6 @@ when an agent selects v1.
152152
Client-side fallback is application controlled and may require opening a new
153153
transport. Protocol-level request cancellation (`$/cancel_request`) is handled
154154
by the shared connection layer, as in v1: cancelling the task awaiting a request
155-
notifies the peer, and an incoming cancellation cancels the handler task and
156-
answers with `-32800`. `session/cancel` remains available for cancelling active
157-
session work.
155+
sends a best-effort cancellation to the peer, and an incoming cancellation
156+
cancels the handler task, which answers with its result or `-32800`.
157+
`session/cancel` remains available for cancelling active session work.

‎docs/quickstart.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ MCP requests return the inner JSON result unchanged, including `null`. Use
228228
and optional `params`. These methods share the same connections and routers
229229
across stdio, HTTP, and WebSocket transports.
230230

231+
## Request cancellation
232+
233+
Connections handle the protocol's `$/cancel_request` notification. When the
234+
peer cancels one of its requests, the SDK cancels the task running your handler.
235+
The handler may catch `asyncio.CancelledError` and return a (partial) result;
236+
otherwise the peer receives a `-32800` "Request cancelled" error. Cancellations
237+
for unknown or already finished requests are ignored; a cancelled request still
238+
gets exactly one response unless the connection is closed first.
239+
240+
Cancelling the task that awaits an outgoing request (for example through
241+
`asyncio.wait_for`) still raises `CancelledError` locally without waiting for the
242+
peer, and additionally sends a best-effort `$/cancel_request` for that request.
243+
Cancellation support is optional for peers. Use `session/cancel` to stop a
244+
prompt turn.
245+
231246
## Maintaining protocol routes
232247

233248
The `Agent` and `Client` protocols in `src/acp/interfaces.py` are the source of

‎src/acp/connection.py‎

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import inspect
66
import json
77
import logging
8-
import sys
98
from collections.abc import Awaitable, Callable
109
from dataclasses import dataclass
1110
from enum import Enum
@@ -161,12 +160,14 @@ def _process_message(self, message: dict[str, Any]) -> None:
161160
if method is not None: # this is a request or notification
162161
# {"jsonrpc": "2.0", "id": 1, "method": "foo", "params": {...}} # request
163162
# {"jsonrpc": "2.0", "method": "foo", "params: {...}} # notification
164-
task = self._tasks.create(
165-
self._run_request(message) if has_id else self._run_notification(message),
166-
name="acp.Connection.request" if has_id else "acp.Connection.notification",
167-
)
168-
if has_id:
169-
self._track_incoming(message["id"], task)
163+
if not has_id:
164+
self._tasks.create(self._run_notification(message), name="acp.Connection.notification")
165+
return
166+
# The handler gets its own task so ``$/cancel_request`` can cancel it, even before it
167+
# starts, without also cancelling delivery of the response the peer still expects.
168+
handler = self._tasks.create(self._execute_request(message), name="acp.Connection.request")
169+
self._track_incoming(message["id"], handler)
170+
self._tasks.create(self._run_request(message, handler), name="acp.Connection.response")
170171
return
171172
if has_id: # this is a response, {"id", "result" | "error"}
172173
self._handle_response(message)
@@ -226,22 +227,19 @@ def _send_cancel_request(self, request_id: int) -> None:
226227
def _on_cancel_request_error(self, task: asyncio.Task[Any], exc: BaseException) -> None:
227228
logging.debug("Failed to send %s", _CANCEL_REQUEST_METHOD, exc_info=exc)
228229

229-
async def _run_request(self, message: dict[str, Any]) -> None:
230-
try:
231-
payload = await self._execute_request(message)
232-
except asyncio.CancelledError:
233-
if self._closed:
234-
raise
235-
# Cancelled by ``$/cancel_request`` or from inside the handler; either way the
236-
# protocol still requires a response for the original request.
237-
task = asyncio.current_task()
238-
if sys.version_info >= (3, 11) and task is not None:
239-
task.uncancel()
230+
async def _run_request(
231+
self, message: dict[str, Any], handler: asyncio.Future[dict[str, Any]] | None = None
232+
) -> None:
233+
if handler is None:
234+
handler = self._tasks.create(self._execute_request(message), name="acp.Connection.request")
235+
# Unlike ``await handler``, ``asyncio.wait`` does not re-raise the handler's cancellation, and
236+
# ``$/cancel_request`` only targets the handler, so only ``close()`` can abandon the response.
237+
await asyncio.wait({handler})
238+
if handler.cancelled():
239+
# Cancelled by ``$/cancel_request`` or from inside the handler.
240240
payload = {"jsonrpc": "2.0", "id": message["id"], "error": RequestError.request_cancelled().to_error_obj()}
241-
if self._closed:
242-
# The transport is gone (e.g. the handler returned a result while being
243-
# cancelled by ``close()``); sending would never complete.
244-
return
241+
else:
242+
payload = handler.result()
245243
await self._transport.send(payload)
246244
self._notify_observers(StreamDirection.OUTGOING, payload)
247245

‎tests/test_request_cancellation.py‎

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
from acp import Agent
13+
from acp.connection import Connection
1314
from acp.core import AgentSideConnection, ClientSideConnection
1415
from acp.schema import PermissionOption, ToolCallUpdate
1516
from tests.conftest import TestAgent, TestClient
@@ -24,7 +25,7 @@ async def _read(reader: asyncio.StreamReader) -> dict[str, Any]:
2425
return json.loads(await asyncio.wait_for(reader.readline(), timeout=1))
2526

2627

27-
def _prompt(request_id: int | str) -> dict[str, Any]:
28+
def _prompt(request_id: int | str | None) -> dict[str, Any]:
2829
return {
2930
"jsonrpc": "2.0",
3031
"id": request_id,
@@ -33,7 +34,7 @@ def _prompt(request_id: int | str) -> dict[str, Any]:
3334
}
3435

3536

36-
def _cancel_request(request_id: int | str) -> dict[str, Any]:
37+
def _cancel_request(request_id: int | str | None) -> dict[str, Any]:
3738
return {"jsonrpc": "2.0", "method": "$/cancel_request", "params": {"requestId": request_id}}
3839

3940

@@ -77,6 +78,122 @@ async def test_cancel_request_cancels_handler_and_replies_request_cancelled(
7778
assert "$/cancel_request" not in caplog.text
7879

7980

81+
@pytest.mark.asyncio
82+
@pytest.mark.parametrize("request_id", [0, "req-0", None])
83+
async def test_cancel_request_before_the_handler_starts_still_replies(
84+
server, caplog: pytest.LogCaptureFixture, request_id: int | str | None
85+
) -> None:
86+
agent = _BlockingAgent()
87+
async with AgentSideConnection(cast(Agent, agent), server.server_writer, server.server_reader, listening=True):
88+
with caplog.at_level(logging.ERROR):
89+
# One write, so the receive loop reads both frames before the handler task first runs.
90+
server.client_writer.write(
91+
(json.dumps(_prompt(request_id)) + "\n" + json.dumps(_cancel_request(request_id)) + "\n").encode()
92+
)
93+
await server.client_writer.drain()
94+
response = await _read(server.client_reader)
95+
96+
assert response["id"] == request_id
97+
assert response["error"]["code"] == -32800
98+
assert not agent.started.is_set()
99+
assert caplog.text == ""
100+
with pytest.raises(asyncio.TimeoutError):
101+
await asyncio.wait_for(server.client_reader.readline(), timeout=0.1)
102+
103+
104+
class _GatedTransport:
105+
"""Message transport whose sends block until ``release`` is set."""
106+
107+
def __init__(self) -> None:
108+
self.incoming: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
109+
self.sent: list[dict[str, Any]] = []
110+
self.sending = asyncio.Event()
111+
self.release = asyncio.Event()
112+
self.settled = asyncio.Event()
113+
self.send_cancelled = False
114+
self._receiving = False
115+
116+
async def send(self, message: dict[str, Any]) -> None:
117+
self.sending.set()
118+
try:
119+
await self.release.wait()
120+
except asyncio.CancelledError:
121+
self.send_cancelled = True
122+
raise
123+
else:
124+
self.sent.append(message)
125+
finally:
126+
self.settled.set()
127+
128+
async def receive(self) -> dict[str, Any] | None:
129+
self._receiving = True
130+
try:
131+
return await self.incoming.get()
132+
finally:
133+
self._receiving = False
134+
135+
async def close(self) -> None:
136+
pass
137+
138+
async def deliver(self, message: dict[str, Any]) -> None:
139+
"""Queue ``message`` and wait until the connection has processed it."""
140+
await self.incoming.put(message)
141+
for _ in range(100):
142+
if self._receiving and self.incoming.empty():
143+
return
144+
await asyncio.sleep(0)
145+
raise AssertionError("the connection did not process the message")
146+
147+
148+
@pytest.mark.asyncio
149+
@pytest.mark.parametrize("handler_cancelled", [False, True], ids=["result", "request_cancelled"])
150+
async def test_cancel_request_during_response_send_keeps_the_response(handler_cancelled: bool) -> None:
151+
transport = _GatedTransport()
152+
started = asyncio.Event()
153+
154+
async def handler(method: str, params: Any, is_notification: bool) -> Any:
155+
started.set()
156+
if handler_cancelled:
157+
await asyncio.Event().wait()
158+
return {"ok": True}
159+
160+
async with Connection(handler, transport):
161+
await transport.deliver(_prompt(0))
162+
await asyncio.wait_for(started.wait(), timeout=1)
163+
if handler_cancelled:
164+
await transport.deliver(_cancel_request(0))
165+
await asyncio.wait_for(transport.sending.wait(), timeout=1)
166+
167+
# A late (or repeated) cancellation lands while the response is still being sent.
168+
await transport.deliver(_cancel_request(0))
169+
assert transport.sent == []
170+
transport.release.set()
171+
await asyncio.wait_for(transport.settled.wait(), timeout=1)
172+
assert not transport.send_cancelled, "the cancellation aborted the response send"
173+
174+
if handler_cancelled:
175+
assert [(m["id"], m["error"]["code"]) for m in transport.sent] == [(0, -32800)]
176+
else:
177+
assert transport.sent == [{"jsonrpc": "2.0", "id": 0, "result": {"ok": True}}]
178+
179+
180+
@pytest.mark.asyncio
181+
async def test_close_cancels_a_blocked_response_send() -> None:
182+
transport = _GatedTransport()
183+
184+
async def handler(method: str, params: Any, is_notification: bool) -> Any:
185+
return {"ok": True}
186+
187+
conn = Connection(handler, transport)
188+
await transport.deliver(_prompt(0))
189+
await asyncio.wait_for(transport.sending.wait(), timeout=1)
190+
191+
await asyncio.wait_for(conn.close(), timeout=1)
192+
193+
assert transport.send_cancelled
194+
assert transport.sent == []
195+
196+
80197
@pytest.mark.asyncio
81198
async def test_cancel_request_only_affects_the_targeted_request(server, caplog: pytest.LogCaptureFixture) -> None:
82199
agent = _BlockingAgent()

‎tests/test_v2_runtime.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,3 +550,35 @@ async def handle_extension_request(self, method: str, params: Any) -> Any:
550550
finally:
551551
await client_connection.close()
552552
await agent_connection.close()
553+
554+
555+
@pytest.mark.asyncio
556+
async def test_cancel_request_before_the_handler_starts_still_replies() -> None:
557+
handled: list[str] = []
558+
559+
class RecordingAgent(ExtensionAgent):
560+
async def handle_extension_request(self, method: str, params: Any) -> Any:
561+
handled.append(method)
562+
return {}
563+
564+
peer, agent_transport = memory_transport_pair()
565+
agent_connection = v2.AgentSideConnection(RecordingAgent(), agent_transport)
566+
567+
try:
568+
await peer.send({
569+
"jsonrpc": "2.0",
570+
"id": 0,
571+
"method": "initialize",
572+
"params": initialize_request().model_dump(mode="json", by_alias=True, exclude_none=True),
573+
})
574+
assert "result" in await asyncio.wait_for(peer.receive(), timeout=1)
575+
576+
# Both frames are queued before the connection runs, so the cancel precedes the handler.
577+
await peer.send({"jsonrpc": "2.0", "id": 1, "method": "_vendor/slow", "params": {}})
578+
await peer.send({"jsonrpc": "2.0", "method": "$/cancel_request", "params": {"requestId": 1}})
579+
response = await asyncio.wait_for(peer.receive(), timeout=1)
580+
581+
assert (response["id"], response["error"]["code"]) == (1, -32800)
582+
assert handled == []
583+
finally:
584+
await agent_connection.close()

0 commit comments

Comments
 (0)