Skip to content

A nursery might inject a Cancelled even when none of the tasks received a Cancelled #1457

@vxgmichel

Description

@vxgmichel

I noticed that nurseries might inject Cancelled exception even when none of the tasks have received a Cancelled. I have two different examples reproducing this issue.

A nursery with a single task raising an exception

The following code runs a nursery with a single task raising an exception while the outer scope has been cancelled:

async def main():
    with trio.CancelScope() as scope:
        try:
            async with trio.open_nursery() as nursery:
                scope.cancel()
                raise RuntimeError
        except trio.MultiError as exc:
            print("Exceptions:", exc)
            print("Cancelled caught:", nursery.cancel_scope.cancelled_caught)

It prints:

Exceptions: RuntimeError(), Cancelled()
Cancelled caught: False

The Cancelled() exception is injected here:

trio/trio/_core/_run.py

Lines 845 to 851 in fbe3bd3

# Nothing to wait for, so just execute a checkpoint -- but we
# still need to mix any exception (e.g. from an external
# cancellation) in with the rest of our exceptions.
try:
await checkpoint()
except BaseException as exc:
self._add_exc(exc)

A potential fix could to be to use cancel_shielded_checkpoint() instead of checkpoint().

A nursery with a two tasks, both raising an exception

The following code runs a nursery with two tasks both raising an exception while the outer scope has been cancelled:

async def main():
    with trio.CancelScope() as scope:
        try:
            async with trio.open_nursery() as nursery:
                nursery.start_soon(_raise, RuntimeError(1))
                scope.cancel()
                raise RuntimeError(2)
        except trio.MultiError as exc:
            print("Exceptions:", exc)
            print("Cancelled caught:", nursery.cancel_scope.cancelled_caught)

It prints:

Exceptions: RuntimeError(2), Cancelled(), RuntimeError(1)
Cancelled caught: False

The Cancelled() exception is injected here:

trio/trio/_core/_run.py

Lines 835 to 840 in fbe3bd3

# If we get cancelled (or have an exception injected, like
# KeyboardInterrupt), then save that, but still wait until our
# children finish.
def aborted(raise_cancel):
self._add_exc(capture(raise_cancel).error)
return Abort.FAILED

I don't see the point of injecting a Cancelled here, since the cancellation is going to be ignored anyway: isn't it a rule of trio that a cancelled (in the sense of cancel_called) but completed operation should not raise a Cancelled?

Raising Cancelled means that the operation did not happen.
https://trio.readthedocs.io/en/latest/reference-core.html#cancellable-primitives

I feel like it would be more consistent with the trio cancellation semantics to see the nursery cleanup as:

try:
    yield nursery
finally:
    with trio.CancelScope(shield=True):
        await nursery.join_tasks()

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