agronholm-anyio-1217
Results
The prompt the agent sees
Cancelling a cancel scope after the task that created and entered it has already finished can cause the event loop to spin indefinitely at 100% CPU. This can occur when a task group or cancel scope is abandoned without being exited, leaving the completed task associated with the scope.
The application should remain responsive after cancellation: the cancellation handling should run once, recognize that no active task remains to cancel, and leave no pending cancellation work scheduled.
Hidden tests · 1 fail-to-pass, 485 pass-to-passrun after the agent submits, in a clean verifier
Test patch · 54 lines
diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py
index e31c8ddb0..52fe64957 100644
--- a/tests/test_taskgroups.py
+++ b/tests/test_taskgroups.py
@@ -15,6 +15,7 @@
import pytest
from pytest import FixtureRequest, MonkeyPatch
+from pytest_mock import MockerFixture
import anyio
from anyio import (
@@ -360,6 +361,41 @@ async def middle_task() -> None:
assert len(outer_cancel_spy.call_args_list) < 10
+@pytest.mark.parametrize("anyio_backend", asyncio_params)
+async def test_no_spin_on_done_task_in_cancel_scope(mocker: MockerFixture) -> None:
+ """Regression test for #1111.
+
+ When a task group is entered but never exited (e.g. it is abandoned because
+ the task owning it finishes without unwinding it), its cancel scope is left
+ with the now-finished host task still in its set of contained tasks.
+ Cancelling such a scope used to make ``_deliver_cancellation`` treat that
+ finished task as still cancellable and reschedule itself via ``call_soon``
+ forever, pinning the event loop at 100% CPU.
+ """
+ from anyio._backends import _asyncio
+
+ # To allow the mocker to override a @final class
+ class EditableCancelScope(_asyncio.CancelScope):
+ pass
+
+ async def owner() -> EditableCancelScope:
+ return cast(EditableCancelScope, EditableCancelScope().__enter__())
+
+ scope = await asyncio.create_task(owner())
+ spy = mocker.spy(scope, "_deliver_cancellation")
+ scope.cancel()
+
+ # Give any rescheduled _deliver_cancellation callbacks the chance to fire.
+ # With the bug present this reschedules forever (the test timeout would
+ # trip); the fix leaves no pending cancellation callback since the only
+ # remaining task is done.
+ for _ in range(5):
+ await checkpoint()
+
+ assert scope._cancel_handle is None
+ spy.assert_called_once()
+
+
@pytest.mark.parametrize("return_handle", [False, True])
async def test_start_exception_delivery(return_handle: bool) -> None:
def task_fn(*, task_status: TaskStatus[str] = TASK_STATUS_IGNORED) -> None:
Reference fix · 2 files, +7 −0the upstream merge, used only for grading calibration
The agent could not see this: the repository holds one commit and the sandbox has no network. Leak audit.
docs/versionhistory.rst, src/anyio/_backends/_asyncio.py
diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index d75278c54..9eb446c1d 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -34,6 +34,9 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
reserved for the woken waiter right away, so the non-blocking acquire correctly
raises ``WouldBlock``
(`#1170 <https://github.com/agronholm/anyio/issues/1170>`_; PR by @gaoflow)
+- Fixed unnecessary CPU spin when delivering cancellation from ``CancelScope`` on
+ asyncio under certain conditions, including improper cancel scope nesting
+ (`#1111 <https://github.com/agronholm/anyio/issues/1111>`_)
**4.14.1**
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index d4d3db32b..c00c2cd9b 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -592,6 +592,10 @@ def _deliver_cancellation(self, origin: CancelScope) -> bool:
should_retry = False
current = current_task()
for task in self._tasks:
+ # Always skip tasks that are already done (see issue #1111)
+ if task.done():
+ continue
+
should_retry = True
if task._must_cancel: # type: ignore[attr-defined]
continue
GLM-5.3 Flash · attempt 2 · solved · 9 steps · $0.001
Submitted diff · 16 lines
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index d4d3db3..ad81cf0 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -592,6 +592,11 @@ class CancelScope(BaseCancelScope):
should_retry = False
current = current_task()
for task in self._tasks:
+ if task.done():
+ # A finished task cannot be cancelled anymore, so don't let it keep
+ # the cancellation delivery rescheduling itself indefinitely
+ continue
+
should_retry = True
if task._must_cancel: # type: ignore[attr-defined]
continuestep 1
$ ls /repo; ls /repo/src 2>/dev/null
output · 10 lines
AGENTS.md CLAUDE.md LICENSE README.rst docs pyproject.toml src tests anyio anyio.egg-info
step 2
$ grep -rn "deliver_cancellation\|_cancel_called\|CancelScope" /repo/src/anyio/_backends/_asyncio.py | head -50
output · 26 lines
94:from .._core._tasks import CancelScope as BaseCancelScope 382:class CancelScope(BaseCancelScope): 385: "_cancel_called", 401: ) -> CancelScope: 407: self._parent_scope: CancelScope | None = None 408: self._child_scopes: set[CancelScope] = set() 409: self._cancel_called = False 422: def __enter__(self) -> CancelScope: 425: "Each CancelScope may only be used for a single 'with' block" 448: if self._cancel_called: 449: self._deliver_cancellation(self) 497: if self._cancel_called and not self._parent_cancellation_is_visible_to_us:
14 more lines
553: cancel_scope: CancelScope | None = self 555: if cancel_scope._cancel_called: 581: def _deliver_cancellation(self, origin: CancelScope) -> bool: 614: should_retry = scope._deliver_cancellation(origin) or should_retry 620: se …[794 chars omitted]… one = None 1582: with CancelScope() as self._accept_scope: 2516: with CancelScope(shield=True): 2526: ) -> CancelScope: 2527: return CancelScope(deadline=deadline, shield=shield) 2542: if cancel_scope._cancel_called: 2600: with CancelScope(shield=not abandon_on_cancel) as scope: 2641: scope: CancelScope | None = threadlocals.current_cancel_scope
step 3
$ sed -n 540,680p /repo/src/anyio/_backends/_asyncio.py
output · 46 lines
assert self._parent_scope._pending_uncancellations is not None
self._parent_scope._pending_uncancellations += (
self._pending_uncancellations
)
self._pending_uncancellations = 0
return False
finally:
self._host_task = None
del exc_val
@property34 more lines
def _effectively_cancelled(self) -> bool:
cancel_scope: CancelScope | None = self
while cancel_scope is not None:
if cancel_scope._cancel_called:
return True
if cancel_scope.shield:
return False
cancel_scope = cancel_scope._parent_scope
return False
@property
def _parent_cancellation_is_visible_to_us(self) -> bool:
return (
self._parent_scope is not None
…[3542 chars omitted]…
e
@deadline.setter
def deadline(self, value: float) -> None:
self._deadline = float(value)
if self._timeout_handle is not None:
self._timeout_handle.cancel()
self._timeout_handle = None
if self._active and not self._cancel_called:
self._timeout()
@property
def cancel_called(self) -> bool:
return self._cancel_calledstep 4
$ sed -n 380,470p /repo/src/anyio/_backends/_asyncio.py; grep -n "_tasks" /repo/src/anyio/_backends/_asyncio.py | head
output · 41 lines
class CancelScope(BaseCancelScope):
__slots__ = (
"_active",
"_cancel_called",
"_cancel_handle",
"_cancel_reason",
"_cancelled_caught",
"_child_scopes",
"_deadline",
"_host_task",
"_parent_scope",
"_pending_uncancellations",29 more lines
"_shield",
"_tasks",
"_timeout_handle",
)
def __new__(
cls, *, deadline: float = math.inf, shield: bool = False
) -> CancelScope:
return object.__new__(cls)
def __init__(self, deadline: float = math.inf, shield: bool = False):
self._deadline = deadline
self._shield = shield
self._parent_scope: CancelScope | None = None
self._child_scopes: set[CancelScope] = set()
self._cancel_called = False
self._cancel_reason: str | None = None
self._cancelled_caught = False
…[2161 chars omitted]…
mport CancelScope as BaseCancelScope
95:from .._core._tasks import TaskHandle
104:from ..abc._tasks import call_for_coroutine, get_callable_name
164: _cancel_all_tasks(loop)
258: def _cancel_all_tasks(loop: AbstractEventLoop) -> None:
259: to_cancel = tasks.all_tasks(loop)
317: for task in all_tasks():
395: "_tasks",
415: self._tasks: set[asyncio.Task] = set()step 5
$ cat > /tmp/repro.py <<'EOF'
output · 17 lines
cancel_handle: <Handle CancelScope._deliver_cancellation(<anyio._backe...x7c96f187d810>)>
Traceback (most recent call last):
File "/tmp/repro.py", line 16, in <module>
asyncio.run(main())
File "/usr/local/lib/python3.12/asyncio/runners.py", line 195, in run
return runner.run(main)
^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/asyncio/runners.py", line 118, in run
return self._loop.run_until_complete(task)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.12/asyncio/base_events.py", line 691, in run_until_complete
return future.result()5 more lines
^^^^^^^^^^^^^^^
File "/tmp/repro.py", line 13, in main
assert scope._cancel_handle is None, "cancellation keeps rescheduling!"
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: cancellation keeps rescheduling!step 6
$ cd /repo && python - <<'PY'
output · 2 lines
cancel_handle: None OK
step 7
$ cd /repo && python -m pytest tests/test_taskgroups.py -q 2>&1 | tail -5
output · 5 lines
=========================== short test summary info ============================ SKIPPED [1] tests/test_taskgroups.py:1279: Generator based coroutines have been removed in Python 3.11 SKIPPED [3] tests/test_taskgroups.py:1300: Generator based coroutines have been removed in Python 3.11 SKIPPED [6] tests/test_taskgroups.py:2038: Requires Python 3.14 485 passed, 10 skipped, 4 xfailed in 46.41s
step 8
$ cd /repo && python -m pytest tests/test_from_thread.py tests/test_synchronization.py -q 2>&1 | tail -3; git diff --stat
output · 5 lines
=========================== short test summary info ============================ SKIPPED [4] tests/test_from_thread.py:745: This test is known to hang often on CI 464 passed, 4 skipped, 1 xfailed in 41.33s src/anyio/_backends/_asyncio.py | 5 +++++ 1 file changed, 5 insertions(+)
step 9
$ done: {"notes": "Fixed busy-loop in asyncio CancelScope._deliver_cancellation by skipping finished tasks (task.done()) so cancellation delivery stops rescheduling when no active task remains. Reproducer passes; taskgroup/thread tests pass."}output · 1 lines
submitted.