diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index b9846a30c..f273b095d 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -45,6 +45,9 @@ This library adheres to `Semantic Versioning 2.0 `_. ``Path(".txt")``) instead of raising ``ValueError`` when given an empty stem on a path with a non-empty suffix, unlike :meth:`pathlib.PurePath.with_stem` (`#1200 `_; PR by @Sanjays2402) +- Fixed cancellation delivery on asyncio scheduling an unnecessary retry for tasks + that already had native cancellation pending + (`#1258 `_; PR by @subotac) **4.14.2** diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py index d0f4b0126..c547c319d 100644 --- a/src/anyio/_backends/_asyncio.py +++ b/src/anyio/_backends/_asyncio.py @@ -595,10 +595,11 @@ def _deliver_cancellation(self, origin: CancelScope) -> bool: if task.done(): continue - should_retry = True if task._must_cancel: # type: ignore[attr-defined] continue + should_retry = True + # The task is eligible for cancellation if it has started if task is not current and (task is self._host_task or _task_started(task)): waiter = task._fut_waiter # type: ignore[attr-defined] diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py index cd935f796..3ead143ff 100644 --- a/tests/test_taskgroups.py +++ b/tests/test_taskgroups.py @@ -400,6 +400,45 @@ async def owner() -> EditableCancelScope: spy.assert_called_once() +@pytest.mark.parametrize("anyio_backend", asyncio_params) +async def test_no_retry_for_task_with_pending_cancellation( + mocker: MockerFixture, +) -> None: + """Regression test for #1258.""" + from anyio._backends import _asyncio + + # To allow the mocker to override a @final class + class EditableCancelScope(_asyncio.CancelScope): + pass + + async def owner( + started: asyncio.Future[EditableCancelScope], blocker: asyncio.Future[None] + ) -> None: + scope = EditableCancelScope().__enter__() + started.set_result(scope) + await blocker + + loop = asyncio.get_running_loop() + started: asyncio.Future[EditableCancelScope] = loop.create_future() + blocker: asyncio.Future[None] = loop.create_future() + task = asyncio.create_task(owner(started, blocker)) + scope = await started + spy = mocker.spy(scope, "_deliver_cancellation") + + # Make the waiter done before cancelling the task so asyncio marks the task + # itself for cancellation instead of forwarding cancellation to the waiter. + blocker.set_result(None) + task.cancel() + assert task._must_cancel # type: ignore[attr-defined] + + scope.cancel() + assert scope._cancel_handle is None + spy.assert_called_once() + + with pytest.raises(asyncio.CancelledError): + await task + + @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: