classification
Title: asyncio.Task doesn't propagate CancelledError() exception correctly.
Type: behavior Stage:
Components: asyncio Versions: Python 3.9
process
Status: open Resolution:
Dependencies: Superseder:
Assigned To: Nosy List: asvetlov, bjs, chris.jerdonek, graingert, pagliaricci.m, yselivanov
Priority: normal Keywords:

Created on 2021-10-06 11:30 by pagliaricci.m, last changed 2021-10-10 04:13 by chris.jerdonek.

Files
File name Uploaded Description Edit
task_bug.py pagliaricci.m, 2021-10-06 11:30
task_bug.py pagliaricci.m, 2021-10-09 13:00
task_bug.py pagliaricci.m, 2021-10-09 13:16
Messages (10)
msg403294 - (view) Author: Marco Pagliaricci (pagliaricci.m) Date: 2021-10-06 11:30
I've spotted a little bug in how asyncio.CancelledError() exception is propagated inside an asyncio.Task.

Since python 3.9 the asyncio.Task.cancel() method has a new 'msg' parameter, that will create an asyncio.CancelledError(msg) exception incorporating that message.

The exception is successfully propagated to the coroutine the asyncio.Task is running, so the coroutine successfully gets raised an asyncio.CancelledError(msg) with the specified message in asyncio.Task.cancel(msg) method.

But, once the asyncio.Task is cancelled, is impossible to retrieve that original asyncio.CancelledError(msg) exception with the message, because it seems that *a new* asyncio.CancelledError() [without the message] is raised when asyncio.Task.result() or asyncio.Task.exception() methods are called.

I have the feeling that this is just wrong, and that the original message specified in asyncio.Task.cancel(msg) should be propagated even also asyncio.Task.result() is called.

I'm including a little snippet of code that clearly shows this bug.

I'm using python 3.9.6, in particular:
Python 3.9.6 (default, Aug 21 2021, 09:02:49) 
[GCC 10.2.1 20210110] on linux
msg403297 - (view) Author: Ben (bjs) * Date: 2021-10-06 11:36
This seems to be present in both the Python implementation as well as the accelerated C _asyncio module.

It looks like that when a Task awaits a cancelled future,
the task itself is cancelled but the cancellation message is not propagated to the task.
https://github.com/python/cpython/blob/main/Lib/asyncio/tasks.py#L242
msg403350 - (view) Author: Chris Jerdonek (chris.jerdonek) * (Python committer) Date: 2021-10-07 06:46
> But, once the asyncio.Task is cancelled, is impossible to retrieve that original asyncio.CancelledError(msg) exception with the message, because it seems that *a new* asyncio.CancelledError() [without the message] is raised when asyncio.Task.result() or asyncio.Task.exception() methods are called.

You say it's "impossible", but isn't the message accessible via the exception chain (and visible in the traceback)? One benefit of not duplicating the message on the internal call to cancel() is that it makes it easier to pinpoint which CancelledError object is associated with the user's call to cancel(), and which is associated with the call done by asyncio internals, which is a different cancellation. Another benefit is that it prevents info from being duplicated in the traceback.
msg403360 - (view) Author: Thomas Grainger (graingert) * Date: 2021-10-07 08:25
afaik this is intentional https://bugs.python.org/issue31033
msg403521 - (view) Author: Marco Pagliaricci (pagliaricci.m) Date: 2021-10-09 07:38
OK, I see your point.
But I still can't understand one thing and I think it's very confusing:

1) if you see my example, inside the job() coroutine, I get correctly
cancelled with an `asyncio.CancelledError` exception containing my message.
2) Now: if I re-raise the asyncio.CancelledError as-is, I lose the message,
if I call the `asyncio.Task.exception()` function.
3) If I raise a *new* asyncio.CancelledError with a new message, inside the
job() coroutine's `except asyncio.CancelledError:` block, I still lose the
message if I call `asyncio.Task.exception()`.
4) But if I raise another exception, say `raise ValueError("TEST")`, always
from the `except asyncio.CancelledError:` block of the job() coroutine, I
*get* the message!
I get `ValueError("TEST")` by calling `asyncio.Task.exception()`, while I
don't with the `asyncio.CancelledError()` one.

