python-trio / python-trio/trio

Cancellation may not always immediately happen at checkpoints

Open
#2,973 11 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

cancellation design discussion docs
Dominant language
Python
Stars
7.3k
Forks
431
Avg merge
2d 17h
Merged PRs (30d)
6

Description

Updated issue text (after thinking about it a bit)

Summary

Any awaitable function in Trio (i.e., await trio.foo(...)) is guaranteed to be a checkpoint. That means, aside from being a scheduling point (which is not relevant to this issue), that it is a cancellation point: i.e., if the function is called from within a cancellation scope that is cancelled (subject to shielding rules) then it will raise trio.Cancelled. Of course, a function that is waiting (in a sleep, for I/O data, etc.) can also be interrupted by a cancellation (with a few well-documented exceptions).

But what if the cancellation scope becomes cancelled just as the function is finishing? Then, surprisingly (to me), it might not raise trio.Cancelled. Here's a simple example:

import trio

async def wait_for_event(ev: trio.Event):
    await ev.wait()
    print("If this prints then wait() did not raise")

async def main():
    async with trio.open_nursery() as n:
        ev = trio.Event()
        n.start_soon(wait_for_event, ev)
        await trio.sleep(0.1)
        ev.set()
        n.cancel_scope.cancel()

trio.run(main)

This will print the message. That's because await ev.wait() simply returns, despite being in a cancelled scope at that point.

What's more, if you swap the last two lines of main() (ev.set() and n.cancel_scope.cancel()) then the message will not be printed, because await ev.wait() raises a trio.Cancelled exception instead. I would expect that the behaviour depends on the overall state at that moment (the event is in a set state and in a cancelled context, so one or the other deterministically wins). Instead, it depends on the order things happened, even between checkpoints. I can see why that is likely to have happened internally, but it still seems against the spirit of Trio.

Why I care

Here's an almost-concrete example of why this is a problem, using my little aioresult library:

async def use_result(rc: aioresult.ResultCapture):
    # ...
    await rc.wait_done()
    val = rc.result()
    # ... use val ...

async def main():
    async with trio.open_nursery() as n:
        rc = aioresult.ResultCapture(n, produce_result)
        n.start_soon(use_result, rc)
        # ... some time later ...
        if some_condition:
            n.cancel_scope.cancel()

Here's the risk: produce_result() and use_result() are both cancelled but, await rc.wait_done() returns without exception (because when produce_result() finished with trio.Cancelled that set the event inside aioresult.ResultCapture). That means that rc.result() raises an aioresult.TaskFailedException (because the routine it wraps raised an exception). Which means that the simple cancellation get converted into a full-fat exception propagated out of the nursery!

Now, I don't think this can happen because the event gets cancelled just before it's set in this case. But it seems like it's got a fragile dependency on undocumented behaviour here.

The request

I think there should be changes relating to this:

  • Trio's docs about checkpoints (the two main places listed in "documentation" in the original issue text) make it really clear that cancellation is not guaranteed to be checked as the call is finishing. In other words, if surrounding cancel scope is cancelled while await trio.foo() is in progress then it is possible for the routine to return without raising trio.Cancelled.
  • BUT, for some particular functions where it's possible, the functions are changed to add that guarantee and that is documented. That would include, IMO, at least Event.wait() and await trio.MemoryReceiveChannel.receive(), and probably also trio.lowlevel.ParkingLot. I'm not so interested in the lower-level synchronization primitives (CapacityLimiter, Lock, Semaphore and Condition) but I suppose it applies to those too.

I've not put my money where my mouth is by submitting a pull request!

Implementation

One problem with this idea is that I'm not sure how it could be efficiently implemented. Ideally, late in the body of these functions (after waiting for their condition to be true) we would re-check for cancellation but without doing a schedule point because we have just been scheduled. That means that we want a sync-coloured cancellation check, but that was suggested 5 years ago in #961 and is not done yet. - I now think this would be best implemented by optionally allowing trio.lowlevel.wait_task_reschedule() to still call the abort function, and therefore end up raising trio.Cancelled, even if trio.lowlevel.reschedule() has been called. I plan to add a comment with further details but that's the key idea. In the case of trio.Event, aside from passing a parameter to enable that behaviour, no other code changes whatsoever would be needed to change ("fix") its behaviour. For other synchronisation primitives (e.g., memory channels), it's a different matter. (To be continued...)

Original issue text

Show original issue text
Short summary

My reading of the documentation is that a await ... should always end in a Cancelled being raised if it's in an enclosing cancel scope that is cancelled, but that is not actually always the case in practice. This is perhaps a design decision – should that rule always be enforced, or is it allowed for a cancellation request to take a bit of scheduler delay to implement. If the decision has already been made (or we make that decision now) then this may just end up being a documentation issue (or maybe I'm just reading too much into it), but even if so then it could do with clarifying.

Actual behaviour

In reality, it is possible for a task to wake from an await without exception even if it is in a scope that has been explicitly cancelled.

This gist gives a complete example.

It involves running two tasks in a nursery:

  • One sends a few values to a memory channel (with send_nowait()) then immediately cancels the cancel scope for the nursery.
  • The other awaits receive() on the channel in a loop.

When I run it, the receive task gets one value from the memory channel before getting the cancellation exception.

Documentation

The documentation says that, at each checkpoint, Trio checks whether the task is cancelled:

Neither is really explicit about when within the checkpoint the cancellation state is checked. However, my subjective reading of them (especially the first one) is that is that they strongly imply that it is impossible for await trio.foo() to finish with anything other than a trio.Cancelled if it is in a cancelled scope (barring usual shielding rules) when it completes.

Most trio operations are conveniently in two parts: one that waits for the operation to become possible (e.g., socket read available, memory channel non-empty) and then one that synchronously performs that operation. So it certainly seems possible for the cancellation check to happen at the end of the wait step, in addition to before and (effectively) during.

The scheduling point would also happen alongside the wait, even if the condition is already true (and remains true). So, even if there is no actual wait, then it's possible that a cancellation would come into effect during the course of the await call, and the position of the cancellation check(s) still matters.

Side note about asyncio

Just for curiosity, I tried analogous code with asyncio, using an asyncio.TaskGroup in place of a nursery and simulating a cancel by raising an exception from one of the tasks. It appears that asyncio.Queue.get() is not a scheduling point, because a receive loop will happily pop 100s of values off of it in a loop after the task that send them has long finished with an exception (but a small perturbation to the code will stop cause it to be cancelled before getting any).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the checkpoint and cancel-point sections in reference-core and design.md, then inspect lowlevel.wait_task_rescheduled and the documented Event.wait and MemoryReceiveChannel.receive entry points. The work is complete when the cancellation guarantees are decided, the affected behavior is implemented where appropriate, and the documentation clearly describes the result.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
backend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.