Skip to content

fix(server): propagate caller cancellation out of ActiveTask.subscribe - #1402

Open
v0ropaev wants to merge 1 commit into
a2aproject:mainfrom
v0ropaev:fix/subscribe-propagate-cancel
Open

v0ropaev wants to merge 1 commit into
a2aproject:mainfrom
v0ropaev:fix/subscribe-propagate-cancel

Conversation

@v0ropaev

Copy link
Copy Markdown

Description

ActiveTask.subscribe catches asyncio.CancelledError together with QueueShutDown and breaks out of its loop. So when the task that iterates a subscription gets cancelled, the generator just finishes and the cancellation is lost.

It shows up with a timeout around a blocking send. With an executor that moves the task to WORKING and keeps going, asyncio.wait_for(handler.on_message_send(...), timeout=0.3) returns the task in TASK_STATE_WORKING instead of raising TimeoutError. Calling cancel() on a task running on_message_send behaves the same way. It ends normally with cancelled() == False and returns whatever non-terminal state the task had at that moment. A cancelled consumer of on_message_send_stream or on_subscribe_to_task sees its stream end as if the task had finished.

The fix catches only QueueShutDown. The finally block already closes the tap, drops the reference count and runs _maybe_cleanup(), so cleanup happens the same way when the cancellation propagates. I couldn't find anything other than the caller that would raise CancelledError there. The producer and the consumer only put Exception instances on the queue (the producer swallows its own cancellation in _run_producer), and shutting the queue down raises QueueShutDown for both immediate=True and immediate=False.

The existing test_active_task_subscribe_cancelled_during_wait accepted either CancelledError or StopAsyncIteration, with a comment putting the second one down to a Python 3.10 generator quirk. It was really this except swallowing the cancel, and the test gets StopAsyncIteration on 3.13 as well. With the fix both versions raise CancelledError, so the test now requires it.

I also added three handler tests using the existing _HangingAgent. One wraps on_message_send in wait_for and expects TimeoutError, one cancels a task running on_message_send, and one cancels a task consuming on_message_send_stream. With the src change stashed all four fail on both 3.10 and 3.13 (the three new ones with "DID NOT RAISE", the old one with StopAsyncIteration), and with it they pass.

On 3.10 at 43d1eff the full suite gives 2494 passed, 203 skipped, 4 xfailed, 1 xpassed, and the coverage run clears the 88% gate. ./scripts/lint.sh passes.

One related thing I left alone. The unary wrapper in _plugin_hooks.py only calls after_handler_run from except Exception, so a cancelled send_message now gets before_handler_run with no matching after call. That already happened for a cancel at any other await, but the subscribe wait is where a blocking send usually sits when a timeout or disconnect hits, and before this change the after hook ran there with the stale task. The stream wrapper calls it from finally. I'm happy to make the unary side match in a separate PR if you want that.

ActiveTask.subscribe caught asyncio.CancelledError next to QueueShutDown
and broke out of its loop. When the task iterating the subscription was
cancelled, the generator ended normally instead of re-raising, so the
cancellation was lost.

For DefaultRequestHandlerV2.on_message_send this meant asyncio.wait_for
never raised TimeoutError and Task.cancel() was ignored. The call
returned the task in whatever non-terminal state it had at that moment
as if it were the result. A cancelled consumer of
on_message_send_stream or on_subscribe_to_task saw its stream end
quietly instead of being cancelled.

Only QueueShutDown is caught now. The producer and consumer only
enqueue Exception instances and queue shutdown raises QueueShutDown, so
a CancelledError at this point always comes from the caller. The
finally block still detaches the tap and drops the reference count, so
cleanup is unchanged.
@v0ropaev
v0ropaev requested a review from a team as a code owner October 29, 2026 09:09
@github-actions

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/server/cluster/database_event_stream.py 92.86% 96.94% 🟢 +4.08%
src/a2a/server/request_handlers/default_request_handler_v2.py 88.68% 87.87% 🔴 -0.81%
Total 93.37% 93.38% 🟢 +0.01%

Generated by coverage-comment.yml

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant