diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index d34afd0e2..d8705943c 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -95,6 +95,8 @@ This library adheres to `Semantic Versioning 2.0 `_. task. (`#1197 `_; PR by @tapetersen) +- Fixed ``CancelScope`` not raising a ``RuntimeError`` on asyncio when re-entered + (`#1296 `_; PR by @jaideeppyne) - Fixed ``TemporaryDirectory`` not cleaning up when the host task was cancelled while exiting the context manager, as the cleanup now runs in a shielded cancel scope (`#1304 `_; PR by @smurfix) diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py index 7eba257c9..bebb5e1e3 100644 --- a/src/anyio/_backends/_asyncio.py +++ b/src/anyio/_backends/_asyncio.py @@ -390,6 +390,7 @@ class CancelScope(BaseCancelScope): "_cancelled_caught", "_child_scopes", "_deadline", + "_has_been_entered", "_host_task", "_parent_scope", "_pending_uncancellations", @@ -410,6 +411,7 @@ def __init__(self, deadline: float = math.inf, shield: bool = False): self._cancel_reason: str | None = None self._cancelled_caught = False self._active = False + self._has_been_entered = False self._timeout_handle: asyncio.TimerHandle | None = None self._cancel_handle: asyncio.Handle | None = None self._tasks: set[asyncio.Task] = set() @@ -420,11 +422,12 @@ def __init__(self, deadline: float = math.inf, shield: bool = False): self._pending_uncancellations = None def __enter__(self) -> Self: - if self._active: + if self._has_been_entered: raise RuntimeError( "Each CancelScope may only be used for a single 'with' block" ) + self._has_been_entered = True self._host_task = host_task = cast(asyncio.Task, current_task()) self._tasks.add(host_task) try: diff --git a/tests/test_taskgroups.py b/tests/test_taskgroups.py index d05dc908c..a5ef54dd2 100644 --- a/tests/test_taskgroups.py +++ b/tests/test_taskgroups.py @@ -1604,6 +1604,42 @@ async def test_cancelscope_exit_before_enter() -> None: pytest.raises(RuntimeError, scope.__exit__, None, None, None) +async def test_cancelscope_reuse() -> None: + """ + Test that a RuntimeError is raised if one tries to enter a cancel scope that has + already been exited. + + """ + scope = CancelScope() + with scope: + pass + + with pytest.raises( + RuntimeError, + match="Each CancelScope may only be used for a single 'with' block", + ): + with scope: + pass + + +async def test_cancelscope_reuse_after_cancel() -> None: + """ + Test that reusing a cancelled cancel scope raises a RuntimeError instead of + silently cancelling the body of the second ``with`` block. + + """ + scope = CancelScope() + with scope: + scope.cancel() + + with pytest.raises( + RuntimeError, + match="Each CancelScope may only be used for a single 'with' block", + ): + with scope: + await checkpoint() + + @pytest.mark.parametrize( "anyio_backend", asyncio_params ) # trio does not check for this yet