SWE-Race › Tasks › agronholm-anyio-1217 ← prevnext →

agronholm-anyio-1217

agronholm/anyiosplitsinglemerged 2026-07-12MITfix: 2 files, +7 −01 fail-to-pass · 485 pass-to-pass
Results
Modelsolved / attemptsmedian stepsmedian costattempts
GPT-5.6 Luna5/615$0.0121✓ 2✗ 3✓ 4✓ 5✓ 6✓
DeepSeek V4 Flash2/254$0.0531✓ 2✓
GLM-5.3 Flash2/218$0.0041✓ 2✓
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_no_spin_on_done_task_in_cancel_scope
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

What each model did

Submitted diff first, then every command. Outputs show 12 lines; expand for the rest.
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]
                 continue
step 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

    @property
34 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_called
step 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.