Skip to content

Uncancellable, unscheduled asyncio.Task possible when cancel is issued from wrong thread and event loop debug is enabled #136399

Open
@bacchuswng

Description

@bacchuswng

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

No one assigned

    Labels

    stdlibPython modules in the Lib dirtopic-asynciotype-bugAn unexpected behavior, bug, or error

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

      pFad - Phonifier reborn

      Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

      Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


      Alternative Proxies:

      Alternative Proxy

      pFad Proxy

      pFad v3 Proxy

      pFad v4 Proxy