CabbageDevelopment / qasync

Python library for using asyncio in Qt-based applications.
BSD 2-Clause "Simplified" License
321 stars 45 forks source link

Example code appears to discard reference to the created task #98

Open AlexanderWells-diamond opened 11 months ago

AlexanderWells-diamond commented 11 months ago

The aiohttp_fetch.py example file makes use of the @asyncSlot() decorator to allow passing the on_btnFetch_clicked async function to the .connect method of a button.

Under the covers, @asyncSlot() is just creating a Task object using asyncio.ensure_future(). In the documentation for that function, it says "Save a reference to the result of this function, to avoid a task disappearing mid-execution.". It seems that the example code does not do this, so may be liable for garbage-collection issues if the now reference-less task is cleaned up unexpectedly.

Is there a preferred pattern to use for these decorators? Or can the decorator be modified to keep a reference to the Task that is created?

phausamann commented 4 months ago

I've run into this issue, which results asyncSlot callbacks being cancelled unexpectedly. My solution is to patch the asyncSlot function to use the workaround suggested by the create_task docs:

import asyncio
import functools
import inspect
import sys

from PyQt5.QtCore import pyqtSlot

background_tasks = set()

def asyncSlot(*args, **kwargs):
    """Make a Qt async slot run on asyncio loop.

    Patched version of qasync.asyncSlot that keeps references to tasks, 
    so that they don't get garbage collected.
    """

    def _error_handler(task):
        try:
            task.result()
        except Exception:
            sys.excepthook(*sys.exc_info())
        finally:
            background_tasks.discard(task)

    def outer_decorator(fn):
        @pyqtSlot(*args, **kwargs)
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            # Qt ignores trailing args from a signal but python does
            # not so inspect the slot signature and if it's not
            # callable try removing args until it is.
            task = None
            while len(args):
                try:
                    inspect.signature(fn).bind(*args, **kwargs)
                except TypeError:
                    if len(args):
                        # Only convert args to a list if we need to pop()
                        args = list(args)
                        args.pop()
                        continue
                else:
                    task = asyncio.ensure_future(fn(*args, **kwargs))
                    task.add_done_callback(_error_handler)
                    background_tasks.add(task)
                    break
            if task is None:
                raise TypeError("asyncSlot was not callable from Signal. Potential signature mismatch.")
            return task

        return wrapper

    return outer_decorator

If this is an acceptable way of fixing this bug, I'd be happy to open a PR.