Skip to content

fix(server): mark internal lifecycle events done so a cancelled stream frees its producer - #1336

Merged
Iwaniukooo11 merged 10 commits into
a2aproject:mainfrom
carstenpiepel:fix/cancel-leaks-producer
Oct 14, 2026
Merged

Iwaniukooo11 merged 10 commits into
a2aproject:mainfrom
carstenpiepel:fix/cancel-leaks-producer

Conversation

@carstenpiepel

Copy link
Copy Markdown
Contributor

Fixes #1322.

Problem

When a streamed task is cancelled, its background producer never finishes and the producer:<task_id> entry is never pruned from the registry.

In ActiveTask.subscribe, the two internal lifecycle events _RequestStarted and _RequestCompleted are dequeued from the per-subscriber tapped queue and then skipped with continue (or return) without calling tapped_queue.task_done(). Because those items are never marked done, the producer's teardown await self._event_queue_subscribers.close(immediate=False) waits forever on a tap that still has unfinished items, so _run_producer never completes.

The maintainer confirmed the root cause and approved this fix in #1322.

Fix

Call tapped_queue.task_done() on the _RequestStarted / _RequestCompleted branches before they are skipped, so the tapped queue's unfinished count returns to zero and close(immediate=False) can complete. One-line change in src/a2a/server/agent_execution/active_task.py.

Regression test

tests/server/agent_execution/test_active_task.py::TestActiveTask::test_active_task_cancel_while_subscribed_frees_producer, built on the module's existing fixtures. It drives a subscriber past _RequestStarted, cancels the producer (the streamed-cancel path), and asserts the producer task completes rather than wedging.

Verification

  • Test passes with the fix and fails with TimeoutError when the source change is temporarily reverted (confirming it catches the bug rather than being green by construction).
  • Full active_task module: 33 passed.
  • Broader server sweep (agent_execution + cluster cancel/subscribe + default request handler): 172 passed.
  • ruff check and ruff format --check: clean.

…m frees its producer

ActiveTask.subscribe consumed _RequestStarted and _RequestCompleted with a
bare `continue`, never calling tapped_queue.task_done() for them. When a task
finished normally the _RequestCompleted event made the subscriber return before
the producer's cleanup, so it did no harm. But a cancel stops the agent before
_RequestCompleted is sent: the producer then runs
_event_queue_subscribers.close(immediate=False), which waits until every tap
has marked all of its items done. The stream's tap still holds the unmarked
_RequestStarted, so the wait never ends -- the producer and consumer tasks and
the ActiveTask registry entry leak until the whole handler is aclose()-d.

Mark the two internal lifecycle events done before skipping them. Adds a
regression test that fails (TimeoutError) without the fix and passes with it.

Fixes a2aproject#1322

Signed-off-by: Carsten Piepel <cpiepel@esri.com>
@carstenpiepel
carstenpiepel requested a review from a team as a code owner October 12, 2026 13:46
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/server/agent_execution/active_task.py 95.14% 95.40% 🟢 +0.26%
Total 93.12% 93.13% 🟢 +0.01%

Generated by coverage-comment.yml

…leep

Address review feedback on the a2aproject#1322 regression test:

- Stop swallowing the producer's exception. The previous _settle helper
  suppressed all exceptions, so the test could pass if teardown crashed
  rather than completed. Now assert the producer finishes with no
  exception; the CancelledError from the explicit cancel is the only one
  allowed, closing a false-pass hole.
- Replace the fixed sleep with an explicit synchronization event set when
  the subscriber enters its loop, removing a timing guess that could flake
  on a slow runner.

Re-verified: the test still passes with the fix and still fails with
TimeoutError when the source fix is reverted.

Signed-off-by: Carsten Piepel <cpiepel@esri.com>
@carstenpiepel
carstenpiepel marked this pull request as draft October 12, 2026 14:08
…ment

Second review pass on the a2aproject#1322 regression test:

- _run_producer catches asyncio.CancelledError and returns normally, so a
  cancelled producer never surfaces CancelledError from await and
  producer.exception() is always None here. Drop the dead CancelledError
  handling and the redundant done()/exception() checks; just await the
  producer. If teardown raises, await re-raises and the test fails -- the
  stronger property. Verified by injecting a RuntimeError into teardown.
- Correct the synchronization comment: setting the event before the first
  await does not prove _RequestStarted was consumed. Describe the actual
  scheduling handoff instead of overstating a guarantee.

Re-verified: passes with the fix, fails with TimeoutError when the fix is
reverted, and fails by propagating the exception when teardown raises.

Signed-off-by: Carsten Piepel <cpiepel@esri.com>
Instrumentation showed _RequestStarted is not dequeued before
producer.cancel(); producer teardown drains it through the tap instead.
Correct the comments so they no longer claim the handoff consumes the
event before cancellation. Comment-only change.

Signed-off-by: Carsten Piepel <cpiepel@esri.com>
Producer teardown drains _RequestStarted through the subscriber tap, so
the reader-started event and sleep(0) were not needed. Remove them.

The test still needs one synchronization point: cancelling the producer
before it runs skips _run_producer's CancelledError handler, so the
await raises CancelledError and the cancel path under test is never
exercised. Wait on an event set from the mocked execute() instead.

Verified 10/10 passes with the fix, 10/10 TimeoutError failures without
it, and an injected teardown RuntimeError still propagates.

Signed-off-by: Carsten Piepel <cpiepel@esri.com>
Comment thread src/a2a/server/agent_execution/active_task.py Outdated
Comment thread src/a2a/server/agent_execution/active_task.py
Wrap each dequeued item in one try/finally so task_done() runs exactly
once on every path (raise, lifecycle skip, return, yield). A re-raised
error was never acked, which hangs a graceful close already joining the
tap. Add a regression test and trim the a2aproject#1322 test to essentials.
Comment thread tests/server/agent_execution/test_active_task.py Outdated
@Iwaniukooo11

Copy link
Copy Markdown
Member

@carstenpiepel I've added just one final comment. Once that is addressed, we should be all set and ready to merge:)

@Iwaniukooo11 Iwaniukooo11 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@Iwaniukooo11
Iwaniukooo11 merged commit a4c4fc9 into a2aproject:main Oct 14, 2026
20 checks passed
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.

[Bug]: After a streamed task is cancelled, its background producer never finishes

2 participants