-
-
Notifications
You must be signed in to change notification settings - Fork 371
Defer KI if trio is doing IO #3233
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 8 commits
f843ee9
34aab7f
b6abaf5
5e581bb
6421d8d
b12f2e2
ff3da6b
261e2b2
b76faca
f83ebbb
05bbc50
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| :ref:`Sometimes <ki-handling>`, a Trio program receives an interrupt | ||
| signal (Ctrl+C) at a time when Python's default response (raising | ||
| `KeyboardInterrupt` immediately) might corrupt Trio's internal | ||
| state. Previously, Trio would handle this situation by raising the | ||
| `KeyboardInterrupt` at the next :ref:`checkpoint <checkpoints>` executed | ||
| by the main task (the one running the function you passed to :func:`trio.run`). | ||
| This was responsible for a lot of internal complexity and sometimes led to | ||
| surprising behavior. | ||
|
|
||
| With this release, such a "deferred" `KeyboardInterrupt` is handled in a | ||
| different way: Trio will first cancel all running tasks, then raise | ||
| `KeyboardInterrupt` directly out of the call to :func:`trio.run`. | ||
| The difference is relevant if you have code that tries to catch | ||
| `KeyboardInterrupt` within Trio. This was never entirely robust, but it | ||
| previously might have worked in many cases, whereas now it will never | ||
| catch the interrupt. | ||
|
|
||
| An example of code that mostly worked on previous releases, but won't | ||
| work on this release:: | ||
|
|
||
| async def main(): | ||
| try: | ||
| await trio.sleep_forever() | ||
| except KeyboardInterrupt: | ||
| print("interrupted") | ||
| trio.run(main) | ||
|
|
||
| The fix is to catch `KeyboardInterrupt` outside Trio:: | ||
|
|
||
| async def main(): | ||
| await trio.sleep_forever() | ||
| try: | ||
| trio.run(main) | ||
| except KeyboardInterrupt: | ||
| print("interrupted") | ||
|
|
||
| If that doesn't work for you (because you want to respond to | ||
| `KeyboardInterrupt` by doing something other than cancelling all | ||
| tasks), then you can start a task that uses | ||
| `trio.open_signal_receiver` to receive the interrupt signal ``SIGINT`` | ||
| directly and handle it however you wish. Such a task takes precedence | ||
| over Trio's default interrupt handling. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -313,8 +313,8 @@ async def check_unprotected_kill() -> None: | |
| _core.run(check_unprotected_kill) | ||
| assert record_set == {"s1 ok", "s2 ok", "r1 raise ok"} | ||
|
|
||
| # simulated control-C during raiser, which is *protected*, so the KI gets | ||
| # delivered to the main task instead | ||
| # simulated control-C during raiser, which is *protected*, so the run | ||
| # gets cancelled instead. | ||
| print("check 2") | ||
| record_set = set() | ||
|
|
||
|
|
@@ -325,9 +325,12 @@ async def check_protected_kill() -> None: | |
| nursery.start_soon(_core.enable_ki_protection(raiser), "r1", record_set) | ||
| # __aexit__ blocks, and then receives the KI | ||
|
|
||
| # raises inside a nursery, so the KeyboardInterrupt is wrapped in an ExceptionGroup | ||
| with RaisesGroup(KeyboardInterrupt): | ||
| # KeyboardInterrupt is inserted from the trio.run | ||
| with pytest.raises(KeyboardInterrupt) as excinfo: | ||
| _core.run(check_protected_kill) | ||
|
|
||
| # TODO: be consistent about providing Cancelled tree as __context__ | ||
|
||
| assert excinfo.value.__context__ is None | ||
| assert record_set == {"s1 ok", "s2 ok", "r1 cancel ok"} | ||
|
|
||
| # kill at last moment still raises (run_sync_soon until it raises an | ||
|
|
@@ -373,10 +376,11 @@ async def main_1() -> None: | |
| async def main_2() -> None: | ||
| assert _core.currently_ki_protected() | ||
| ki_self() | ||
| with pytest.raises(KeyboardInterrupt): | ||
| with pytest.raises(_core.Cancelled): | ||
| await _core.checkpoint_if_cancelled() | ||
|
|
||
| _core.run(main_2) | ||
| with pytest.raises(KeyboardInterrupt): | ||
| _core.run(main_2) | ||
|
|
||
| # KI arrives while main task is not abortable, b/c already scheduled | ||
| print("check 6") | ||
|
|
@@ -388,10 +392,11 @@ async def main_3() -> None: | |
| await _core.cancel_shielded_checkpoint() | ||
| await _core.cancel_shielded_checkpoint() | ||
| await _core.cancel_shielded_checkpoint() | ||
| with pytest.raises(KeyboardInterrupt): | ||
| with pytest.raises(_core.Cancelled): | ||
| await _core.checkpoint() | ||
|
|
||
| _core.run(main_3) | ||
| with pytest.raises(KeyboardInterrupt): | ||
| _core.run(main_3) | ||
|
|
||
| # KI arrives while main task is not abortable, b/c refuses to be aborted | ||
| print("check 7") | ||
|
|
@@ -407,10 +412,11 @@ def abort(_: RaiseCancelT) -> Abort: | |
| return _core.Abort.FAILED | ||
|
|
||
| assert await _core.wait_task_rescheduled(abort) == 1 | ||
| with pytest.raises(KeyboardInterrupt): | ||
| with pytest.raises(_core.Cancelled): | ||
| await _core.checkpoint() | ||
|
|
||
| _core.run(main_4) | ||
| with pytest.raises(KeyboardInterrupt): | ||
| _core.run(main_4) | ||
|
|
||
| # KI delivered via slow abort | ||
| print("check 8") | ||
|
|
@@ -426,11 +432,12 @@ def abort(raise_cancel: RaiseCancelT) -> Abort: | |
| _core.reschedule(task, result) | ||
| return _core.Abort.FAILED | ||
|
|
||
| with pytest.raises(KeyboardInterrupt): | ||
| with pytest.raises(_core.Cancelled): | ||
| assert await _core.wait_task_rescheduled(abort) | ||
| await _core.checkpoint() | ||
|
|
||
| _core.run(main_5) | ||
| with pytest.raises(KeyboardInterrupt): | ||
| _core.run(main_5) | ||
|
|
||
| # KI arrives just before main task exits, so the run_sync_soon machinery | ||
| # is still functioning and will accept the callback to deliver the KI, but | ||
|
|
@@ -457,10 +464,11 @@ async def main_7() -> None: | |
| # ...but even after the KI, we keep running uninterrupted... | ||
| record_list.append("ok") | ||
| # ...until we hit a checkpoint: | ||
| with pytest.raises(KeyboardInterrupt): | ||
| with pytest.raises(_core.Cancelled): | ||
| await sleep(10) | ||
|
|
||
| _core.run(main_7, restrict_keyboard_interrupt_to_checkpoints=True) | ||
| with pytest.raises(KeyboardInterrupt): | ||
| _core.run(main_7, restrict_keyboard_interrupt_to_checkpoints=True) | ||
| assert record_list == ["ok"] | ||
| record_list = [] | ||
| # Exact same code raises KI early if we leave off the argument, doesn't | ||
|
|
@@ -469,25 +477,6 @@ async def main_7() -> None: | |
| _core.run(main_7) | ||
| assert record_list == [] | ||
|
|
||
| # KI arrives while main task is inside a cancelled cancellation scope | ||
| # the KeyboardInterrupt should take priority | ||
| print("check 11") | ||
|
|
||
| @_core.enable_ki_protection | ||
| async def main_8() -> None: | ||
| assert _core.currently_ki_protected() | ||
| with _core.CancelScope() as cancel_scope: | ||
| cancel_scope.cancel() | ||
| with pytest.raises(_core.Cancelled): | ||
| await _core.checkpoint() | ||
| ki_self() | ||
| with pytest.raises(KeyboardInterrupt): | ||
| await _core.checkpoint() | ||
| with pytest.raises(_core.Cancelled): | ||
| await _core.checkpoint() | ||
|
|
||
| _core.run(main_8) | ||
|
|
||
|
|
||
| def test_ki_is_good_neighbor() -> None: | ||
| # in the unlikely event someone overwrites our signal handler, we leave | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.