Repository navigation
Description
Versions
a2a-sdk[http-server]1.3.0 (DefaultRequestHandlerV2)- Python 3.13
- macOS / Linux
What happens
If the asyncio task awaiting DefaultRequestHandlerV2.on_message_send (non-streaming message/send) is cancelled while the AgentExecutor is still running and has not emitted any event yet, the cancellation is swallowed and the task never finishes: it stays parked forever in ActiveTask.get_task() on self._task_created.wait().
A visible consequence: asyncio.run(...) hangs at shutdown in _cancel_all_tasks() whenever a message/send call is in flight, because the cancelled handler task never completes. We first hit it through the SDK client's send_message over HTTP to an in-process server; the repro below calls the request handler directly.
Expected
Cancellation propagates: the task awaiting on_message_send ends with asyncio.CancelledError promptly (after the subscriber cleanup has run).
Minimal reproduction
No network, no server; only the SDK. The executor blocks before emitting anything; the send is started as a task, cancelled, and awaited with a 3 s bound so the script reports instead of hanging.
"""Cancelling a non-streaming on_message_send while the agent runs never finishes."""
import asyncio
import importlib.metadata
import os
import re
import sys
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.context import ServerCallContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers.default_request_handler_v2 import (
DefaultRequestHandlerV2,
)
from a2a.server.tasks import InMemoryTaskStore
from a2a.types.a2a_pb2 import (
AgentCapabilities,
AgentCard,
Message,
Part,
Role,
SendMessageRequest,
)
started = asyncio.Event()
class SlowExecutor(AgentExecutor):
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
started.set()
await asyncio.Event().wait() # long-running work; emits nothing yet
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
pass
async def main() -> int:
print("a2a-sdk", importlib.metadata.version("a2a-sdk"), "| Python", sys.version.split()[0])
handler = DefaultRequestHandlerV2(
agent_executor=SlowExecutor(),
task_store=InMemoryTaskStore(),
agent_card=AgentCard(
name="repro",
description="repro",
version="1.0",
capabilities=AgentCapabilities(),
default_input_modes=["text/plain"],
default_output_modes=["text/plain"],
),
)
request = SendMessageRequest(
message=Message(
message_id="m1", role=Role.ROLE_USER, parts=[Part(text="hi")]
)
)
send = asyncio.create_task(handler.on_message_send(request, ServerCallContext()))
await asyncio.wait_for(started.wait(), 3)
send.cancel()
try:
await asyncio.wait_for(asyncio.shield(send), 3)
except asyncio.CancelledError:
print("OK: on_message_send ended with CancelledError")
return 0
except TimeoutError:
print("HANG: on_message_send still pending 3 s after cancel()")
stack, coro = "", send.get_coro()
while coro is not None and hasattr(coro, "cr_frame"): # walk the await chain
f = coro.cr_frame
if f is not None:
name = re.sub(r".*/(site-packages|lib/python3\.\d+)/", "", f.f_code.co_filename)
stack += f" {name}:{f.f_lineno} {f.f_code.co_name}\n"
coro = coro.cr_await
print("pending at:\n" + stack, end="")
return 1
print("UNEXPECTED: returned", send.result())
return 2
if __name__ == "__main__":
# asyncio.run() would itself hang in _cancel_all_tasks() on the stuck task,
# so run the loop directly and exit without cancelling leftovers.
code = asyncio.new_event_loop().run_until_complete(main())
sys.stdout.flush()
os._exit(code)Output on 1.3.0:
a2a-sdk 1.3.0 | Python 3.13.15
HANG: on_message_send still pending 3 s after cancel()
pending at:
a2a/utils/telemetry.py:250 async_wrapper
a2a/server/request_handlers/request_handler.py:285 async_wrapper
a2a/server/request_handlers/default_request_handler_v2.py:464 on_message_send
a2a/server/agent_execution/active_task.py:1096 get_task
asyncio/locks.py:213 wait
The asyncio.run shutdown variant: start on_message_send as a task, wait until the executor has started, and return from the main coroutine. asyncio.run() cancels the leftover tasks (the handler task and the SDK's producer/consumer tasks) and then waits for them; it never returns.
Root cause (line numbers from 1.3.0)
-
a2a/server/agent_execution/active_task.py:851, inActiveTask.subscribe():except (QueueShutDown, asyncio.CancelledError): break
A
CancelledErrordelivered to the subscriber while it waits intapped_queue.dequeue_event()is caught and turned into abreak. Thefinally(lines 853-860) cleans up and the generator then ends normally, so the cancellation does not reach the caller. -
a2a/server/request_handlers/default_request_handler_v2.py:424-464, inon_message_send(): theasync foroversubscribe()simply ends,resultis stillNone, so the handler calls:result = await active_task.get_task() # line 464
-
a2a/server/agent_execution/active_task.py:1094-1096,ActiveTask.get_task():await self._task_created.wait()
_task_createdis set only when a task already exists atstart()(line 607), when the consumer stores the first task event emitted by the agent (line 273), or when the consumer records a producer failure (line 188). None of these happens here:- On a plain cancel of the request, the producer task is a separate background task and is not cancelled; it keeps running the executor, which has not emitted anything yet.
_task_createdstays clear for as long as the agent is silent (forever in the repro). - At
asyncio.runshutdown, the producer is cancelled too;_run_producercatchesCancelledError(line 705) and just closes the queues; nothing sets_task_createdon that path either.
Since the one
CancelledErrorwas already consumed in step 1, nothing interrupts the wait. - On a plain cancel of the request, the producer task is a separate background task and is not cancelled; it keeps running the executor, which has not emitted anything yet.
Side effect even when it does not hang: if the agent later emits a task, the cancelled on_message_send returns a normal result instead of raising CancelledError, which breaks structured-concurrency expectations (e.g. asyncio.timeout, TaskGroup).
Suggested fix direction
- In
ActiveTask.subscribe(), do not swallowasyncio.CancelledError: handle onlyQueueShutDownwithbreak, and letCancelledErrorpropagate after thefinallycleanup (or re-raise it explicitly). The existingfinallyalready performs the unsubscribe and cleanup. - Independently,
on_message_send/get_task()should not wait unboundedly for_task_createdonce the subscription has ended without a result; e.g. wait on_task_createdand_is_finishedtogether, or return/raise when the task has finished without a stored task.
Activity