You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
TaskGroup suppresses grouped exceptions due to parent task cancellation #95704
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"
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 TaskGroup__aexit__ with a CancellationError but without having cancelled any tasks yet, and then one or more tasks catch the CancellationError and raise something else (in your case, AttributeError because contextlib.closing() calls sock.close() during cleanup, which doesn't exist). This code seems to repro the same issue but without a fake socket class or context managers, and with only one child:
--- 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?
So test_taskgroup_11 has a race condition. Each of the 5 tasks created sleeps 0.1 second and then crashes, raising ZeroDivisionError. The main task sleeps 0.1 second and then cancels the runner task, which cancels the crashing tasks. The test is written to always expect a CancelledError, but if any of the 5 crashing tasks finishes its sleep before being cancelled, it will raise ZeroDivisionError and the change to TaskGroup.__aexit__ makes it so that in that cases it raises an ExceptionGroup wrapping the ZeroDivisionError(s).
IIUC test_taskgroup_12 has the same race, the difference with test_taskgroup_11 is that it uses two nested TaskGroups.
It looks like test_cancellation_in_body has the same race condition again. (This looks like it used to be called test_taskgroup_08 in EdgeDb; @Tinche renamed it when he added a cancel message to it in #31559 -- which was then removed again by @asvetlov when he deprecated cancel messages. :-)
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 ZeroDivisionError, or the one where they are cancelled in their sleep?
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 RuntimeError.
Assuming not, is the scenario the tests attempts to test the one where the crashers raise ZeroDivisionError, or the one where they are cancelled in their sleep?
I copy/pasted those tests into quattro, and at that time for test_taskgroup_08 I added the following docstring: """Cancelling a TG does not propagate children exceptions.""" (but I wrote it as my interpretation of the test). So I'm guessing the children were supposed to raise, and those exceptions were supposed to get swallowed.
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 CancelledError or (an EG wrapping a) RuntimeError? If it raises CancelledError, that explains why the test isn't flaky for quattro -- it's not flaky in asyncio on main either, but it would be if we were to commit my fix to taskgroups.py without fixing those three racy tests.
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 TaskGroup.__aexit__. (Hence creating 5 children -- to increase the chance of at least one of them actually crashing.) @graingert's demo (or my reduction of it) would seem to be a much more reliable way to elicit this effect: the child task crashes during cleanup, after it has caught the CancelledError.
So I'm guessing the children were supposed to raise, and those exceptions were supposed to get swallowed.
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:
It raises CancelledError. But you have to remember the quattro TG is the EdgeDB TG with minimal changes (type hints and tweaks).
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.
Yeah, I think it makes sense to change it in asyncio, and quattro will follow whatever asyncio does here.
Come to think of it, maybe this intent explains the raciness of the test
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.
In conclusion I think the three affected tests might have to be modified
Can we modify the tests to avoid sleeps with these hardcoded timeouts?
What about rewriting test_cancellation_in_body like this:
asyncdeftest_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()
asyncdeffoo():
try:
awaitasyncio.sleep(LONG_SLEEP)
exceptCancelledError:
# We raise, and expect it to be propagated.raiseexc# Something other than a CancelledError.asyncdefrunner():
asyncwithtaskgroups.TaskGroup() asg:
g.create_task(foo())
awaitasyncio.sleep(LONG_SLEEP)
r=asyncio.create_task(runner())
awaitasyncio.sleep(0.1)
self.assertFalse(r.done())
r.cancel()
withself.assertRaises(ExceptionGroup) ascm:
awaitrself.assertEqual(cm.exception==ExceptionGroup([exc]))
(I didn't actually run this because I don't have your changes, but you get the idea.)
Activity
gvanrossum commentedon Aug 6, 2022
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
TaskGroup__aexit__with aCancellationErrorbut without having cancelled any tasks yet, and then one or more tasks catch theCancellationErrorand raise something else (in your case,AttributeErrorbecausecontextlib.closing()callssock.close()during cleanup, which doesn't exist). This code seems to repro the same issue but without a fake socket class or context managers, and with only one child:It prints
** Cancelled **and then raisesCancelledError, whereas I agree that it would be more useful to report theRuntimeErrorAlas, the obvious fix (don't honor
propagate_cancellation_errorifself._errorsis non-empty) breaks two of the asyncio tests.gvanrossum commentedon Aug 7, 2022
Looks like this is the correct fix:
@grangert Do you agree? If so, do you want to make a PR with the new test added?
gvanrossum commentedon Aug 7, 2022
Spoke too soon, now
test_cancellation_in_bodyfails. :-(gvanrossum commentedon Aug 7, 2022
And so does
test_taskgroup_11. It's a game of whack-a-mole.Oh wait, sometimes I get no failures, sometimes I get
test_taskgroup_12failing. FWIW I am testing on Windows.gvanrossum commentedon Aug 7, 2022
Interestingly
I don't get any failures on MacUPDATE: it crashes much less frequently on Mac.What do you get on Linux?gvanrossum commentedon Aug 7, 2022
So
test_taskgroup_11has a race condition. Each of the 5 tasks created sleeps 0.1 second and then crashes, raisingZeroDivisionError. The main task sleeps 0.1 second and then cancels the runner task, which cancels the crashing tasks. The test is written to always expect aCancelledError, but if any of the 5 crashing tasks finishes its sleep before being cancelled, it will raiseZeroDivisionErrorand the change toTaskGroup.__aexit__makes it so that in that cases it raises anExceptionGroupwrapping theZeroDivisionError(s).IIUC
test_taskgroup_12has the same race, the difference withtest_taskgroup_11is that it uses two nestedTaskGroups.It looks like
test_cancellation_in_bodyhas the same race condition again. (This looks like it used to be calledtest_taskgroup_08in EdgeDb; @Tinche renamed it when he added a cancel message to it in #31559 -- which was then removed again by @asvetlov when he deprecated cancel messages. :-)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
ZeroDivisionError, or the one where they are cancelled in their sleep?Discuss.
Tinche commentedon Aug 7, 2022
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
RuntimeError.I copy/pasted those tests into quattro, and at that time for
test_taskgroup_08I added the following docstring: """Cancelling a TG does not propagate children exceptions.""" (but I wrote it as my interpretation of the test). So I'm guessing the children were supposed to raise, and those exceptions were supposed to get swallowed.Tinche commentedon Aug 7, 2022
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.
gvanrossum commentedon Aug 7, 2022
Thanks, @Tinche. What would quattro do with the example from #95704 (comment) (assuming that's a meaningful question)? Does it raise
CancelledErroror (an EG wrapping a)RuntimeError? If it raisesCancelledError, that explains why the test isn't flaky for quattro -- it's not flaky in asyncio on main either, but it would be if we were to commit my fix totaskgroups.pywithout fixing those three racy tests.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
TaskGroup.__aexit__. (Hence creating 5 children -- to increase the chance of at least one of them actually crashing.) @graingert's demo (or my reduction of it) would seem to be a much more reliable way to elicit this effect: the child task crashes during cleanup, after it has caught theCancelledError.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)
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:
and they would have to be changed to expect an EG wrapping
ZeroDivisionErrorinstead ofCancellationError(not wrapped by an EG).Tinche commentedon Aug 7, 2022
It raises
CancelledError. But you have to remember the quattro TG is the EdgeDB TG with minimal changes (type hints and tweaks).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
test_cancellation_in_bodylike this:(I didn't actually run this because I don't have your changes, but you get the idea.)
gvanrossum commentedon Aug 7, 2022
Just about, although that's not the way to test for an EG.
pythonGH-95704: Don't suppress errors from tasks when TG is cancelled
GH-95704: Don't suppress errors from tasks when TG is cancelled (#95761)
pythonGH-95704: Don't suppress errors from tasks when TG is cancelled (…
GH-95704: Don't suppress errors from tasks when TG is cancelled (GH-9…