diff --git a/docs/source/history.rst b/docs/source/history.rst index 356293eebb..88da4606ba 100644 --- a/docs/source/history.rst +++ b/docs/source/history.rst @@ -266,7 +266,7 @@ Deprecations and Removals ~~~~~~~~~~~~~~~~~~~~~~~~~ - Attempting to explicitly raise :exc:`trio.Cancelled` will cause a :exc:`RuntimeError`. - :meth:`cancel_scope.cancel() ` should + :meth:`cancel_scope.cancel() ` should be used instead. (`#342 `__) diff --git a/docs/source/reference-core.rst b/docs/source/reference-core.rst index 914d39d338..a1d3a83389 100644 --- a/docs/source/reference-core.rst +++ b/docs/source/reference-core.rst @@ -380,7 +380,7 @@ whether this scope caught a :exc:`Cancelled` exception:: The ``cancel_scope`` object also allows you to check or adjust this scope's deadline, explicitly trigger a cancellation without waiting for the deadline, check if the scope has already been cancelled, and -so forth – see :func:`open_cancel_scope` below for the full details. +so forth – see :class:`CancelScope` below for the full details. .. _blocking-cleanup-example: @@ -415,7 +415,7 @@ Of course, if you really want to make another blocking call in your cleanup handler, trio will let you; it's trying to prevent you from accidentally shooting yourself in the foot. Intentional foot-shooting is no problem (or at least – it's not trio's problem). To do this, -create a new scope, and set its :attr:`~The cancel scope interface.shield` +create a new scope, and set its :attr:`~CancelScope.shield` attribute to :data:`True`:: with trio.move_on_after(TIMEOUT): @@ -494,67 +494,17 @@ but *will* still close the underlying socket before raising Cancellation API details ~~~~~~~~~~~~~~~~~~~~~~~~ -The primitive operation for creating a new cancellation scope is: +:func:`move_on_after` and all the other cancellation facilities provided +by Trio are ultimately implemented in terms of :class:`CancelScope` +objects. -.. autofunction:: open_cancel_scope - :with: cancel_scope - -Cancel scope objects provide the following interface: - -.. interface:: The cancel scope interface - - .. attribute:: deadline - - Read-write, :class:`float`. An absolute time on the current - run's clock at which this scope will automatically become - cancelled. You can adjust the deadline by modifying this - attribute, e.g.:: - - # I need a little more time! - cancel_scope.deadline += 30 - - Note that for efficiency, the core run loop only checks for - expired deadlines every once in a while. This means that in - certain cases there may be a short delay between when the clock - says the deadline should have expired, and when checkpoints - start raising :exc:`~trio.Cancelled`. This is a very obscure - corner case that you're unlikely to notice, but we document it - for completeness. (If this *does* cause problems for you, of - course, then `we want to know! - `__) - - Defaults to :data:`math.inf`, which means "no deadline", though - this can be overridden by the ``deadline=`` argument to - :func:`~trio.open_cancel_scope`. +.. autoclass:: trio.CancelScope - .. attribute:: shield + .. autoattribute:: deadline - Read-write, :class:`bool`, default :data:`False`. So long as - this is set to :data:`True`, then the code inside this scope - will not receive :exc:`~trio.Cancelled` exceptions from scopes - that are outside this scope. They can still receive - :exc:`~trio.Cancelled` exceptions from (1) this scope, or (2) - scopes inside this scope. You can modify this attribute:: + .. autoattribute:: shield - with trio.open_cancel_scope() as cancel_scope: - cancel_scope.shield = True - # This cannot be interrupted by any means short of - # killing the process: - await sleep(10) - - cancel_scope.shield = False - # Now this can be cancelled normally: - await sleep(10) - - Defaults to :data:`False`, though this can be overridden by the - ``shield=`` argument to :func:`~trio.open_cancel_scope`. - - .. method:: cancel() - - Cancels this scope immediately. - - This method is idempotent, i.e. if the scope was already - cancelled then this method silently does nothing. + .. automethod:: cancel() .. attribute:: cancelled_caught @@ -564,24 +514,30 @@ Cancel scope objects provide the following interface: exception, and (2) this scope is the one that was responsible for triggering this :exc:`~trio.Cancelled` exception. + If the same :class:`CancelScope` is reused for multiple ``with`` + blocks, the :attr:`cancelled_caught` attribute applies to the + most recent ``with`` block. (It is reset to :data:`False` each + time a new ``with`` block is entered.) + .. attribute:: cancel_called Readonly :class:`bool`. Records whether cancellation has been requested for this scope, either by an explicit call to :meth:`cancel` or by the deadline expiring. - This attribute being True does *not* necessarily mean that - the code within the scope has been, or will be, affected by - the cancellation. For example, if :meth:`cancel` was called - just before the scope exits, when it's too late to deliver - a :exc:`~trio.Cancelled` exception, then this attribute will - still be True. + This attribute being True does *not* necessarily mean that the + code within the scope has been, or will be, affected by the + cancellation. For example, if :meth:`cancel` was called after + the last checkpoint in the ``with`` block, when it's too late to + deliver a :exc:`~trio.Cancelled` exception, then this attribute + will still be True. This attribute is mostly useful for debugging and introspection. If you want to know whether or not a chunk of code was actually cancelled, then :attr:`cancelled_caught` is usually more appropriate. + Trio also provides several convenience functions for the common situation of just wanting to impose a timeout on some code: diff --git a/newsfragments/607.feature.rst b/newsfragments/607.feature.rst new file mode 100644 index 0000000000..db78a2ea4c --- /dev/null +++ b/newsfragments/607.feature.rst @@ -0,0 +1,4 @@ +Add support for "unbound cancel scopes": you can now construct a +:class:`trio.CancelScope` without entering its context, e.g., so you +can pass it to another task which will use it to wrap some work that +you want to be able to cancel from afar. diff --git a/newsfragments/607.removal.rst b/newsfragments/607.removal.rst new file mode 100644 index 0000000000..1500b812ad --- /dev/null +++ b/newsfragments/607.removal.rst @@ -0,0 +1,3 @@ +Deprecate ``trio.open_cancel_scope`` in favor of :class:`trio.CancelScope`, +which more clearly reflects that creating a cancel scope is just an ordinary +object construction and does not need to be immediately paired with entering it. diff --git a/notes-to-self/graceful-shutdown-idea.py b/notes-to-self/graceful-shutdown-idea.py index 28164cb495..2477596f72 100644 --- a/notes-to-self/graceful-shutdown-idea.py +++ b/notes-to-self/graceful-shutdown-idea.py @@ -11,16 +11,12 @@ def start_shutdown(self): for cancel_scope in self._cancel_scopes: cancel_scope.cancel() - @contextmanager def cancel_on_graceful_shutdown(self): - with trio.open_cancel_scope() as cancel_scope: - self._cancel_scopes.add(cancel_scope) - if self._shutting_down: - cancel_scope.cancel() - try: - yield - finally: - self._cancel_scopes.remove(cancel_scope) + cancel_scope = trio.CancelScope() + self._cancel_scopes.add(cancel_scope) + if self._shutting_down: + cancel_scope.cancel() + return cancel_scope @property def shutting_down(self): @@ -41,8 +37,8 @@ async def stream_handler(stream): # To trigger the shutdown: async def listen_for_shutdown_signals(): - with trio.catch_signals({signal.SIGINT, signal.SIGTERM}) as signal_aiter: - async for batch in signal_aiter: + with trio.open_signal_receiver(signal.SIGINT, signal.SIGTERM) as signal_aiter: + async for sig in signal_aiter: gsm.start_shutdown() break # TODO: it'd be nice to have some logic like "if we get another @@ -50,7 +46,7 @@ async def listen_for_shutdown_signals(): # That's easy enough: # # with trio.move_on_after(30): - # async for batch in signal_aiter: + # async for sig in signal_aiter: # break # sys.exit() # diff --git a/trio/__init__.py b/trio/__init__.py index 9aac6c1469..a4eff1f37a 100644 --- a/trio/__init__.py +++ b/trio/__init__.py @@ -18,8 +18,8 @@ from ._core import ( TrioInternalError, RunFinishedError, WouldBlock, Cancelled, BusyResourceError, ClosedResourceError, MultiError, run, open_nursery, - open_cancel_scope, current_effective_deadline, TASK_STATUS_IGNORED, - current_time, BrokenResourceError, EndOfChannel + CancelScope, open_cancel_scope, current_effective_deadline, + TASK_STATUS_IGNORED, current_time, BrokenResourceError, EndOfChannel ) from ._timeouts import ( diff --git a/trio/_core/_exceptions.py b/trio/_core/_exceptions.py index fd20406ae0..937da2328e 100644 --- a/trio/_core/_exceptions.py +++ b/trio/_core/_exceptions.py @@ -49,7 +49,7 @@ class Cancelled(BaseException): Attempting to raise :exc:`Cancelled` yourself will cause a :exc:`RuntimeError`. It would not be associated with a cancel scope and thus not be caught by Trio. Use - :meth:`cancel_scope.cancel() ` + :meth:`cancel_scope.cancel() ` instead. .. note:: diff --git a/trio/_core/_run.py b/trio/_core/_run.py index 1bc4e70b0c..f96f6c9181 100644 --- a/trio/_core/_run.py +++ b/trio/_core/_run.py @@ -35,15 +35,16 @@ WaitTaskRescheduled, ) from .. import _core +from .._deprecate import deprecated # At the bottom of this file there's also some "clever" code that generates # wrapper functions for runner and io manager methods, and adds them to # __all__. These are all re-exported as part of the 'trio' or 'trio.hazmat' # namespaces. __all__ = [ - "Task", "run", "open_nursery", "open_cancel_scope", "checkpoint", - "current_task", "current_effective_deadline", "checkpoint_if_cancelled", - "TASK_STATUS_IGNORED" + "Task", "run", "open_nursery", "open_cancel_scope", "CancelScope", + "checkpoint", "current_task", "current_effective_deadline", + "checkpoint_if_cancelled", "TASK_STATUS_IGNORED" ] GLOBAL_RUN_CONTEXT = threading.local() @@ -116,28 +117,139 @@ def deadline_to_sleep_time(self, deadline): ################################################################ -@attr.s(cmp=False, hash=False, repr=False) +@attr.s(cmp=False, repr=False, slots=True) class CancelScope: - _tasks = attr.ib(default=attr.Factory(set)) - _scope_task = attr.ib(default=None) - _effective_deadline = attr.ib(default=inf) - _deadline = attr.ib(default=inf) - _shield = attr.ib(default=False) - cancel_called = attr.ib(default=False) - cancelled_caught = attr.ib(default=False) - - @staticmethod - def _create(deadline, shield): + """A *cancellation scope*: the link between a unit of cancellable + work and Trio's cancellation system. + + A :class:`CancelScope` becomes associated with some cancellable work + when it is used as a context manager surrounding that work:: + + cancel_scope = trio.CancelScope() + ... + with cancel_scope: + await long_running_operation() + + Inside the ``with`` block, a cancellation of ``cancel_scope`` (via + a call to its :meth:`cancel` method or via the expiry of its + :attr:`deadline`) will immediately interrupt the + ``long_running_operation()`` by raising :exc:`Cancelled` at its + next :ref:`checkpoint `. + + The context manager ``__enter__`` returns the :class:`CancelScope` + object itself, so you can also write ``with trio.CancelScope() as + cancel_scope:``. + + If a cancel scope becomes cancelled before entering its ``with`` block, + the :exc:`Cancelled` exception will be raised at the first + checkpoint inside the ``with`` block. This allows a + :class:`CancelScope` to be created in one :ref:`task ` and + passed to another, so that the first task can later cancel some work + inside the second. + + Cancel scopes are reusable: once you exit the ``with`` block, you + can use the same :class:`CancelScope` object to wrap another chunk + of work. (The cancellation state doesn't change; once a cancel + scope becomes cancelled, it stays cancelled.) This can be useful + if you want a cancellation to be able to interrupt some operations + in a loop but not others:: + + cancel_scope = trio.CancelScope(deadline=...) + while True: + with cancel_scope: + request = await get_next_request() + response = await handle_request(request) + await send_response(response) + + Cancel scopes are *not* reentrant: you can't enter a second + ``with`` block using the same :class:`CancelScope` while the first + one is still active. (You'll get a :exc:`RuntimeError` if you try.) + + The :class:`CancelScope` constructor takes initial values for the + cancel scope's :attr:`deadline` and :attr:`shield` attributes; these + may be freely modified after construction, whether or not the scope + has been entered yet, and changes take immediate effect. + """ + + _tasks = attr.ib(factory=set, init=False) + _scope_task = attr.ib(default=None, init=False) + _effective_deadline = attr.ib(default=inf, init=False) + cancel_called = attr.ib(default=False, init=False) + cancelled_caught = attr.ib(default=False, init=False) + + # Constructor arguments: + _deadline = attr.ib(default=inf, kw_only=True) + _shield = attr.ib(default=False, kw_only=True) + + @enable_ki_protection + def __enter__(self): task = _core.current_task() - scope = CancelScope() - scope._scope_task = task - scope._add_task(task) - scope.deadline = deadline - scope.shield = shield - return scope + if self._scope_task is not None: + raise RuntimeError( + "cancel scope may not be entered while it is already " + "active{}".format( + "" if self._scope_task is task else + " in another task ({!r})".format(self._scope_task.name) + ) + ) + self._scope_task = task + self.cancelled_caught = False + with self._might_change_effective_deadline(): + self._add_task(task) + return self + + @enable_ki_protection + def __exit__(self, etype, exc, tb): + # NB: NurseryManager calls _close() directly rather than __exit__(), + # so __exit__() must be just _close() plus this logic for adapting + # the exception-filtering result to the context manager API. + + # Tracebacks show the 'raise' line below out of context, so let's give + # this variable a name that makes sense out of context. + remaining_error_after_cancel_scope = self._close(exc) + if remaining_error_after_cancel_scope is None: + return True + elif remaining_error_after_cancel_scope is exc: + return False + else: + # Copied verbatim from MultiErrorCatcher. Python doesn't + # allow us to encapsulate this __context__ fixup. + old_context = remaining_error_after_cancel_scope.__context__ + try: + raise remaining_error_after_cancel_scope + finally: + _, value, _ = sys.exc_info() + assert value is remaining_error_after_cancel_scope + value.__context__ = old_context def __repr__(self): - return "".format(id(self)) + if self._scope_task is None: + binding = "unbound" + else: + binding = "bound to {!r}".format(self._scope_task.name) + if len(self._tasks) > 1: + binding += " and its {} descendant{}".format( + len(self._tasks) - 1, "s" if len(self._tasks) > 2 else "" + ) + + if self.cancel_called: + state = ", cancelled" + elif self.deadline == inf: + state = "" + else: + try: + now = current_time() + except RuntimeError: # must be called from async context + state = "" + else: + state = ", deadline is {:.2f} seconds {}".format( + abs(self.deadline - now), + "from now" if self.deadline >= now else "ago" + ) + + return "".format( + id(self), binding, state + ) @contextmanager @enable_ki_protection @@ -160,6 +272,28 @@ def _might_change_effective_deadline(self): @property def deadline(self): + """Read-write, :class:`float`. An absolute time on the current + run's clock at which this scope will automatically become + cancelled. You can adjust the deadline by modifying this + attribute, e.g.:: + + # I need a little more time! + cancel_scope.deadline += 30 + + Note that for efficiency, the core run loop only checks for + expired deadlines every once in a while. This means that in + certain cases there may be a short delay between when the clock + says the deadline should have expired, and when checkpoints + start raising :exc:`~trio.Cancelled`. This is a very obscure + corner case that you're unlikely to notice, but we document it + for completeness. (If this *does* cause problems for you, of + course, then `we want to know! + `__) + + Defaults to :data:`math.inf`, which means "no deadline", though + this can be overridden by the ``deadline=`` argument to + the :class:`~trio.CancelScope` constructor. + """ return self._deadline @deadline.setter @@ -169,9 +303,30 @@ def deadline(self, new_deadline): @property def shield(self): + """Read-write, :class:`bool`, default :data:`False`. So long as + this is set to :data:`True`, then the code inside this scope + will not receive :exc:`~trio.Cancelled` exceptions from scopes + that are outside this scope. They can still receive + :exc:`~trio.Cancelled` exceptions from (1) this scope, or (2) + scopes inside this scope. You can modify this attribute:: + + with trio.CancelScope() as cancel_scope: + cancel_scope.shield = True + # This cannot be interrupted by any means short of + # killing the process: + await sleep(10) + + cancel_scope.shield = False + # Now this can be cancelled normally: + await sleep(10) + + Defaults to :data:`False`, though this can be overridden by the + ``shield=`` argument to the :class:`~trio.CancelScope` constructor. + """ return self._shield @shield.setter + @enable_ki_protection def shield(self, new_value): if not isinstance(new_value, bool): raise TypeError("shield must be a bool") @@ -191,6 +346,11 @@ def _cancel_no_notify(self): @enable_ki_protection def cancel(self): + """Cancels this scope immediately. + + This method is idempotent, i.e., if the scope was already + cancelled then this method silently does nothing. + """ for task in self._cancel_no_notify(): task._attempt_delivery_of_any_pending_cancel() @@ -199,8 +359,7 @@ def _add_task(self, task): task._cancel_stack.append(self) def _remove_task(self, task): - with self._might_change_effective_deadline(): - self._tasks.remove(task) + self._tasks.remove(task) assert task._cancel_stack[-1] is self task._cancel_stack.pop() @@ -225,52 +384,18 @@ def _exc_filter(self, exc): return exc def _close(self, exc): - self._remove_task(self._scope_task) + with self._might_change_effective_deadline(): + self._remove_task(self._scope_task) + self._scope_task = None if exc is not None: - filtered_exc = MultiError.filter(self._exc_filter, exc) - return filtered_exc - - -# We explicitly avoid @contextmanager since it adds extraneous stack frames -# to exceptions. -@attr.s -class CancelScopeManager: - - _deadline = attr.ib(default=inf) - _shield = attr.ib(default=False) - - @enable_ki_protection - def __enter__(self): - self._scope = CancelScope._create(self._deadline, self._shield) - return self._scope - - @enable_ki_protection - def __exit__(self, etype, exc, tb): - # Tracebacks show the 'raise' line below out of context, so let's give - # this variable a name that makes sense out of context. - remaining_error_after_cancel_scope = self._scope._close(exc) - if remaining_error_after_cancel_scope is None: - return True - elif remaining_error_after_cancel_scope is exc: - return False - else: - # Copied verbatim from MultiErrorCatcher. Python doesn't - # allow us to encapsulate this __context__ fixup. - old_context = remaining_error_after_cancel_scope.__context__ - try: - raise remaining_error_after_cancel_scope - finally: - _, value, _ = sys.exc_info() - assert value is remaining_error_after_cancel_scope - value.__context__ = old_context + return MultiError.filter(self._exc_filter, exc) + return None +@deprecated("0.10.0", issue=607, instead="trio.CancelScope") def open_cancel_scope(*, deadline=inf, shield=False): - """Returns a context manager which creates a new cancellation scope. - - """ - - return CancelScopeManager(deadline, shield) + """Returns a context manager which creates a new cancellation scope.""" + return CancelScope(deadline=deadline, shield=shield) ################################################################ @@ -376,7 +501,8 @@ class NurseryManager: @enable_ki_protection async def __aenter__(self): - self._scope = CancelScope._create(deadline=inf, shield=False) + self._scope = CancelScope() + self._scope.__enter__() self._nursery = Nursery(current_task(), self._scope) return self._nursery @@ -1586,7 +1712,7 @@ async def checkpoint(): :func:`checkpoint`.) """ - with open_cancel_scope(deadline=-inf): + with CancelScope(deadline=-inf): await _core.wait_task_rescheduled(lambda _: _core.Abort.SUCCEEDED) diff --git a/trio/_core/_traps.py b/trio/_core/_traps.py index c51cd252ea..f7cf934489 100644 --- a/trio/_core/_traps.py +++ b/trio/_core/_traps.py @@ -37,7 +37,7 @@ async def cancel_shielded_checkpoint(): Equivalent to (but potentially more efficient than):: - with trio.open_cancel_scope(shield=True): + with trio.CancelScope(shield=True): await trio.hazmat.checkpoint() """ diff --git a/trio/_core/tests/test_ki.py b/trio/_core/tests/test_ki.py index 6c95325735..e0375e669c 100644 --- a/trio/_core/tests/test_ki.py +++ b/trio/_core/tests/test_ki.py @@ -410,7 +410,7 @@ async def main(): @_core.enable_ki_protection async def main(): assert _core.currently_ki_protected() - with _core.open_cancel_scope() as cancel_scope: + with _core.CancelScope() as cancel_scope: cancel_scope.cancel() with pytest.raises(_core.Cancelled): await _core.checkpoint() diff --git a/trio/_core/tests/test_parking_lot.py b/trio/_core/tests/test_parking_lot.py index de6de63081..95e4a96b50 100644 --- a/trio/_core/tests/test_parking_lot.py +++ b/trio/_core/tests/test_parking_lot.py @@ -82,7 +82,7 @@ async def waiter(i, lot): async def cancellable_waiter(name, lot, scopes, record): - with _core.open_cancel_scope() as scope: + with _core.CancelScope() as scope: scopes[name] = scope record.append("sleep {}".format(name)) try: diff --git a/trio/_core/tests/test_run.py b/trio/_core/tests/test_run.py index c959fff558..c1d2aa0b8f 100644 --- a/trio/_core/tests/test_run.py +++ b/trio/_core/tests/test_run.py @@ -17,7 +17,8 @@ from .tutil import check_sequence_matches, gc_collect_harder from ... import _core -from ..._timeouts import sleep +from ..._threads import run_sync_in_worker_thread +from ..._timeouts import sleep, fail_after from ..._util import aiter_compat from ...testing import ( wait_all_tasks_blocked, @@ -336,7 +337,7 @@ async def child(): await _core.checkpoint() await _core.checkpoint() - with _core.open_cancel_scope(deadline=_core.current_time() + 5): + with _core.CancelScope(deadline=_core.current_time() + 5): stats = _core.current_statistics() print(stats) assert stats.seconds_to_next_deadline == 5 @@ -540,15 +541,28 @@ async def main(): assert "Instrument has been disabled" in caplog.records[0].message -async def test_cancel_scope_repr(): - # Trivial smoke test - with _core.open_cancel_scope() as scope: - assert repr(scope).startswith("