python-trio / python-trio/trio
Cancellation may not always immediately happen at checkpoints
Nobody has claimed this yet.
- 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 raisingtrio.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()andawait trio.MemoryReceiveChannel.receive(), and probably alsotrio.lowlevel.ParkingLot. I'm not so interested in the lower-level synchronization primitives (CapacityLimiter,Lock,SemaphoreandCondition) 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:
- Trio's core functionality → General principles → Checkpoints says: "[A checkpoint is] a point where Trio checks for cancellation. For example, if the code that called your function set a timeout, and that timeout has expired, then the next time your function executes a checkpoint Trio will raise a Cancelled exception. See Cancellation and timeouts below for more details."
- Design and internals → User-level API principles → Cancel points and schedule points says: "a cancel point is a point where your code checks if it has been cancelled – e.g., due to a timeout having expired – and potentially raises a Cancelled error."
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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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