Skip to content

extmod/uasyncio/task.py: Fix crash when non-awaited task is awaited. #8929

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

Closed
wants to merge 1 commit into from

Conversation

jimmo
Copy link
Member

@jimmo jimmo commented Jul 19, 2022

A task that has been sent to the loop's exception handler due to being re-scheduled twice will then subsequently cause a raise None if it is subsequently awaited. In the C version of task.py, this causes a segfault.

async def task():
    raise Exception("task")

t = asyncio.create_task(task())
await asyncio.sleep(0)
await asyncio.sleep(0)  # <-- loop exception handler called here
await t  # <-- this line crashes

This PR makes the await succeed (via raising StopIteration instead).

I'm not sure this is the right fix though... it seems wrong to make the await succeed. The other option that I can think of are:

  • Improve the logic for detecting the "un-awaited" task. Without reference counting this seems impossible.
  • Make the task retain its exception, and even after it has been delivered to the loop's exception handler, still have await raise the original exception.
  • Make the await raise CancelledError.

I think the third option (CancelledError) might be the most pragmatic alternative?

A task that has been sent to the loop's exception handler due to being
re-scheduled twice will then subsequently cause a `raise None` if it is
subsequently awaited. In the C version of task.py, this causes a segfault.

This makes the await succeed (via raising StopIteration instead).

Signed-off-by: Jim Mussared <jim.mussared@gmail.com>
@dlech
Copy link
Contributor

dlech commented Jul 19, 2022

What is the CPython behavior for this test case?

@dlech
Copy link
Contributor

dlech commented Jul 19, 2022

What is the CPython behavior for this test case?

I got curious, so I played around with this a bit.

import asyncio

async def task():
    raise Exception("task")

async def test():
    t = asyncio.create_task(task())
    await asyncio.sleep(0)
    await asyncio.sleep(0)
    await t

async def wait():
    await asyncio.sleep(2)
    print('done')

async def main():
    await asyncio.gather(test(), wait())

asyncio.run(main())

Results in:

Traceback (most recent call last):
  File "/home/david/work/junk/mp8929/test.py", line 19, in <module>
    asyncio.run(main())
  File "/usr/lib/python3.10/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/usr/lib/python3.10/asyncio/base_events.py", line 646, in run_until_complete
    return future.result()
  File "/home/david/work/junk/mp8929/test.py", line 17, in main
    await asyncio.gather(test(), wait())
  File "/home/david/work/junk/mp8929/test.py", line 10, in test
    await t
  File "/home/david/work/junk/mp8929/test.py", line 4, in task
    raise Exception("task")
Exception: task

If you comment out await t, then you get:

Task exception was never retrieved
future: <Task finished name='Task-4' coro=<task() done, defined at /home/david/work/junk/mp8929/test.py:3> exception=Exception('task')>
Traceback (most recent call last):
  File "/home/david/work/junk/mp8929/test.py", line 4, in task
    raise Exception("task")
Exception: task
done

Since CPython is reference counted, it knows the task (t) cannot be used any more, so it calls the loop exception handler right away (before done).

If you add global t as the first line of test() so that the task lives on past the end of the coroutine, you get:

done
Task exception was never retrieved
future: <Task finished name='Task-4' coro=<task() done, defined at /home/david/work/junk/mp8929/test.py:3> exception=Exception('task')>
Traceback (most recent call last):
  File "/home/david/work/junk/mp8929/test.py", line 4, in task
    raise Exception("task")
Exception: task

The loop exception handler is not called until the very end of the program.

@jimmo
Copy link
Member Author

jimmo commented Jul 19, 2022

What is the CPython behavior for this test case?

@dlech Thanks for reminding me, sorry forgot to include this above.

In this test case, CPython raises on the await (and never sends the exception to the loop handler).

My understanding is that only when a task becomes unreferenced does it get sent to the loop handler, and until then it's assumed that it will potentially be awaited.

If we consider the loop handler to be "informative", then that would be a good argument for alternative 2 above (i.e. retain the exception on the task).

@dlech
Copy link
Contributor

dlech commented Jul 19, 2022

My first instinct is to change the MicroPython behavior to be like the CPython case with the global t. By default, don't run the loop exception handler unless the task is still queued when the loop is stopped. Then we could add a Task.close() method that could be used to check for unawaited tasks, e.g. in a context manager. This could act as a replacement for the reference counting.

import asyncio

async def task():
    raise Exception("task")

async def test():
    async with asyncio.create_task(task()) as t:
        await asyncio.sleep(0)
        await asyncio.sleep(0)
        # await t <- not awaited task
    # <-- loop exception handler runs here

async def wait():
    await asyncio.sleep(2)
    print('done')

async def main():
    await asyncio.gather(test(), wait())

asyncio.run(main())

Unfortunately, this wouldn't be compatible with CPython, so probably not a practical solution.

@dlech
Copy link
Contributor

dlech commented Jul 19, 2022

If we consider the loop handler to be "informative", then that would be a good argument for alternative 2 above (i.e. retain the exception on the task).

I think not retaining the exception and raising when awaited is a bug. There could be cases where exceptions are expected.

t = asyncio.create_task(task())
...
try:
    await t
except SomeError:
    ...

In this case, SomeError is expected and handled but if the loop exception handler also runs before this, it just creates noise (which could be misleading if it says the exception was not handled, but it really was).

@dhalbert
Copy link
Contributor

FYI, we incorporated this a while ago into CircuitPython, even though it is currently unmerged into MicroPython, as adafruit#7059.

tannewt pushed a commit to tannewt/circuitpython that referenced this pull request Feb 16, 2024
Require 64 bytes extra CDC RX buffer
@dpgeorge
Copy link
Member

Make the task retain its exception, and even after it has been delivered to the loop's exception handler, still have await raise the original exception.

This fix (option 2) was actually implemented as part of fixing gather(), see 2ecbad4

And the test from this PR was separately added in c3ca361

@dpgeorge dpgeorge closed this Feb 25, 2024
@dpgeorge
Copy link
Member

FYI, we incorporated this a while ago into CircuitPython

@dhalbert you probably want to revert this fix in CircuitPython.

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

Successfully merging this pull request may close these issues.

4 participants