Skip to content

Commit 96bef42

Browse files
kovanclaude
andcommitted
Add asyncio.CancelScope with level-triggered cancellation
Introduce CancelScope, an async context manager that provides level-triggered cancellation semantics for asyncio. Once cancelled (via cancel() or deadline expiry), every subsequent await inside the scope raises CancelledError until the scope exits — the coroutine cannot simply catch-and-ignore. This integrates with Task.__step by checking the current scope's state after the existing edge-triggered _must_cancel check. The scope pushes/pops itself on a per-task _current_cancel_scope linked-list stack. Existing edge-triggered mechanisms (cancel(), uncancel(), Timeout, TaskGroup) remain completely unchanged. New public API: - asyncio.CancelScope(*, deadline=None, shield=False) - asyncio.cancel_scope(delay, *, shield=False) - asyncio.cancel_scope_at(when, *, shield=False) Both the Python (_PyTask) and C (_CTask) Task implementations are updated. Code not using CancelScope has zero overhead (_current_cancel_scope is None → single pointer check). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 72eca2a commit 96bef42

6 files changed

Lines changed: 665 additions & 0 deletions

File tree

Lib/asyncio/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
# This relies on each of the submodules having an __all__ variable.
88
from .base_events import *
9+
from .cancelscope import *
910
from .coroutines import *
1011
from .events import *
1112
from .exceptions import *
@@ -24,6 +25,7 @@
2425
from .transports import *
2526

2627
__all__ = (base_events.__all__ +
28+
cancelscope.__all__ +
2729
coroutines.__all__ +
2830
events.__all__ +
2931
exceptions.__all__ +

Lib/asyncio/cancelscope.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""CancelScope — level-triggered cancellation for asyncio."""
2+
3+
__all__ = ('CancelScope', 'cancel_scope', 'cancel_scope_at')
4+
5+
from . import events
6+
from . import exceptions
7+
from . import tasks
8+
9+
10+
class CancelScope:
11+
"""Async context manager providing level-triggered cancellation.
12+
13+
Once cancelled (via :meth:`cancel` or deadline expiry), every subsequent
14+
``await`` inside the scope raises :exc:`~asyncio.CancelledError` until the
15+
scope exits. The coroutine cannot simply catch-and-ignore the error.
16+
17+
Parameters
18+
----------
19+
deadline : float or None
20+
Absolute event-loop time after which the scope auto-cancels.
21+
shield : bool
22+
If ``True``, the level-triggered re-injection is suppressed while
23+
the scope is the current scope.
24+
"""
25+
26+
def __init__(self, *, deadline=None, shield=False):
27+
self._deadline = deadline
28+
self._shield = shield
29+
self._cancel_called = False
30+
self._task = None
31+
self._parent_scope = None
32+
self._timeout_handle = None
33+
self._host_task_cancelling = 0
34+
self._cancelled_caught = False
35+
36+
# -- public properties ---------------------------------------------------
37+
38+
@property
39+
def deadline(self):
40+
"""Absolute event-loop time of the deadline, or *None*."""
41+
return self._deadline
42+
43+
@deadline.setter
44+
def deadline(self, value):
45+
self._deadline = value
46+
if self._task is not None and not self._task.done():
47+
self._reschedule()
48+
49+
@property
50+
def shield(self):
51+
"""Whether level-triggered re-injection is suppressed."""
52+
return self._shield
53+
54+
@shield.setter
55+
def shield(self, value):
56+
self._shield = value
57+
58+
@property
59+
def cancel_called(self):
60+
"""``True`` after :meth:`cancel` has been called."""
61+
return self._cancel_called
62+
63+
@property
64+
def cancelled_caught(self):
65+
"""``True`` if the scope caught the :exc:`CancelledError` on exit."""
66+
return self._cancelled_caught
67+
68+
# -- control methods -----------------------------------------------------
69+
70+
def cancel(self):
71+
"""Cancel this scope.
72+
73+
All subsequent awaits inside the scope will raise
74+
:exc:`~asyncio.CancelledError`.
75+
"""
76+
if not self._cancel_called:
77+
self._cancel_called = True
78+
if self._task is not None and not self._task.done():
79+
self._task.cancel()
80+
81+
def reschedule(self, deadline):
82+
"""Change the deadline.
83+
84+
If *deadline* is ``None`` the timeout is removed.
85+
"""
86+
self._deadline = deadline
87+
if self._task is not None:
88+
self._reschedule()
89+
90+
# -- async context manager -----------------------------------------------
91+
92+
async def __aenter__(self):
93+
task = tasks.current_task()
94+
if task is None:
95+
# Fallback: _PyTask uses Python-level tracking that the
96+
# C current_task() does not see.
97+
task = tasks._py_current_task()
98+
if task is None:
99+
raise RuntimeError("CancelScope requires a running task")
100+
self._task = task
101+
self._host_task_cancelling = task.cancelling()
102+
self._parent_scope = task._current_cancel_scope
103+
task._current_cancel_scope = self
104+
if self._deadline is not None:
105+
loop = events.get_running_loop()
106+
self._timeout_handle = loop.call_at(
107+
self._deadline, self._on_deadline)
108+
return self
109+
110+
async def __aexit__(self, exc_type, exc_val, exc_tb):
111+
if self._timeout_handle is not None:
112+
self._timeout_handle.cancel()
113+
self._timeout_handle = None
114+
115+
# Pop scope stack
116+
self._task._current_cancel_scope = self._parent_scope
117+
118+
if self._cancel_called:
119+
# Consume the one cancel() we injected, bringing the task's
120+
# cancellation counter back to where it was on __aenter__.
121+
if self._task.cancelling() > self._host_task_cancelling:
122+
self._task.uncancel()
123+
if exc_type is not None and issubclass(
124+
exc_type, exceptions.CancelledError):
125+
self._cancelled_caught = True
126+
return True # suppress the CancelledError
127+
128+
return False
129+
130+
# -- internal helpers ----------------------------------------------------
131+
132+
def _reschedule(self):
133+
if self._timeout_handle is not None:
134+
self._timeout_handle.cancel()
135+
self._timeout_handle = None
136+
if self._deadline is not None and not self._task.done():
137+
loop = events.get_running_loop()
138+
self._timeout_handle = loop.call_at(
139+
self._deadline, self._on_deadline)
140+
141+
def _on_deadline(self):
142+
self._timeout_handle = None
143+
self.cancel()
144+
145+
146+
def cancel_scope(delay, *, shield=False):
147+
"""Return a :class:`CancelScope` that expires *delay* seconds from now."""
148+
loop = events.get_running_loop()
149+
return CancelScope(deadline=loop.time() + delay, shield=shield)
150+
151+
152+
def cancel_scope_at(when, *, shield=False):
153+
"""Return a :class:`CancelScope` that expires at absolute time *when*."""
154+
return CancelScope(deadline=when, shield=shield)

Lib/asyncio/tasks.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def __init__(self, coro, *, loop=None, name=None, context=None,
101101
self._must_cancel = False
102102
self._fut_waiter = None
103103
self._coro = coro
104+
self._current_cancel_scope = None
104105
if context is None:
105106
self._context = contextvars.copy_context()
106107
else:
@@ -271,6 +272,11 @@ def __step(self, exc=None):
271272
if not isinstance(exc, exceptions.CancelledError):
272273
exc = self._make_cancelled_error()
273274
self._must_cancel = False
275+
elif (self._current_cancel_scope is not None
276+
and self._current_cancel_scope._cancel_called
277+
and not self._current_cancel_scope._shield
278+
and not isinstance(exc, exceptions.CancelledError)):
279+
exc = self._make_cancelled_error()
274280
self._fut_waiter = None
275281

276282
_py_enter_task(self._loop, self)

0 commit comments

Comments
 (0)