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