Skip to content
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

Updated cancellation semantics to better match the new trio and asyncio ones #586

Merged
merged 14 commits into from
Jul 17, 2023

Conversation

agronholm
Copy link
Owner

This is a lighter version of #496 which I could never get right.

Fixes #374.

@agronholm agronholm requested a review from graingert July 12, 2023 12:32
Copy link
Contributor

@zanieb zanieb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks great! I have some minor comments.

Does this also address cases from #432?

docs/tasks.rst Show resolved Hide resolved
src/anyio/_backends/_asyncio.py Outdated Show resolved Hide resolved
Comment on lines 519 to 520
elif exc_val is not None and not ignore_exception:
raise exc_val
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the follow-up for "reraise the CancelledError later unless this task was already interrupted by another exception" right? As well as if exc_val is not null on context exit? Perhaps a comment about these cases (particularly when the second case would occur) would be helpful.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just about the hardest part to figure out, as AnyIO is treading into uncharted territory as far as cancellation semantics go. I need to double check that I got this right, or at least that I've made it behave consistently.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC the idea was to raise a single cancellation exception if the host task was cancelled and there were no other errors.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least asyncio.TaskGroup behaves this way.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case where exc_val is the exception originally pased to __aexit__, you will get a nicer traceback (no extraneous frame) if you return False rather than explicitly reraising it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure it does -- save the original exc_val and compare its identity against the current one.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't mean this wasn't fixable. What I left unsaid was that I would need to add an extra local variable just for distinguishing between these two cases.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be clearer not to reassign exc_val anyway. That'd make the case I had questions about at #586 (comment) more obvious.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you're right. I'll poke the code and see what I come up with.

Copy link
Owner Author

@agronholm agronholm Jul 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted not to store any CancellationErrors which then simplifies the code somewhat. I just pushed an update.

src/anyio/_backends/_asyncio.py Outdated Show resolved Hide resolved
src/anyio/_backends/_asyncio.py Outdated Show resolved Hide resolved
agronholm and others added 2 commits July 14, 2023 20:02
Co-authored-by: Zanie Blue <contact@zanie.dev>
Co-authored-by: Zanie Blue <contact@zanie.dev>
``ExceptionGroup`` (or ``BaseExceptionGroup`` if one or more ``BaseException`` were
included)
- Fixed task group not raising a cancellation exception on asyncio at exit if no child
tasks were spawned and an outer cancellation scope had been cancelled before
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trio would say that a Cancelled exception means some work was interrupted. In this case, it sounds like no work was interrupted, so I'm not sure it's actually important to raise the exception. Trio does raise the exception, because of its rule that every async operation that can block (including NurseryManager.__aexit__) is always both a schedule point and a cancel point, but this has caused problems occasionally and there has been recent discussion of changing it; IIRC consensus was that it would be fine to change but no one has done it yet. Discussion in python-trio/trio#1457.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue that this refers to was that an AnyIO TaskGroup would swallow a CancelledError that was meant to cancel the entire task.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge-triggered cancellation rears its ugly head again!

My intuition is that for fully consistent behavior here you need to treat each asyncio task as implicitly enclosed in a CancelScope that would be cancelled by task.cancel(), and include that implicit cancel scope in your logic of whether each cancel scope should swallow a cancellation or forward it outward. You can indirectly detect whether a task-level cancellation has occurred, at least on 3.11+, by counting anyio-originated calls to task.cancel() that are not yet uncancelled, and comparing that against task.cancelling().

Making task group exit a cancel point is defensible though. Even if Trio stops doing that, you can easily add it back by putting in an explicit checkpoint_if_cancelled.

Copy link
Owner Author

@agronholm agronholm Jul 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

treat each asyncio task as implicitly enclosed in a CancelScope that would be cancelled by task.cancel().

This is actually something I was going to propose for asyncio! I'll be sending that post to Async-SIG shortly. Meanwhile, I can again look for a workaround for Python < 3.11, but that should not block this PR.

@agronholm
Copy link
Owner Author

This looks great! I have some minor comments.

Does this also address cases from #432?

The differences in what comes out when cancellation happens is not really something I can reasonably control. Trio does its own thing and asyncio needs a single CancelledError to pop out in order for asyncio to work with it.

@agronholm
Copy link
Owner Author

So, what's the verdict? Are more changes needed here?

Comment on lines +507 to +508
if not waited_for_tasks_to_finish:
await AsyncIOBackend.checkpoint()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth documenting why we checkpoint here?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified.

Comment on lines 501 to 502
# Raise the CancelledError received while waiting for child tasks to exit,
# unless the context manager itself was previously exited with another exception
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will also be skipped if an exception was raised in the the group (i.e. a value is in self._exceptions) from the clause above. Perhaps worth noting for clarity.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified.

Comment on lines +503 to +504
if cancelled_exc_while_waiting_tasks:
if exc_val is None or ignore_exception:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This could be one statement

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would not increase readability. I've seen something similar used in CPython code, and my initial thoughts were the same as yours until I saw the wisdom in doing it like this.

@zanieb
Copy link
Contributor

zanieb commented Jul 16, 2023

Overall I think this is clearer than before! I poked at it a little bit but couldn't come up with a better way to express the logic.

@agronholm
Copy link
Owner Author

I'm fairly happy with this, so I'm merging. If anything comes up, we can sort it out during the RC cycle.

@agronholm agronholm merged commit 316052e into master Jul 17, 2023
@agronholm agronholm deleted the detect-asyncio-native-cancel branch July 17, 2023 20:56
@agronholm
Copy link
Owner Author

This looks great! I have some minor comments.
Does this also address cases from #432?

The differences in what comes out when cancellation happens is not really something I can reasonably control. Trio does its own thing and asyncio needs a single CancelledError to pop out in order for asyncio to work with it.

So I just talked to Joshua Oreman and it seems that Trio wouldn't have an issue with a single Cancelled coming out of a task group; it was just my misconception. Expect another PR to address that.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Task group is swallowing CancelledError.
3 participants