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 d0f4b0126..71f3f1616 100644 --- a/src/anyio/_backends/_asyncio.py +++ b/src/anyio/_backends/_asyncio.py @@ -390,6 +390,7 @@ class CancelScope(BaseCancelScope): "_child_scopes", "_deadline", "_host_task", + "_host_task_cancel_count_at_enter", "_parent_scope", "_pending_uncancellations", "_shield", @@ -413,6 +414,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: @@ -442,6 +446,8 @@ def __enter__(self) -> Self: 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: @@ -499,6 +505,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( @@ -514,7 +537,7 @@ def __exit__( self._cancelled_caught = True if remaining is None: - return True + return not external_cancel_pending context = remaining.__context__ try: @@ -530,7 +553,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 cd935f796..7a2fac7f0 100644 --- a/tests/test_taskgroups.py +++ b/tests/test_taskgroups.py @@ -1689,6 +1689,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