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
TaskGroup suppresses grouped exceptions due to parent task cancellation #95704
Comments
I walked through this one with a debugger and you're right that we can do better. It seems the essence of the reproducer is that we enter import asyncio
async def child():
try:
await asyncio.sleep(2)
except asyncio.CancelledError:
print("** Cancelled **")
raise RuntimeError
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(child())
await asyncio.sleep(0.5)
raise asyncio.CancelledError
asyncio.run(main()) It prints Alas, the obvious fix (don't honor |
Looks like this is the correct fix: --- a/Lib/asyncio/taskgroups.py
+++ b/Lib/asyncio/taskgroups.py
@@ -115,7 +115,8 @@ async def __aexit__(self, et, exc, tb):
if self._base_error is not None:
raise self._base_error
- if propagate_cancellation_error is not None:
+ if (propagate_cancellation_error is not None and
+ (self._parent_cancel_requested or not self._errors)):
# The wrapping task was cancelled; since we're done with
# closing all child tasks, just propagate the cancellation
# request now. @grangert Do you agree? If so, do you want to make a PR with the new test added? |
Spoke too soon, now |
And so does Oh wait, sometimes I get no failures, sometimes I get |
Interestingly |
So IIUC It looks like Now I'd love to ask @1st1 what his intention was with these tests -- did he want them to be "racy"? (That would have been annoying, because racy tests just destabilize the test suite.) Assuming not, is the scenario the tests attempts to test the one where the crashers raise Discuss. |
Going through the thread. Going by the 'errors should never pass silently' I also agree we should make it so the example from #95704 (comment) raises an EG with
I copy/pasted those tests into quattro, and at that time for |
Oh, and the quattro test isn't flaky, at least I couldn't make it fail. But it is racy, sometimes the children get cancelled and sometimes they don't, but the test passes in both cases, so it's still a bad test. |
Thanks, @Tinche. What would quattro do with the example from #95704 (comment) (assuming that's a meaningful question)? Does it raise Your docstring might well express the intent of the test, but @graingert's complaint here is that cancelling a TG should propagate the children's exceptions. Come to think of it, maybe this intent explains the raciness of the test: the idea might have been to cancel the test before any of the children have crashed, but to have at least one child crash before the cancellation reaches
But they weren't supposed to raise too soon -- note the line (after the main task has slept 0.1 seconds, and just before it cancels the runner task) self.assertFalse(r.done()) which indicates that we expect the runner to still be running -- IOW the TG hasn't exited yet, which looks like a proxy for the assertion that the TG hasn't processed any crashing child tasks yet, and that in turn is attempting to assert that no child task has crashed yet at this point. So shortening the sleep in the crasher task would defeat the purpose of the test, and make it fail -- the TG would self-cancel when the first child crashes before the main task can cancel the runner task. But lengthening the sleep in the crasher would also defeat the purpose of the test, while making it pass -- the crasher tasks would never crash. In conclusion I think the three affected tests might have to be modified to use the following crasher: async def foo():
try:
await asyncio.sleep(1)
finally:
1 / 0 and they would have to be changed to expect an EG wrapping |
It raises
Yeah, I think it makes sense to change it in asyncio, and quattro will follow whatever asyncio does here.
I wager you're right. I personally wouldn't write the test this way though, I would write several tests for each scenario, so I vote for rewriting these tests.
Can we modify the tests to avoid sleeps with these hardcoded timeouts? What about rewriting async def test_cancellation_in_body_child_propagation(self):
"""
If the task running the TaskGroup is blocked on something and then
cancelled, the TG children are cancelled too.
If the children raise an exception different than `CancelledError`
while being stopped, those exceptions are raised in an ExceptionGroup.
"""
LONG_SLEEP = 10 # The awaiter is expected to be cancelled waiting on this.
exc = RuntimeError()
async def foo():
try:
await asyncio.sleep(LONG_SLEEP)
except CancelledError:
# We raise, and expect it to be propagated.
raise exc # Something other than a CancelledError.
async def runner():
async with taskgroups.TaskGroup() as g:
g.create_task(foo())
await asyncio.sleep(LONG_SLEEP)
r = asyncio.create_task(runner())
await asyncio.sleep(0.1)
self.assertFalse(r.done())
r.cancel()
with self.assertRaises(ExceptionGroup) as cm:
await r
self.assertEqual(cm.exception == ExceptionGroup([exc])) (I didn't actually run this because I don't have your changes, but you get the idea.) |
Just about, although that's not the way to test for an EG. |
When a task catches CancelledError and raises some other error, the other error should not silently be suppressed. Exception: when uncancel() != 0, always propagate CancelledError. NOTE: This represents a change in behavior (hence the need to change three tests). But it is only an edge case. We need to discuss with the RM whether to backport to 3.11RC2, or 3.11.1, or not at all.
When a task catches CancelledError and raises some other error, the other error should not silently be suppressed. Any scenario where a task crashes in cleanup upon cancellation will now result in an ExceptionGroup wrapping the crash(es) instead of propagating CancelledError and ignoring the side errors. NOTE: This represents a change in behavior (hence the need to change several tests). But it is only an edge case. Co-authored-by: Thomas Grainger <tagrain@gmail.com>
…pythonGH-95761) When a task catches CancelledError and raises some other error, the other error should not silently be suppressed. Any scenario where a task crashes in cleanup upon cancellation will now result in an ExceptionGroup wrapping the crash(es) instead of propagating CancelledError and ignoring the side errors. NOTE: This represents a change in behavior (hence the need to change several tests). But it is only an edge case. Co-authored-by: Thomas Grainger <tagrain@gmail.com> (cherry picked from commit f51f54f) Co-authored-by: Guido van Rossum <guido@python.org>
…5761) When a task catches CancelledError and raises some other error, the other error should not silently be suppressed. Any scenario where a task crashes in cleanup upon cancellation will now result in an ExceptionGroup wrapping the crash(es) instead of propagating CancelledError and ignoring the side errors. NOTE: This represents a change in behavior (hence the need to change several tests). But it is only an edge case. Co-authored-by: Thomas Grainger <tagrain@gmail.com> (cherry picked from commit f51f54f) Co-authored-by: Guido van Rossum <guido@python.org>
graingert commentedAug 5, 2022
•
edited
TaskGroup suppresses grouped exceptions due to parent task cancellation
The suggestion was to always check for Cancellation in every finally block in async context managers, however sometimes there's no finally block visible and the problem applies to synchronous context managers too
In this demo I'd expect to be able to print: "handled attribute error"
The text was updated successfully, but these errors were encountered: