From 9f5f14b3eb57f6965fc2c16879df93263bb020ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Mon, 27 May 2024 00:25:21 +0300 Subject: [PATCH] Fixed task group getting cancelled if start() gets cancelled (#717) This change fixes the problem by special casing the situation where the Future backing `task_status` was cancelled which only happens when the host task is cancelled. --- docs/versionhistory.rst | 13 +++++++++---- src/anyio/_backends/_asyncio.py | 6 ++++++ tests/test_taskgroups.py | 23 +++++++++++++++++++---- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 01028693..2ecec4bb 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -10,14 +10,19 @@ This library adheres to `Semantic Versioning 2.0 `_. portals - Added ``__slots__`` to ``AsyncResource`` so that child classes can use ``__slots__`` (`#733 `_; PR by Justin Su) +- Fixed two bugs with ``TaskGroup.start()`` on asyncio: + + * Fixed erroneous ``RuntimeError: called 'started' twice on the same task status`` + when cancelling a task in a TaskGroup created with the ``start()`` method before + the first checkpoint is reached after calling ``task_status.started()`` + (`#706 `_; PR by Dominik Schwabe) + * Fixed the entire task group being cancelled if a ``TaskGroup.start()`` call gets + cancelled (`#685 `_, + `#710 `_) - Fixed a race condition that caused crashes when multiple event loops of the same backend were running in separate threads and simultaneously attempted to use AnyIO for their first time (`#425 `_; PR by David Jiricek and Ganden Schaffner) -- Fixed erroneous ``RuntimeError: called 'started' twice on the same task status`` - when cancelling a task in a TaskGroup created with the ``start()`` method before - the first checkpoint is reached after calling ``task_status.started()`` - (`#706 `_; PR by Dominik Schwabe) - Fixed cancellation delivery on asyncio incrementing the wrong cancel scope's cancellation counter when cascading a cancel operation to a child scope, thus failing to uncancel the host task (`#716 `_) diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py index ecd865a7..b69665c2 100644 --- a/src/anyio/_backends/_asyncio.py +++ b/src/anyio/_backends/_asyncio.py @@ -714,6 +714,12 @@ def task_done(_task: asyncio.Task) -> None: exc = e if exc is not None: + # The future can only be in the cancelled state if the host task was + # cancelled, so return immediately instead of adding one more + # CancelledError to the exceptions list + if task_status_future is not None and task_status_future.cancelled(): + return + if task_status_future is None or task_status_future.done(): if not isinstance(exc, CancelledError): self._exceptions.append(exc) diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py index da84f656..11329df4 100644 --- a/tests/test_taskgroups.py +++ b/tests/test_taskgroups.py @@ -22,6 +22,7 @@ get_current_task, move_on_after, sleep, + sleep_forever, wait_all_tasks_blocked, ) from anyio.abc import TaskGroup, TaskStatus @@ -127,7 +128,6 @@ async def test_no_called_started_twice() -> None: async def taskfunc(*, task_status: TaskStatus) -> None: task_status.started() - # anyio>4.3.0 should not raise "RuntimeError: called 'started' twice on the same task status" async with create_task_group() as tg: coro = tg.start(taskfunc) tg.cancel_scope.cancel() @@ -196,9 +196,6 @@ async def taskfunc(*, task_status: TaskStatus) -> None: assert not finished -@pytest.mark.xfail( - sys.version_info < (3, 9), reason="Requires a way to detect cancellation source" -) @pytest.mark.parametrize("anyio_backend", ["asyncio"]) async def test_start_native_host_cancelled() -> None: started = finished = False @@ -1360,6 +1357,24 @@ async def wait_cancel() -> None: await cancelled.wait() +async def test_start_cancels_parent_scope() -> None: + """Regression test for #685 / #710.""" + started: bool = False + + async def in_task_group(task_status: TaskStatus[None]) -> None: + nonlocal started + started = True + await sleep_forever() + + async with create_task_group() as tg: + with CancelScope() as inner_scope: + inner_scope.cancel() + await tg.start(in_task_group) + + assert started + assert not tg.cancel_scope.cancel_called + + class TestTaskStatusTyping: """ These tests do not do anything at run time, but since the test suite is also checked