Skip to content

Commit

Permalink
Always inject a deferred KI at the top level of trio.run(), per discu…
Browse files Browse the repository at this point in the history
…ssion in python-trio#151
  • Loading branch information
oremanj committed May 22, 2020
1 parent 6b76c5c commit aee9661
Show file tree
Hide file tree
Showing 3 changed files with 18 additions and 106 deletions.
36 changes: 15 additions & 21 deletions docs/source/reference-lowlevel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -281,27 +281,21 @@ correctness invariants. On the other, if the user accidentally writes
an infinite loop, we do want to be able to break out of that. Our
solution is to install a default signal handler which checks whether
it's safe to raise :exc:`KeyboardInterrupt` at the place where the
signal is received. If so, then we do; otherwise, we schedule a
:exc:`KeyboardInterrupt` to be delivered sometime soon.

.. note:: Delivery "sometime soon" is accomplished by picking an open
nursery and spawning a new task there that raises
`KeyboardInterrupt`. Like any other unhandled exception, this will
cancel sibling tasks as it propagates, and ultimately escape from
the call to :func:`trio.run` unless caught sooner.

It's not a good idea to try to catch `KeyboardInterrupt` while
you're still inside Trio, because it might be raised anywhere,
including outside your ``try``/``except`` block. If you want Ctrl+C
to do something that's not "tear down all running tasks", then you
should use :func:`open_signal_receiver` to install a handler for
``SIGINT``. If you do that, then Ctrl+C will go to your handler rather
than using the default handling described in this section.

The details of which nursery gets the `KeyboardInterrupt` injected
are subject to change. Currently it's the innermost nursery
that's active in the main task (the one running the original function
you passed to :func:`trio.run`).
signal is received. If so, then we do. Otherwise, we cancel all tasks
and raise `KeyboardInterrupt` directly as the result of :func:`trio.run`.

.. note:: This behavior means it's not a good idea to try to catch
`KeyboardInterrupt` within a Trio task. Most Trio
programs are I/O-bound, so most interrupts will be received while
no task is running (because Trio is waiting for I/O). There's no
task that should obviously receive the interrupt in such cases, so
Trio doesn't raise it within a task at all: every task gets cancelled,
then `KeyboardInterrupt` is raised once that's complete.

If you want to handle Ctrl+C by doing something other than "cancel
all tasks", then you should use :func:`open_signal_receiver` to
install a handler for ``SIGINT``. If you do that, then Ctrl+C will
go to your handler, and it can do whatever it wants.

So that's great, but – how do we know whether we're in one of the
sensitive parts of the program or not?
Expand Down
42 changes: 3 additions & 39 deletions trio/_core/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1412,48 +1412,12 @@ def current_trio_token(self):
def deliver_ki(self):
self.ki_pending = True
try:
self.entry_queue.run_sync_soon(self._deliver_ki_cb)
self.entry_queue.run_sync_soon(
self.system_nursery.cancel_scope.cancel
)
except RunFinishedError:
pass

# The name of this function shows up in tracebacks, so make it a good one
async def _raise_deferred_keyboard_interrupt(self):
raise KeyboardInterrupt

def _deliver_ki_cb(self):
if not self.ki_pending:
return

# Can't happen because main_task and run_sync_soon_task are created at
# the same time -- so even if KI arrives before main_task is created,
# we won't get here until afterwards.
assert self.main_task is not None
if self.main_task_outcome is not None:
# We're already in the process of exiting -- leave ki_pending set
# and we'll check it again on our way out of run().
return

# Raise KI from a new task in the innermost nursery that was opened
# by the main task. Rationale:
# - Using a new task means we don't have to contend with
# injecting KI at a checkpoint in an existing task.
# - Either the main task has at least one nursery open, or there are
# no non-system tasks except the main task.
# - The main task is likely to be waiting in __aexit__ of its innermost
# nursery. On Trio <=0.15.0, a deferred KI would be raised at the
# main task's next checkpoint. So, spawning our raise-KI task in the
# main task's innermost nursery is the most backwards-compatible
# thing we can do.
for nursery in reversed(self.main_task.child_nurseries):
if not nursery._closed:
self.ki_pending = False
nursery.start_soon(self._raise_deferred_keyboard_interrupt)
return

# If we get here, the main task has no non-closed child nurseries.
# Cancel the whole run; we'll raise KI on our way out of run().
self.system_nursery.cancel_scope.cancel()

################
# Quiescing
################
Expand Down
46 changes: 0 additions & 46 deletions trio/_core/tests/test_ki.py
Original file line number Diff line number Diff line change
Expand Up @@ -438,52 +438,6 @@ async def main():
_core.run(main)
assert record == []

print("check 11")
# KI delivered into innermost main task nursery if there are any
@_core.enable_ki_protection
async def main():
async with _core.open_nursery():
with pytest.raises(KeyboardInterrupt):
async with _core.open_nursery():
ki_self()
ki_self()
record.append("ok")
# First tick ensures KI callback ran
# Second tick ensures KI delivery task ran
await _core.cancel_shielded_checkpoint()
await _core.cancel_shielded_checkpoint()
with pytest.raises(_core.Cancelled):
await _core.checkpoint()
record.append("ok 2")
record.append("ok 3")
record.append("ok 4")

_core.run(main)
assert record == ["ok", "ok 2", "ok 3", "ok 4"]

# Closed nurseries are ignored when picking one to deliver KI
print("check 12")
record = []

@_core.enable_ki_protection
async def main():
with pytest.raises(KeyboardInterrupt):
async with _core.open_nursery():
async with _core.open_nursery() as inner:
assert inner._closed is False
inner._closed = True
ki_self()
# First tick ensures KI callback ran
# Second tick ensures KI delivery task ran
await _core.cancel_shielded_checkpoint()
await _core.cancel_shielded_checkpoint()
record.append("ok")
record.append("nope") # pragma: no cover
record.append("ok 2")

_core.run(main)
assert record == ["ok", "ok 2"]


def test_ki_is_good_neighbor():
# in the unlikely event someone overwrites our signal handler, we leave
Expand Down

0 comments on commit aee9661

Please sign in to comment.