Skip to content

fix: serialize concurrent cancel requests with a per-task lock - #1185

Open
ez-lbz wants to merge 2 commits into
a2aproject:mainfrom
ez-lbz:fix/cancel-toctou
Open

fix: serialize concurrent cancel requests with a per-task lock#1185
ez-lbz wants to merge 2 commits into
a2aproject:mainfrom
ez-lbz:fix/cancel-toctou

Conversation

@ez-lbz

@ez-lbz ez-lbz commented Aug 10, 2026

Copy link
Copy Markdown

What changed

1. Serialize the cancel check-then-act with a per-task lock

Problem: LegacyRequestHandler.on_cancel_task() (src/a2a/server/request_handlers/default_request_handler.py) performed "load task → check terminal state → call agent_executor.cancel()" without any synchronization. Two concurrent cancels of the same task could both pass the terminal-state check and both issue a cancel, duplicating the cancellation side effects (TOCTOU race).

Fix (src/a2a/server/request_handlers/default_request_handler.py):

  • Added a per-task lock map (_cancel_locks) so the entire check-then-act critical section in on_cancel_task is atomic per task. The first cancel to acquire the lock performs the cancel; any concurrent cancel waits, then re-reads the task state and fails with TaskNotCancelableError if the task is now terminal.
  • Each entry carries a user count so the lock entry is garbage-collected once the last caller finishes (no unbounded growth), and entries are only removed when no other caller can be waiting on them.
  • V2's DefaultRequestHandlerV2 is unaffected: cancels there are already serialized per ActiveTask by ActiveTaskRegistry/ActiveTask.

Testing

  • ./.venv/Scripts/python -m pytest tests/server/request_handlers/test_default_request_handler.py -q75 passed (includes new test_on_cancel_task_serializes_concurrent_cancels: two concurrent cancels, the first blocks inside the executor, and the second must observe the resulting terminal state and raise TaskNotCancelableError without ever reaching the executor; also asserts the per-task lock entry is cleaned up afterwards).
  • ./.venv/Scripts/python -m ruff check on modified files: clean (the pre-existing too-many-positional-arguments finding on the handler __init__ exists on main).
  • Behavior change: concurrent cancels of the same task are now serialized; the losing request gets TaskNotCancelableError instead of issuing a duplicate cancel.

@ez-lbz
ez-lbz requested a review from a team as a code owner August 10, 2026 16:11
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

Base PR Delta
src/a2a/server/events/event_queue_v2.py 91.79% 91.28% 🔴 -0.51%
src/a2a/server/request_handlers/default_request_handler.py 98.13% 98.26% 🟢 +0.12%
src/a2a/utils/telemetry.py 91.47% 90.70% 🔴 -0.78%
Total 93.00% 92.99% ⚪️ -0.00%

Generated by coverage-comment.yml

The ref-counted per-task cancel lock was typed as a heterogeneous list
(`list[asyncio.Lock | int]`), which the type checker cannot narrow by
index (entry[0] vs entry[1]). Replace it with a small dataclass carrying
the lock and the in-flight user count, and add the TOCTOU acronym to the
spellcheck allow list.
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