Skip to content

Commit d19c4d2

Browse files
fix(agent_execution): resolve ActiveTask 'destroyed but pending' warning during teardown (a2aproject#1122)
Fixes a2aproject#1121. This PR resolves the issue where `ActiveTask` would throw an asyncio warning when being garbage collected, by ensuring the `_producer_task` is properly awaited during the `_run_consumer` teardown path, allowing queues to properly close without leaving dangling pending background tasks. --------- Co-authored-by: mykytanetipa <mykytanetipa@google.com>
1 parent 4e3d724 commit d19c4d2

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

src/a2a/server/agent_execution/active_task.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,6 +587,19 @@ async def _run_consumer(self) -> None:
587587
self._is_finished.set()
588588
self._request_queue.shutdown(immediate=True)
589589
await self._event_queue_agent.close(immediate=True)
590+
591+
if self._producer_task and not self._producer_task.done():
592+
try:
593+
await self._producer_task
594+
except asyncio.CancelledError:
595+
pass
596+
except Exception as e: # noqa: BLE001
597+
logger.debug(
598+
'Consumer[%s]: Awaited producer_task raised %r',
599+
self._task_id,
600+
e,
601+
)
602+
590603
async with self._lock:
591604
self._reference_count -= 1
592605
logger.debug('Consumer[%s]: Finishing', self._task_id)

tests/server/agent_execution/test_active_task.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,3 +895,87 @@ async def execute_mock(req, q):
895895
assert len(events) == 0
896896

897897
await active_task.cancel(request_context)
898+
899+
900+
@pytest.mark.timeout(5)
901+
@pytest.mark.asyncio
902+
async def test_producer_awaited_on_normal_completion():
903+
"""Verify producer is awaited before cleanup to prevent GC warnings.
904+
905+
Regression test for #1121: when the consumer finishes first (normal
906+
completion path), the producer must be awaited before
907+
_maybe_cleanup() releases the ActiveTask reference, otherwise asyncio
908+
logs "Task was destroyed but it is pending!".
909+
"""
910+
agent_executor = Mock()
911+
task_manager = Mock()
912+
cleanup_called = False
913+
914+
def on_cleanup(_task: ActiveTask) -> None:
915+
nonlocal cleanup_called
916+
cleanup_called = True
917+
918+
active_task = ActiveTask(
919+
agent_executor=agent_executor,
920+
task_id='test-task-id',
921+
task_manager=task_manager,
922+
on_cleanup=on_cleanup,
923+
)
924+
925+
execute_started = asyncio.Event()
926+
execute_barrier = asyncio.Event()
927+
928+
async def execute_mock(req, q):
929+
execute_started.set()
930+
await execute_barrier.wait()
931+
932+
agent_executor.execute = AsyncMock(side_effect=execute_mock)
933+
agent_executor.cancel = AsyncMock()
934+
task_manager.get_task = AsyncMock(
935+
return_value=Task(
936+
id='test-task-id',
937+
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
938+
)
939+
)
940+
task_manager.save_task_event = AsyncMock()
941+
task_manager.ensure_task_id = AsyncMock(
942+
return_value=Task(
943+
id='test-task-id',
944+
status=TaskStatus(state=TaskState.TASK_STATE_WORKING),
945+
)
946+
)
947+
task_manager.process = AsyncMock(side_effect=lambda x: x)
948+
949+
request_context = Mock(spec=RequestContext)
950+
request_context.call_context = ServerCallContext()
951+
request_context.context_id = 'test-context'
952+
request_context.message = None
953+
954+
await active_task.enqueue_request(request_context)
955+
await active_task.start(
956+
call_context=ServerCallContext(), create_task_if_missing=True
957+
)
958+
959+
await execute_started.wait()
960+
961+
if active_task._consumer_task:
962+
active_task._consumer_task.cancel()
963+
964+
# Release the producer so it can finish its work and loop back
965+
# to get(), where it will receive QueueShutDown.
966+
execute_barrier.set()
967+
968+
try:
969+
await active_task._consumer_task
970+
except asyncio.CancelledError:
971+
pass
972+
973+
await active_task._is_finished.wait()
974+
975+
assert active_task._producer_task is not None
976+
assert active_task._producer_task.done(), (
977+
'Producer task should be done after consumer teardown'
978+
)
979+
assert cleanup_called, (
980+
'on_cleanup should be called after producer is drained'
981+
)

0 commit comments

Comments
 (0)