From dac3398b2380fd2edbb13ca363e4d4fc3dd54916 Mon Sep 17 00:00:00 2001 From: Mohammed Anas Nathani Date: Wed, 29 Jul 2026 20:13:25 +0530 Subject: [PATCH] fix: do not swallow concurrent native Task.cancel on CancelScope exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cancel scope delivers cancellation to its host task (e.g. Happy Eyeballs winning and cancelling the task group) and a native Task.cancel() lands in the same cycle, __exit__ previously uncancelled only the AnyIO-tagged cancels and then swallowed the AnyIO CancelledError. That left cancelling() > 0 with no exception propagating — the host returned normally and callers waiting on the task hung. Snapshot Task.cancelling() at scope enter and, after undoing this scope's pending uncancels, refuse to swallow if the cancel count is still above that baseline. Pre-existing cancels from before enter are unaffected. Fixes #1214 --- docs/versionhistory.rst | 7 ++++++ src/anyio/_backends/_asyncio.py | 27 +++++++++++++++++++-- tests/test_taskgroups.py | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index b5b8d01b9..f44d58e79 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -5,6 +5,13 @@ This library adheres to `Semantic Versioning 2.0 `_. **UNRELEASED** +- Fixed ``CancelScope`` on the asyncio backend swallowing a concurrent native + ``Task.cancel()`` when the scope was also cancelled (e.g. Happy Eyeballs + winning and cancelling the host task group while the caller cancels the host + task). The host task previously returned normally with ``cancelling() > 0`` + and ``cancelled() is False``; ``CancelledError`` now propagates as expected + (`#1214 `_; PR by + @MohammedAnasNathani) - Added ``StapledObjectStream.send_nowait()`` that delegates to the underlying ``ObjectSendStream``, if it implements it (`#1241 `_; PR by @davidbrochart) diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py index 4fc1f0c64..090e06b04 100644 --- a/src/anyio/_backends/_asyncio.py +++ b/src/anyio/_backends/_asyncio.py @@ -391,6 +391,7 @@ class CancelScope(BaseCancelScope): "_child_scopes", "_deadline", "_host_task", + "_host_task_cancel_count_at_enter", "_parent_scope", "_pending_uncancellations", "_shield", @@ -416,6 +417,9 @@ def __init__(self, deadline: float = math.inf, shield: bool = False): self._cancel_handle: asyncio.Handle | None = None self._tasks: set[asyncio.Task] = set() self._host_task: asyncio.Task | None = None + # Snapshot of host Task.cancelling() at __enter__; used so that on exit we + # only treat *new* native cancels (above this baseline) as external (#1214). + self._host_task_cancel_count_at_enter: int = 0 if sys.version_info >= (3, 11): self._pending_uncancellations: int | None = 0 else: @@ -445,6 +449,8 @@ def __enter__(self) -> CancelScope: self._timeout() self._active = True + if sys.version_info >= (3, 11): + self._host_task_cancel_count_at_enter = host_task.cancelling() # Start cancelling the host task if the scope was cancelled before entering if self._cancel_called: @@ -502,6 +508,23 @@ def __exit__( self._host_task.uncancel() self._pending_uncancellations -= 1 + # If a native Task.cancel() landed *while this scope was active* + # (e.g. Happy Eyeballs wins and cancels the host TG at the same + # time the caller cancels the host task), the CancelledError we + # hold may be the AnyIO-tagged one, but after undoing *our* + # cancels the host still has a cancel count above the enter + # baseline. Swallowing would leave cancelling() elevated with + # no exception propagating — the task returns normally and the + # caller's await never sees CancelledError (#1214). + # Compare against the enter baseline so a cancel that was + # already pending before ``__enter__`` does not block swallow + # (see test_cancel_message_replaced). + external_cancel_pending = ( + self._pending_uncancellations is not None + and self._host_task.cancelling() + > self._host_task_cancel_count_at_enter + ) + # Update cancelled_caught and check for exceptions we must not swallow if isinstance(exc_val, BaseExceptionGroup): cancelleds_caught, remaining = exc_val.split( @@ -517,7 +540,7 @@ def __exit__( self._cancelled_caught = True if remaining is None: - return True + return not external_cancel_pending context = remaining.__context__ try: @@ -533,7 +556,7 @@ def __exit__( exc_val ): self._cancelled_caught = True - return True + return not external_cancel_pending else: return False else: diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py index f9bff2556..7b87c2f53 100644 --- a/tests/test_taskgroups.py +++ b/tests/test_taskgroups.py @@ -1688,6 +1688,49 @@ async def test_uncancel_after_scope_and_native_cancel(self) -> None: assert task.cancelling() == 1 task.uncancel() + async def test_child_scope_cancel_does_not_swallow_native_host_cancel( + self, + ) -> None: + """ + When a child cancels its task group (Happy Eyeballs style) and a native + ``Task.cancel()`` lands on the host in the same cycle, the host must still + observe ``CancelledError`` — not return normally with ``cancelling() > 0``. + + Regression test for #1214. + """ + attempt_started = asyncio.Event() + connection_won = asyncio.Event() + + async def operation() -> None: + async with create_task_group() as task_group: + + async def connect_attempt() -> None: + attempt_started.set() + await connection_won.wait() + # Same pattern as connect_tcp(): winner cancels the group. + task_group.cancel_scope.cancel() + + task_group.start_soon(connect_attempt) + await sleep_forever() + + task = asyncio.create_task(operation()) + await attempt_started.wait() + + external_cancel_accepted: list[bool] = [] + + def externally_cancel() -> None: + external_cancel_accepted.append(task.cancel("external cancellation")) + + connection_won.set() + asyncio.get_running_loop().call_soon(externally_cancel) + + with pytest.raises(asyncio.CancelledError): + await task + + assert external_cancel_accepted == [True] + assert task.cancelled() + assert task.cancelling() >= 1 + async def test_cancel_message_replaced(self) -> None: task = asyncio.current_task() assert task