Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

**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 <https://github.com/agronholm/anyio/issues/1214>`_; PR by
@MohammedAnasNathani)
- Added ``StapledObjectStream.send_nowait()`` that delegates to the underlying
``ObjectSendStream``, if it implements it
(`#1241 <https://github.com/agronholm/anyio/pull/1241>`_; PR by @davidbrochart)
Expand Down
27 changes: 25 additions & 2 deletions src/anyio/_backends/_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ class CancelScope(BaseCancelScope):
"_child_scopes",
"_deadline",
"_host_task",
"_host_task_cancel_count_at_enter",
"_parent_scope",
"_pending_uncancellations",
"_shield",
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand All @@ -530,7 +553,7 @@ def __exit__(
exc_val
):
self._cancelled_caught = True
return True
return not external_cancel_pending
else:
return False
else:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_taskgroups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Comment on lines +1692 to +1734

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This test is a massively overcomplicated. The bug can be reproduced with 1/5 of the code here.

async def test_cancel_message_replaced(self) -> None:
task = asyncio.current_task()
assert task
Expand Down
Loading