Skip to content
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

Concern around deprecating App.add_background_task() #2809

Closed
rmartin16 opened this issue Sep 2, 2024 · 19 comments · Fixed by #2814
Closed

Concern around deprecating App.add_background_task() #2809

rmartin16 opened this issue Sep 2, 2024 · 19 comments · Fixed by #2814
Labels
bug A crash or error in behavior.

Comments

@rmartin16
Copy link
Member

rmartin16 commented Sep 2, 2024

Describe the bug

In #2678, add_background_task() was deprecated; users are now recommended to use asyncio.create_task() instead. However, asyncio.create_task() is not technically a sufficient drop-in replacement for add_background_task().

Most existing uses of add_background_task() are almost certainly just call it and forget it, e.g.:

self.add_background_task(self.long_running_task)

Replacing this blindly with asyncio.create_task() is not inherently safe. As outlined in the Python documentation and discussed on Discourse, users should take care to ensure a reference to the returned Task is maintained until the task completes.

So, really, users should rewrite their previous add_background_task() calls like this:

# during `App.startup()`
self.background_tasks = set()

# later on
task = asyncio.create_task(self.long_running_task)
self.background_tasks.add(task)
task.add_done_callback(self.background_tasks.discard)

I can imagine this isn't obvious to many developers using Toga.

Steps to reproduce

Use asyncio.create_task() in an app.

Expected behavior

Toga should recommend a method to run background tasks with less potentially sharp edges. Although, I think such a method would look a lot like the previous add_background_task()...

That said, I think that add_background_task() may have been exposed to this same underlying issue....since it ultimately just calls ensure_future() for a wrapped version of the user's coroutine and a reference to the Future returned by ensure_future() is never maintained.

Screenshots

No response

Environment

  • Operating System: all
  • Python version: all
  • Software versions:
    • Toga: 0.4.6

Logs

No response

Additional context

I think, mostly, I just wanted to capture these thoughts and see what others think. First, the origin of the warning in the Python docs isn't clear to me...namely, when is this important? (maybe this incredibly verbose SO answer says why...) Second, if Toga has always been exposed to this...why hasn't the issue ever been reported?

@rmartin16 rmartin16 added the bug A crash or error in behavior. label Sep 2, 2024
@freakboy3742
Copy link
Member

Well... TIL :-)

The primary motivation behind deprecating add_background_task() was to remove a Toga-specific interpretation of asyncio APIs in favor of the core APIs with documented patterns. I think that's still a desirable goal... it's just problematic that the documented pattern is... more complex than you'd hope.

I agree that it's very curious that this has never been reported as an problem in live Toga apps. My immediate guess is that there's something in the underlying app.loop.call_soon_threadsafe(wrapped_handler(self, method)) call that is generating a closure over something that is preventing deletion... but if this is the case, it's definitely not obvious what feature is preventing the deletion.

It also raises the question over whether the on_running support that was added as one of the add_background_task deprecation is subject to this bug. It's essentially replicating the old "threadsafe handler" pattern... but if that is potentially problematic, then we should be doing some variant on the the "store and cleanup" trick that you've described.

@freakboy3742 freakboy3742 added the awaiting details More details are needed before the issue can be triaged. label Sep 2, 2024
@rmartin16
Copy link
Member Author

rmartin16 commented Sep 3, 2024

I read through that long SO answer I referenced. It describes a somewhat convoluted way that the garbage collector can destroy a Task while it is running; and thus demonstrating the need to keep a reference to the Task.

So, the situation to create this issue exists...but most implementations probably accidentally avoid it because of exact implementation of asyncio or other factors.

A Toga app that creates the issue:

import asyncio
import gc

import toga

class HelloWorld(toga.App):
    def startup(self):
        self.main_window = toga.MainWindow(content=toga.Button("Click"))
        self.main_window.show()

        self.tasks = [
            asyncio.create_task(self.coro1(), name="coro1"),
            asyncio.create_task(self.coro2(), name="coro2"),
        ]

    async def on_running(self):
        print("inside on_running - going to wait for future")
        await self.loop.create_future()

    async def coro1(self):
        for _ in range(5):
            print("coro1 printing...")
            await asyncio.sleep(1)
            gc.collect()
        self.exit()

    async def coro2(self):
        print("inside coro2 - going to wait for future")
        await self.loop.create_future()

def main():
    return HelloWorld()

When you run it, you can see that the Task created for on_running() is destroyed prematurely.

[helloworld] Starting in dev mode...
===========================================================================
coro1 printing...
inside coro2 - going to wait for future
inside on_running - going to wait for future
Task was destroyed but it is pending!
source_traceback: Object created at (most recent call last):
  File "<string>", line 1, in <module>
  File "<frozen runpy>", line 226, in run_module
  File "<frozen runpy>", line 98, in _run_module_code
  File "<frozen runpy>", line 88, in _run_code
  File "/home/russell/tmp/beeware/helloworld/src/helloworld/__main__.py", line 4, in <module>
    main().main_loop()
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/toga/app.py", line 480, in main_loop
    self._impl.main_loop()
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/toga_gtk/app.py", line 149, in main_loop
    self.loop.run_forever(application=self.native)
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/gbulb/glib_events.py", line 865, in run_forever
    self.run()
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/gbulb/gtk.py", line 39, in run
    super().run()
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/gbulb/glib_events.py", line 818, in run
    self._application.run(self._argv)
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/gi/overrides/Gio.py", line 42, in run
    return Gio.Application.run(self, *args, **kwargs)
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/gbulb/glib_events.py", line 169, in __callback__
    self._run()
  File "/home/russell/.pyenv/versions/3.11.9/lib/python3.11/asyncio/events.py", line 84, in _run
    self._context.run(self._callback, *self._args)
  File "/home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/toga/handlers.py", line 169, in _handler
    return asyncio.ensure_future(
  File "/home/russell/.pyenv/versions/3.11.9/lib/python3.11/asyncio/tasks.py", line 659, in ensure_future
    return _ensure_future(coro_or_future, loop=loop)
  File "/home/russell/.pyenv/versions/3.11.9/lib/python3.11/asyncio/tasks.py", line 680, in _ensure_future
    return loop.create_task(coro_or_future)
task: <Task pending name='Task-3' coro=<handler_with_cleanup() done, defined at /home/russell/.pyenv/versions/briefcase-3.11/lib/python3.11/site-packages/toga/handlers.py:86> wait_for=<Future pending cb=[Task.task_wakeup()] created at /home/russell/.pyenv/versions/3.11.9/lib/python3.11/asyncio/base_events.py:428> created at /home/russell/.pyenv/versions/3.11.9/lib/python3.11/asyncio/tasks.py:680>
coro1 printing...
coro1 printing...
coro1 printing...
coro1 printing...

Notably, the same thing doesn't happen to coro2() because its Task is preserved in self.tasks....but if you don't keep that Task around, the same thing happens for coro2().

Given this, I think we should update how Toga's handlers are managed to guarantee that Tasks created by asyncio.ensure_future() are preserved until the Task completes. Furthermore, we need to consider what to tell or do for users so they can safely submit background tasks.

@rmartin16
Copy link
Member Author

Tracking issue for CPython

There is support for adding native hard references for arbitrary tasks created by asyncio....but it hasn't happened yet.

@freakboy3742
Copy link
Member

Given this, I think we should update how Toga's handlers are managed to guarantee that Tasks created by asyncio.ensure_future() are preserved until the Task completes.

Agreed - you've provided a relatively simple reproduction case; the implication is that the only reason we haven't seen this problem is that they haven't exercised sufficient garbage collection to expose the problem.

Furthermore, we need to consider what to tell or do for users so they can safely submit background tasks.

Agreed. There's a documentation TODO covering writing a "how to do async"; #2099 is still open as a general documentation task covering asyncio usage; the topic is partially covered by the Beeware tutorial, but there's room for a lot more discussion. A discussion of retaining tasks would seem to fit well into that documentation topic.

@rmartin16
Copy link
Member Author

rmartin16 commented Sep 3, 2024

Furthermore, we need to consider what to tell or do for users so they can safely submit background tasks.

Agreed. There's a documentation TODO covering writing a "how to do async"; #2099 is still open as a general documentation task covering asyncio usage; the topic is partially covered by the Beeware tutorial, but there's room for a lot more discussion. A discussion of retaining tasks would seem to fit well into that documentation topic.

So, I think we should update the documentation....but what if we just hook asyncio.create_task()? I mean, we're already completely shaping these event loops...so, this seems, at least, a mostly reasonable idea to me. wdyt?

@freakboy3742
Copy link
Member

You mean monkeypatch the official method to also do some form of caching of created tasks? Or is there a hook we can use that I'm not thinking of?

@rmartin16
Copy link
Member Author

rmartin16 commented Sep 3, 2024

Not quite; asyncio.create_task() is mostly a wrapper on get_event_loop().create_task(). Given this, I was imagining that Toga could interject some logic to track the Tasks when the loop's create_task() is called.

@freakboy3742
Copy link
Member

Oh - interesting... I hadn't considered that approach. I guess that could work; it might get a little complicated with GTK, especially if we switch to the native GTK event loop... but that might be a viable way to make the call safe.

As an aside, I'll also be at the core team summit in a couple of weeks, so I can see about getting some movement on the upstream issue there (as has been suggested on the ticket). That won't fix the situation on Python 3.9-3.12, but it would mean any shim we add could be temporary.

@rmartin16
Copy link
Member Author

rmartin16 commented Sep 4, 2024

While I was thinking about it...perhaps Toga could set a task factory for the loop; this might be a better supported option to inject some logic and then defer to the loop's default behavior.

@freakboy3742
Copy link
Member

I didn't know that option existed - but from the look of it, it would be an even better option. It would guarantee that all tasks created would have lifecycle protection, without the need for any additional handling.

@Jzhenli
Copy link

Jzhenli commented Sep 12, 2024

in toga 0.4.6, how to use asyncio.create_task() to replace App.add_background_task(), would u give a example? @rmartin16 @freakboy3742

@rmartin16 rmartin16 removed the awaiting details More details are needed before the issue can be triaged. label Sep 12, 2024
@rmartin16
Copy link
Member Author

Sure; here's an example of an app creating background tasks:

class HelloWorld(toga.App):
    def startup(self):
        main_box = toga.Box()

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.content = main_box
        self.main_window.show()

        self.loop.call_soon_threadsafe(self.sync_task, "Hi")
        asyncio.create_task(self.async_task("Hi"))

    def sync_task(self, arg):
        print(f"running sync task: {arg}")

    async def async_task(self, arg):
        print(f"running async task: {arg}")

@Jzhenli
Copy link

Jzhenli commented Sep 12, 2024

@rmartin16 thanks for your reply, but when run your example code, it will raise "RuntimeError: no running event loop".

@rmartin16
Copy link
Member Author

There are different ways to start a Toga app; if you aren't using a project created by Briefcase, the code below can start this app (after you've imported asyncio and toga):

HelloWorld(formal_name="Hello World", app_id="com.example.helloworld").main_loop()

@rmartin16
Copy link
Member Author

Also, if you're curious about asyncio outside of Toga, here's similar code:

async def main():
    asyncio.get_running_loop().call_soon_threadsafe(sync_task, "Hi")
    await asyncio.create_task(async_task("Hi"))

def sync_task(arg):
    print(f"running sync task: {arg}")

async def async_task(arg):
    print(f"running async task: {arg}")

asyncio.run(main())

@Jzhenli
Copy link

Jzhenli commented Sep 12, 2024

There are different ways to start a Toga app; if you aren't using a project created by Briefcase, the code below can start this app (after you've imported asyncio and toga):

HelloWorld(formal_name="Hello World", app_id="com.example.helloworld").main_loop()

@rmartin16 yes, I run this example code with briefcase and it will raise "RuntimeError: no running event loop". Detail info as below

[testtoga] Starting in dev mode...

Traceback (most recent call last):
File "", line 1, in
File "C:\Python\Python310\lib\runpy.py", line 224, in run_module
return _run_module_code(code, init_globals, run_name, mod_spec)
File "C:\Python\Python310\lib\runpy.py", line 96, in _run_module_code
_run_code(code, mod_globals, init_globals,
File "C:\Python\Python310\lib\runpy.py", line 86, in run_code
exec(code, run_globals)
File "D:\code\python\beeware-tutorial\testtoga\src\testtoga_main
.py", line 5, in
main().main_loop()
File "D:\code\python\beeware-tutorial\beeware-venv\lib\site-packages\toga\app.py", line 480, in main_loop
self._impl.main_loop()
File "D:\code\python\beeware-tutorial\beeware-venv\lib\site-packages\toga_winforms\app.py", line 186, in main_loop
raise self._exception
File "D:\code\python\beeware-tutorial\beeware-venv\lib\site-packages\toga_winforms\app.py", line 161, in _run_app
self.create()
File "D:\code\python\beeware-tutorial\beeware-venv\lib\site-packages\toga_winforms\app.py", line 127, in create
self.interface._startup()
File "D:\code\python\beeware-tutorial\beeware-venv\lib\site-packages\toga\app.py", line 619, in _startup
self.startup()
File "D:\code\python\beeware-tutorial\testtoga\src\testtoga\app.py", line 20, in startup
asyncio.create_task(self.async_task("Hi"))
File "C:\Python\Python310\lib\asyncio\tasks.py", line 336, in create_task
loop = events.get_running_loop()
RuntimeError: no running event loop

Problem running app testtoga.

@rmartin16
Copy link
Member Author

Ahh, ok, I see now; thank you. On Windows, the event loop is indeed started after startup() runs. I don't normally use Windows.

Given that, a workaround for the time being is to add async tasks from a sync task if such tasks need to be added from startup():

class HelloWorld(toga.App):
    def startup(self):
        main_box = toga.Box()

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.content = main_box
        self.main_window.show()

        self.loop.call_soon_threadsafe(self.sync_task, "Hi")

    def sync_task(self, arg):
        print(f"running sync task: {arg}")
        asyncio.create_task(self.async_task("Hi"))

    async def async_task(self, arg):
        print(f"running async task: {arg}")

@rmartin16
Copy link
Member Author

Also, @Jzhenli, with the latest version of Toga, you can also use an on_running() method to start an arbitrary task at startup. The on_running() method can be sync or async. Otherwise, after startup(), you can use asyncio.create_task() to run background tasks.

For instance:

class HelloWorld(toga.App):
    def startup(self):
        main_box = toga.Box()

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.content = main_box
        self.main_window.show()

        self.loop.call_soon_threadsafe(self.sync_task, "Hi")
        
    def sync_task(self, arg):
        print(f"running sync task: {arg}")
        asyncio.create_task(self.async_task("from sync task"))

    async def async_task(self, arg):
        print(f"running async task: {arg}")
        
    async def on_running(self):
        print(f"on_running")

@Jzhenli
Copy link

Jzhenli commented Sep 12, 2024

now it works, thanks @rmartin16

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug A crash or error in behavior.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants