Description
Bug report
Bug description:
When Python event loop debugging is enabled (e.g., via asyncio.get_running_loop().set_debug(True)
), cancelling an asyncio task from the wrong thread results in the following warning as expected.
RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one
However, after this warning, the task appears to be left in an odd zombie state, and this task cannot be cancelled anymore via cancel()
using the correct thread or using asyncio.run_coroutine_threadsafe(..., x._loop)
).
Likely related, the task also never gets scheduled again on the event loop and thus never completes.
Note that this bug does not appear when event loop debugging is disabled. I believe the check for non-thread-safe operations (which is likely leaving the task in a weird state) is disabled in this case.
I tested this on all the listed versions for macOS as well as Python 3.12 for Linux.
Repro:
from __future__ import annotations
import asyncio
import concurrent.futures
import contextlib
from typing import Iterator
global_task: asyncio.Task | None = None
@contextlib.contextmanager
def thread_pool_executor() -> Iterator[concurrent.futures.ThreadPoolExecutor]:
tpe = concurrent.futures.ThreadPoolExecutor(max_workers=1)
try:
yield tpe
finally:
tpe.shutdown(wait=False, cancel_futures=False)
async def cancel_using_wrong_thread(asyncio_debug: bool) -> None:
async def _thread_main() -> None:
if asyncio_debug:
asyncio.get_running_loop().set_debug(True)
global_task.cancel("cancel_using_wrong_thread")
# Attempt to cancel from new thread - should throw an exception
with thread_pool_executor() as tpe:
await asyncio.get_running_loop().run_in_executor(tpe, asyncio.run, _thread_main())
async def cancel_using_right_thread(asyncio_debug: bool) -> None:
async def _cancel_global_task() -> None:
global_task.cancel("cancel_using_right_thread")
async def _thread_main() -> None:
if asyncio_debug:
asyncio.get_running_loop().set_debug(True)
asyncio.run_coroutine_threadsafe(_cancel_global_task(), global_task._loop)
with thread_pool_executor() as tpe:
await asyncio.get_running_loop().run_in_executor(tpe, asyncio.run, _thread_main())
async def main() -> None:
global global_task
for asyncio_debug in (False, True):
print(f"--- Testing {asyncio_debug=}")
asyncio.get_running_loop().set_debug(asyncio_debug)
global_task = asyncio.create_task(asyncio.sleep(5))
await asyncio.sleep(0)
# Cancel with wrong thread via cancel()
try:
await cancel_using_wrong_thread(asyncio_debug)
except RuntimeError as exc:
if "Non-thread-safe operation" not in str(exc):
raise
await asyncio.wait((global_task,), timeout=1)
print(f"- global_task after cancel_using_wrong_thread(): {global_task=}")
# Cancel with right thread via run_coroutine_threadsafe()
await cancel_using_right_thread(asyncio_debug)
await asyncio.wait((global_task,), timeout=1)
print(f"- global_task after cancel_using_right_thread(): {global_task=}")
# Cancel with right thread via cancel()
global_task.cancel()
await asyncio.wait((global_task,), timeout=1)
print(f"- global_task after cancel(): {global_task=}")
# Sleep should be over
await asyncio.wait((global_task,), timeout=5)
print(f"- global_task after sleep should have ended: {global_task=}")
if asyncio_debug:
await global_task # hangs
else:
assert global_task.done() # works fine with asyncio_debug = False
if __name__ == "__main__":
asyncio.run(main())
CPython versions tested on:
3.13, 3.12, 3.11, 3.10, 3.9
Operating systems tested on:
macOS, Linux
Metadata
Metadata
Assignees
Projects
Status