I’m honestly not too familiar with asyncio, but for me (on python 3.13.5) the original main_async_bad version does end up printing a traceback (even without awaiting the task):
Starting async!
Ending.
Task exception was never retrieved
future: <Task finished name='Task-2' coro=<adelayed_boom() done, defined at .../foo.py:10> exception=AttributeError("'NoneType' object has no attribute 'sense'")>
Traceback (most recent call last):
File ".../foo.py", line 12, in adelayed_boom
None.sense
AttributeError: 'NoneType' object has no attribute 'sense'
Am I missing something?
And you can even get the exception to print “in a timely manner”, if you del task (or don’t save task into a temporary variable in the first place). So, this
async def main_async_bad():
print("Starting async!")
asyncio.create_task(adelayed_boom())
# ^ create the task and immediately drop it
await asyncio.sleep(3) # it's too fiddly to wait for input
print("Ending.")
prints the exception traceback before the Ending.:
Starting async!
Task exception was never retrieved
future: <Task finished name='Task-2' coro=<adelayed_boom() done, defined at .../foo.py:10> exception=AttributeError("'NoneType' object has no attribute 'sense'")>
Traceback (most recent call last):
File ".../foo.py", line 12, in adelayed_boom
None.sense
AttributeError: 'NoneType' object has no attribute 'sense'
Ending.
My understanding is that the Task exception was never retrieved thing is triggered when a task with a pending exception is deleted before being awaited. It obviously can’t / shouldn’t print an exception before that point, because otherwise you could end up printing the exception twice.
In fact your spawn implementation has just such an issue:
async def main_async_good_but_oops():
print("Starting async!")
task = spawn(adelayed_boom())
await asyncio.sleep(3)
# At this point the task has already printed its traceback,
# but awaiting it will re-raise the exception, printing it again.
await task
print("Ending.") # Won't happen
prints
Starting async!
Traceback (most recent call last):
File ".../foo.py", line 12, in adelayed_boom
None.sense
AttributeError: 'NoneType' object has no attribute 'sense'
Traceback (most recent call last):
File ".../foo.py", line 80, in <module>
asyncio.run(main_async_good_but_oops())
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../asyncio/runners.py", line 195, in run
return runner.run(main)
~~~~~~~~~~^^^^^^
File ".../asyncio/runners.py", line 118, in run
return self._loop.run_until_complete(task)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
File ".../asyncio/base_events.py", line 725, in run_until_complete
return future.result()
~~~~~~~~~~~~~^^
File ".../foo.py", line 72, in main_async_good_but_oops
await task
File ".../foo.py", line 12, in adelayed_boom
None.sense
AttributeError: 'NoneType' object has no attribute 'sense'
It seems to me that the only real issue with asyncio.create_task is that any tasks that outlive the asyncio.run call end up being cancelled.
But that’s not a case of “Errors passing silently”, but rather “Code being interrupted before managing to reach the error”.
Also, I think that your spawn implementation is still susceptible to this issue (via the except asyncio.exceptions.CancelledError: in handle_errors).
But afaik, this is a fundamental limitation of async loops. Unlike threads and processes, an async task can’t escape past the lifetime of it’s parent async loop, so you are forced to either wait for all tasks to complete (like with asyncio.TaskGroup.create_task) or to cancel any tasks that are still in progress (like with asyncio.create_task).