Is this really wanted? Sorry, but I still find this a lot confusing.
Shouldn't it be better to return from the `asyncio.Task.exception()` the
old one (containing the message) ?
Or, otherwise, create a new instance of the exception for *ALL* the
exception classes?

Thank you for your time,
My Best Regards,

M.

On Thu, Oct 7, 2021 at 10:25 AM Thomas Grainger <report@bugs.python.org>
wrote:

>
> Thomas Grainger <tagrain@gmail.com> added the comment:
>
> afaik this is intentional https://bugs.python.org/issue31033
>
> ----------
> nosy: +graingert
>
> _______________________________________
> Python tracker <report@bugs.python.org>
> <https://bugs.python.org/issue45390>
> _______________________________________
>
msg403530 - (view) Author: Chris Jerdonek (chris.jerdonek) * (Python committer) Date: 2021-10-09 12:51
> 2) Now: if I re-raise the asyncio.CancelledError as-is, I lose the message,
if I call the `asyncio.Task.exception()` function.

Re-raise asyncio.CancelledError where? (And what do you mean by "re-raise"?) Call asyncio.Task.exception() where? This isn't part of your example, so it's not clear what you mean exactly.
msg403531 - (view) Author: Marco Pagliaricci (pagliaricci.m) Date: 2021-10-09 13:00
Chris,
I'm attaching to this e-mail the code I'm referring to.
As you can see, in line 10, I re-raise the asyncio.CancelledError exception
with a message "TEST".
That message is lost, due to the reasons we've talked about.

My point is that, if we substitute that line 10, with the commented line
11, and we comment the line 10, so we raise a ValueError("TEST") exception,
as you can see, the message "TEST" is NOT LOST.
I just find this counter-intuitive, and error-prone.

AT LEAST should be very well specified in the docs.

Regards,
M.

On Sat, Oct 9, 2021 at 2:51 PM Chris Jerdonek <report@bugs.python.org>
wrote:

>
> Chris Jerdonek <chris.jerdonek@gmail.com> added the comment:
>
> > 2) Now: if I re-raise the asyncio.CancelledError as-is, I lose the
> message,
> if I call the `asyncio.Task.exception()` function.
>
> Re-raise asyncio.CancelledError where? (And what do you mean by
> "re-raise"?) Call asyncio.Task.exception() where? This isn't part of your
> example, so it's not clear what you mean exactly.
>
> ----------
>
> _______________________________________
> Python tracker <report@bugs.python.org>
> <https://bugs.python.org/issue45390>
> _______________________________________
>
msg403532 - (view) Author: Chris Jerdonek (chris.jerdonek) * (Python committer) Date: 2021-10-09 13:06
I still don't see you calling asyncio.Task.exception() in your new attachment...
msg403533 - (view) Author: Marco Pagliaricci (pagliaricci.m) Date: 2021-10-09 13:16
Chris,
ok, I have modified the snippet of code to better show what I mean.
Still here, the message of the CancelledError exception is lost, but if I
comment line 10, and uncomment line 11, so I throw a ValueError("TEST"),
that "TEST" string will be printed, so the message is not lost.
Again, I just find this behavior very counter-intuitive, and should be VERY
WELL documented in the docs.

Thanks,
M.

On Sat, Oct 9, 2021 at 3:06 PM Chris Jerdonek <report@bugs.python.org>
wrote:

