Skip to content

Cancel scopes with disjoint extents #886

@njsmith

Description

@njsmith

So! Linked cancel scopes! This is an idea that was first raised in #607 (comment), and also discussed in #835. The latter also has some notes on implementation subtleties that I won't copy here, but are worth reviewing if/when this turns into a PR again.

Here's the problem: If you want to have a single CancelScope that applies to multiple disjoint tasks, then how do you do that? Even with unbound cancel scopes (#835), you can't, because a CancelScope tracks information about both what's been requested, and what actually happened to a specific block of code (.cancelled_caught, or maybe .cancel_succeeded, see #885). If the same scope object could be used for multiple pieces of code simultaneously, then this wouldn't work.

One idea is to let a cancel scope fork off "linked children": new CancelScope objects that are their own independent entity, but that whenever the parent is cancelled this automatically cancels the children as well.

(Do we have any example use cases? I feel like we did, but I don't remember them off the top of my head.)

There's a lot about this that I like, but there are also a lot of potential complexities to think through:

  • Introspection: should there be a way to get the parent scope? The child scopes? The descendent scopes? The initial version of Add support for unbound cancel scopes #835 had a .linked_children attribute that returned all descendents.

  • current_effective_deadline: I guess this would need to walk up the parent chain for each cancel scope to collect up all their deadlines? (Which means we need to store parent links internally.)

  • How do shield attributes and linked scopes interact? In the initial version of Add support for unbound cancel scopes #835, changes to parent.shield propagated to child.shield, but this felt very weird to me. Conceptually, the shield component of a cancel scope is pretty much disjoint from the cancellation component. Maybe this is more evidence for the idea raised in idea: unbound cancel scopes #607, that shielding should be split off into a separate object from cancel scopes?

  • If we add a "soft" cancellation concept (More ergonomic support for graceful shutdown #147), then how will that interact with this?

  • How tightly coupled should the parent and the children be? The two possibilities that come to mind are: (a) the idea described above, where the children are totally independent, full-fledged CancelScope objects, except that parent.cancel() triggers a call to child.cancel(). (b) making them "linked clones", where the parent's deadline is the child's deadline, mutating one deadline mutates them all, cancelling any of them cancels them all, etc. The only thing that would be independent is .cancelled_caught, and maybe in the future some kind of data about what exceptions were caught.

    I guess it won't make a big difference if the way you actually use them in practice is always one of these two stereotyped patterns:

    with cancel_scope_from_somewhere_else.linked_child():
        ...
    
    with cancel_scope_from_somewhere_else.linked_child() as local_cancel_scope:
        ...
    if local_cancel_scope.cancelled_caught:
        ...

.....ugh now that I write that I'm wondering if we should revisit the discussion in #607 about whether cancelled_caught should really be part of the cancel scope itself, versus something separate that you get from __enter__. The main argument against splitting them is that it would make a mess of helpers like move_on_after and fail_after, where if you wanted to expose both the scope object (to allow e.g. adjusting deadlines) and the cancelled_caught object (very important for move_on_after), then having them on two separate objects is annoying, and also compatibility breaking unless we introduce yet a third object, and then Occam starts jumping up and down and waving his arms.

BUT. Here's something we should at least consider:

CancelScope is now becoming a full-fledged class that regular users will interact with. And we could trivially give its constructor a timeout= argument. Supposedly move_on_at and move_on_after are convenience shorthands, but at that point they're not adding a lot of convenience. And fail_at/fail_after are of dubious value to start with, + if you're using them you certainly don't care about the .cancelled_caught attribute.

So here is a possibility we should at least consider:

# Replacement for move_on_after
with CancelScope(timeout=timeout).enter() as was_cancelled:
    ...
if was_cancelled:  # an object with a custom __bool__ method
    ...

# Replacement for move_on_at
with CancelScope(deadline=deadline).enter() as was_cancelled:
    ...

But the key idea here is that the CancelScope object doesn't expose state related to this particular with block, so you could also write cancel_scope = CancelScope(...) and then enter it as many times as you wanted, where-ever you wanted.

fail_after/fail_at could stay like they are now, or maybe become .enter(raise_on_cancel=True)?

I guess was_cancelled.__bool__ would raise an exception if you called it from inside the associated with block?

(While we're at it: consider globally renaming deadlineat, timeoutafter everywhere.)

And hmm, I was thinking that we needed the .enter() there so that we could match up the calls to __enter__ and __exit__, so we knew which was_cancelled object to update. But actually it would be trivial to stash this information on the task, e.g. in the cancel stack. So we could drop the .enter().

If we do decide to provide an option to capture the Cancelled exceptions for later introspection, then that would need something like with cancel_scope.saving_exceptions() as was_cancelled: ..., or with cancel_scope.using(save_exceptions=True) as was_cancelled: ..., but that seems fine.

I don't know if this is a good idea. It's certainly a bigger change than I'd like to be making at this point, now that people are starting to build real projects on top of Trio. But... well, I thought it, so I'm writing it down, and we'll see what you all think (where "you" includes "me, but tomorrow").

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions