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