>
> Chris Jerdonek <chris.jerdonek@gmail.com> added the comment:
>
> I still don't see you calling asyncio.Task.exception() in your new
> attachment...
>
> ----------
>
> _______________________________________
> Python tracker <report@bugs.python.org>
> <https://bugs.python.org/issue45390>
> _______________________________________
>
msg403570 - (view) Author: Chris Jerdonek (chris.jerdonek) * (Python committer) Date: 2021-10-10 04:13
Here's a simplification of Marco's snippet to focus the discussion.

import asyncio

async def job():
    # raise RuntimeError('error!')
    await asyncio.sleep(5)

async def main():
    task = asyncio.create_task(job())
    await asyncio.sleep(1)
    task.cancel('cancel job')
    await task

if __name__=="__main__":
    asyncio.run(main())

----
Running this pre-Python 3.9 gives something like this--

Traceback (most recent call last):
  File "test.py", line 15, in <module>
    asyncio.run(main())
  File "/.../python3.7/asyncio/runners.py", line 43, in run
    return loop.run_until_complete(main)
  File "/.../python3.7/asyncio/base_events.py", line 579, in run_until_complete
    return future.result()
concurrent.futures._base.CancelledError

----
Running this with Python 3.9+ gives something like the following. The difference is that the traceback now starts at the sleep() call:

Traceback (most recent call last):
  File "/.../test.py", line 6, in job
    await asyncio.sleep(5)
  File "/.../python3.9/asyncio/tasks.py", line 654, in sleep
    return await future
asyncio.exceptions.CancelledError: cancel job

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/.../test.py", line 12, in main
    await task
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/.../test.py", line 15, in <module>
    asyncio.run(main())
  File "/.../python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/.../python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
asyncio.exceptions.CancelledError

----
Uncommenting the RuntimeError turns it into this--

Traceback (most recent call last):
  File "/.../test.py", line 15, in <module>
    asyncio.run(main())
  File "/.../python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/.../python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File "/.../test.py", line 12, in main
    await task
  File "/.../test.py", line 5, in job
    raise RuntimeError('error!')
RuntimeError: error!

----
I agree it would be a lot nicer if the original CancelledError('cancel job') could bubble up just like the RuntimeError does, instead of creating a new CancelledError at each await and chaining it to the previous CancelledError. asyncio's creation of a new CancelledError at each stage predates the PR that added the chaining, so this could be viewed as an evolution of the change that added the chaining.

I haven't checked to be sure, but the difference in behavior between CancelledError and other exceptions might be explained by the following lines:
https://github.com/python/cpython/blob/3d1ca867ed0e3ae343166806f8ddd9739e568ab4/Lib/asyncio/tasks.py#L242-L250
You can see that for exceptions other than CancelledError, the exception is propagated by calling super().set_exception(exc), whereas with CancelledError, it is propagated by calling super().cancel() again.

Maybe this would even be an easy change to make. Instead of asyncio creating a new CancelledError and chaining it to the previous, asyncio can just raise the existing one. For the pure Python implementation at least, it may be as simple as making a change here, inside _make_cancelled_error():
https://github.com/python/cpython/blob/3d1ca867ed0e3ae343166806f8ddd9739e568ab4/Lib/asyncio/futures.py#L135-L142
History
Date User Action Args
2021-10-10 04:13:19chris.jerdoneksetmessages: + msg403570
2021-10-09 13:16:22pagliaricci.msetfiles: + task_bug.py

messages: + msg403533
2021-10-09 13:06:02chris.jerdoneksetmessages: + msg403532
2021-10-09 13:00:14pagliaricci.msetfiles: + task_bug.py

messages: + msg403531
2021-10-09 12:51:00chris.jerdoneksetmessages: + msg403530
2021-10-09 07:38:23pagliaricci.msetmessages: + msg403521
2021-10-07 08:25:18graingertsetnosy: + graingert
messages: + msg403360
2021-10-07 06:46:58chris.jerdoneksetnosy: + chris.jerdonek
messages: + msg403350
2021-10-06 11:36:52bjssetnosy: + bjs
messages: + msg403297
2021-10-06 11:30:42pagliaricci.mcreate