From 11445882a13c4d81c8a3764772187ca68d16399f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 12 Dec 2025 12:11:14 +0100 Subject: [PATCH 01/10] Improve loop detection --- docs/backends.rst | 3 +- docs/start.rst | 2 +- docs/static/_pyodide_iframe.html | 1 - examples/cube_auto.py | 2 +- examples/pyscript.html | 2 +- examples/serve_browser_examples.py | 2 +- pyproject.toml | 2 +- rendercanvas/__init__.py | 10 ++++- rendercanvas/_coreutils.py | 5 +++ rendercanvas/_loop.py | 72 +++++++++++++++++++++--------- rendercanvas/asyncio.py | 5 +-- rendercanvas/trio.py | 4 +- rendercanvas/utils/asyncadapter.py | 11 ++++- rendercanvas/utils/asyncs.py | 59 ++++++++++++------------ tests/test_meta.py | 2 +- 15 files changed, 115 insertions(+), 67 deletions(-) diff --git a/docs/backends.rst b/docs/backends.rst index 7738a63e..b01222df 100644 --- a/docs/backends.rst +++ b/docs/backends.rst @@ -297,7 +297,7 @@ An example using PyScript (which uses Pyodide):
- @@ -335,7 +335,6 @@ An example using Pyodide directly: await pyodide.loadPackage("micropip"); const micropip = pyodide.pyimport("micropip"); await micropip.install("numpy"); - await micropip.install("sniffio"); await micropip.install("rendercanvas"); // have to call as runPythonAsync pyodide.runPythonAsync(pythonCode); diff --git a/docs/start.rst b/docs/start.rst index 85676ecf..04cf0f0e 100644 --- a/docs/start.rst +++ b/docs/start.rst @@ -132,7 +132,7 @@ If you like callbacks, ``loop.call_later()`` always works. If you like async, us If you make use of async functions (co-routines), and want to keep your code portable across different canvas backends, restrict your use of async features to ``sleep`` and ``Event``; these are the only features currently implemented in our async adapter utility. -We recommend importing these from :doc:`rendercanvas.utils.asyncs ` or use ``sniffio`` to detect the library that they can be imported from. +We recommend importing these from :doc:`rendercanvas.utils.asyncs ` if you want your code to be portable across different event loop backends. On the other hand, if you know your code always runs on the asyncio loop, you can fully make use of ``asyncio``. Ditto for Trio. diff --git a/docs/static/_pyodide_iframe.html b/docs/static/_pyodide_iframe.html index 6e9d8d09..34da006d 100644 --- a/docs/static/_pyodide_iframe.html +++ b/docs/static/_pyodide_iframe.html @@ -22,7 +22,6 @@

Loading...

let pyodide = await loadPyodide(); await pyodide.loadPackage("micropip"); const micropip = pyodide.pyimport("micropip"); - await micropip.install('sniffio'); await micropip.install('numpy'); // The below loads rendercanvas from pypi. But we will replace it with the name of the wheel, // so that it's loaded from the docs (in _static). diff --git a/examples/cube_auto.py b/examples/cube_auto.py index 15ebe5c6..f98e5d9a 100644 --- a/examples/cube_auto.py +++ b/examples/cube_auto.py @@ -7,7 +7,7 @@ # run_example = true -from rendercanvas.auto import RenderCanvas, loop +from rendercanvas.pyside6 import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync canvas = RenderCanvas( diff --git a/examples/pyscript.html b/examples/pyscript.html index e99fbb1e..d888dd57 100644 --- a/examples/pyscript.html +++ b/examples/pyscript.html @@ -31,7 +31,7 @@

Loading...


- diff --git a/examples/serve_browser_examples.py b/examples/serve_browser_examples.py index aaf1ac6d..5fc81da7 100644 --- a/examples/serve_browser_examples.py +++ b/examples/serve_browser_examples.py @@ -104,7 +104,7 @@ def get_html_index(): diff --git a/pyproject.toml b/pyproject.toml index dc3a7e90..1162a476 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ keywords = [ "jupyter", ] requires-python = ">= 3.10" -dependencies = ["sniffio"] +dependencies = [] # Zero hard dependencies! [project.optional-dependencies] # For users jupyter = ["jupyter_rfb>=0.4.2"] diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index d619902b..5897235a 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -8,8 +8,16 @@ from . import _coreutils from ._enums import CursorShape, EventType, UpdateMode from .base import BaseRenderCanvas, BaseLoop +from ._loop import get_running_loop from . import contexts from . import utils -__all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType", "UpdateMode"] +__all__ = [ + "BaseLoop", + "BaseRenderCanvas", + "CursorShape", + "EventType", + "UpdateMode", + "get_running_loop", +] diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index ccba8132..78d5e6b2 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -21,6 +21,11 @@ IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide +# %% One place to store thread-local info (like the current loop object) + +thread_local = threading.local() + + # %% Logging diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 523d6815..b18b0c3b 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -11,7 +11,13 @@ from typing import TYPE_CHECKING from ._enums import LoopState -from ._coreutils import logger, log_exception, call_later_from_thread, close_agen +from ._coreutils import ( + logger, + log_exception, + call_later_from_thread, + close_agen, + thread_local, +) from .utils.asyncs import sleep from .utils import asyncadapter @@ -28,6 +34,16 @@ ) +def get_running_loop(): + """Get the running loop (for the current thread) or None. + + This method is intended to obtain the currently running rendercanvas + loop object. To detect what async event-loop is actually running, + it's recommended to check ``sys.get_asyncgen_hooks`` or use ``sniffio``. + """ + return getattr(thread_local, "loop", None) + + class BaseLoop: """The base class for an event-loop object. @@ -69,7 +85,9 @@ def __init__(self): self.__should_stop = 0 self.__state = LoopState.off self.__is_initialized = False + self.__uses_adapter = False # set to True if using our asyncadapter self._asyncgens = weakref.WeakSet() + self._previous_asyncgen_hooks = None # self._setup_debug_thread() def _setup_debug_thread(self): @@ -143,6 +161,7 @@ async def wrapper(): self.__is_initialized = True self._rc_init() self._rc_add_task(wrapper, "loop-task") + self.__uses_adapter = len(self.__tasks) > 0 async def _loop_task(self): # This task has multiple purposes: @@ -375,6 +394,12 @@ def __start(self): if self.__state in (LoopState.off, LoopState.ready): self.__state = LoopState.active + # Set the running loop + if get_running_loop(): + logger.error("Detected the loop starting, while another loop is running.") + + thread_local.loop = self + # Setup asyncgen hooks. This is done when we detect the loop starting, # not in run(), because most event-loops will handle interrupts, while # e.g. qt won't care about async generators. @@ -390,6 +415,8 @@ def __stop(self): self.__state = LoopState.off self.__should_stop = 0 + thread_local.loop = None + self.__finish_asyncgen_hooks() # If we used the async adapter, cancel any tasks. If we could assume @@ -447,21 +474,31 @@ def __setup_asyncgen_hooks(self): # for the qt/wx/raw loop do we do this, an in these cases we don't # expect fancy async stuff. - current_asyncgen_hooks = sys.get_asyncgen_hooks() - if ( - current_asyncgen_hooks.firstiter is None - and current_asyncgen_hooks.finalizer is None - ): - sys.set_asyncgen_hooks( - firstiter=self._asyncgen_firstiter_hook, - finalizer=self._asyncgen_finalizer_hook, - ) - else: - # Assume that the hooks are from asyncio/trio on which this loop is running. - pass + # Only register hooks if we use the asyncadapter; async frameworks install their own hooks. + if not self.__uses_adapter: + return + + asyncgens = self._asyncgens + + def asyncgen_firstiter_hook(agen): + asyncgens.add(agen) + + def asyncgen_finalizer_hook(agen): + asyncgens.discard(agen) + close_agen(agen) + + self._previous_asyncgen_hooks = sys.get_asyncgen_hooks() + sys.set_asyncgen_hooks( + firstiter=asyncgen_firstiter_hook, + finalizer=asyncgen_finalizer_hook, + ) def __finish_asyncgen_hooks(self): - sys.set_asyncgen_hooks(None, None) + if self._previous_asyncgen_hooks is not None: + sys.set_asyncgen_hooks(*self._previous_asyncgen_hooks) + self._previous_asyncgen_hooks = None + else: + sys.set_asyncgen_hooks(None, None) if len(self._asyncgens): closing_agens = list(self._asyncgens) @@ -469,13 +506,6 @@ def __finish_asyncgen_hooks(self): for agen in closing_agens: close_agen(agen) - def _asyncgen_firstiter_hook(self, agen): - self._asyncgens.add(agen) - - def _asyncgen_finalizer_hook(self, agen): - self._asyncgens.discard(agen) - close_agen(agen) - def _rc_init(self): """Put the loop in a ready state. diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index e54cb4e8..8ab0d569 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -6,8 +6,7 @@ __all__ = ["AsyncioLoop", "loop"] from .base import BaseLoop - -import sniffio +from .utils.asyncs import detect_current_async_lib class AsyncioLoop(BaseLoop): @@ -43,7 +42,7 @@ async def _rc_run_async(self): import asyncio # Protect against usage of wrong loop object - libname = sniffio.current_async_library() + libname = detect_current_async_lib() if libname != "asyncio": raise TypeError(f"Attempt to run AsyncioLoop with {libname}.") diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index ee498023..d7e3e767 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -8,7 +8,7 @@ from .base import BaseLoop import trio -import sniffio +from .utils.asyncs import detect_current_async_lib class TrioLoop(BaseLoop): @@ -24,7 +24,7 @@ def _rc_run(self): async def _rc_run_async(self): # Protect against usage of wrong loop object - libname = sniffio.current_async_library() + libname = detect_current_async_lib() if libname != "trio": raise TypeError(f"Attempt to run TrioLoop with {libname}.") diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index be6d35a4..95e05226 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -4,8 +4,13 @@ """ import logging +import threading -from sniffio import thread_local as sniffio_thread_local +# Support sniffio for older wgpu releases, and for code that relies on sniffio. +try: + from sniffio_ import thread_local as sniffio_thread_local +except ImportError: + sniffio_thread_local = threading.local() logger = logging.getLogger("asyncadapter") @@ -97,7 +102,9 @@ def step(self): result = None stop = False - old_name, sniffio_thread_local.name = sniffio_thread_local.name, __name__ + old_name = getattr(sniffio_thread_local, "name", None) + sniffio_thread_local.name = __name__ + self.running = True try: if self.cancelled: diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 38cf9a77..a10696d1 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -1,55 +1,56 @@ """ This module implements all async functionality that one can use in any ``rendercanvas`` backend. -This uses ``sniffio`` to detect the async framework in use. -To give an idea how to use ``sniffio`` to get a generic async sleep function: +To give an idea how to implement a generic async sleep function: .. code-block:: py - libname = sniffio.current_async_library() + libname = detect_current_async_lib() sleep = sys.modules[libname].sleep """ import sys -import sniffio - -from .._coreutils import IS_WIN, call_later_from_thread +from .._coreutils import IS_WIN, call_later_from_thread, thread_local USE_THREADED_TIMER = IS_WIN +def detect_current_async_lib(): + """Get the lib name of the currently active async lib, or None. + + This uses ``sys.get_asyncgen_hooks()`` for fast and robust detection. + Compared to sniffio, this is faster and also works when not inside a task. + Compared to ``rendercanvas.get_running_loop()`` this also works when asyncio + is running while the rendercanvas loop is not. + """ + ob = sys.get_asyncgen_hooks()[0] + if ob is not None: + try: + libname = ob.__module__.partition(".")[0] + except AttributeError: + return None + if libname == "rendercanvas": + libname = "rendercanvas.utils.asyncadapter" + return libname + + async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native. - On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the standard ``sleep()``. + On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the ``sleep()`` of asyncio/trio. """ - # The commented code below would be quite elegant, but we don't have get_running_rendercanvas_loop(), - # so instead we replicate the call_soon_threadsafe logic for asyncio and trio here. - # - # if delay > 0 and USE_THREADED_TIMER: - # event = Event() - # rc_loop = get_running_rendercanvas_loop() - # call_later_from_thread(delay, rc_loop.call_soon_threadsafe, event.set) - # await event.wait() - - libname = sniffio.current_async_library() - if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: - asyncio = sys.modules[libname] - loop = asyncio.get_running_loop() - event = asyncio.Event() - call_later_from_thread(delay, loop.call_soon_threadsafe, event.set) - await event.wait() - elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: - trio = sys.modules[libname] - event = trio.Event() - token = trio.lowlevel.current_trio_token() - call_later_from_thread(delay, token.run_sync_soon, event.set) + rc_loop = getattr(thread_local, "loop", None) # == get_running_loop + + if delay > 0 and USE_THREADED_TIMER and rc_loop is not None: + event = Event() + call_later_from_thread(delay, rc_loop.call_soon_threadsafe, event.set) await event.wait() else: + libname = detect_current_async_lib() sleep = sys.modules[libname].sleep await sleep(delay) @@ -58,6 +59,6 @@ class Event: """Generic async event object. Works with trio, asyncio and rendercanvas-native.""" def __new__(cls): - libname = sniffio.current_async_library() + libname = detect_current_async_lib() Event = sys.modules[libname].Event # noqa return Event() diff --git a/tests/test_meta.py b/tests/test_meta.py index 2ac9063b..00837f42 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -76,7 +76,7 @@ def test_namespace(): @pytest.mark.skipif(sys.version_info < (3, 10), reason="Need py310+") def test_deps_plain_import(): modules = get_loaded_modules("rendercanvas", 1) - assert modules == {"rendercanvas", "sniffio"} + assert modules == {"rendercanvas"} # Note, no wgpu From c43ea5079e9fd191cbff19e518e7e76ba126e4a6 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 12 Dec 2025 16:33:09 +0100 Subject: [PATCH 02/10] Tweaking --- rendercanvas/_loop.py | 109 ++++++++++++++++++----------- rendercanvas/asyncio.py | 3 +- rendercanvas/utils/asyncadapter.py | 3 +- rendercanvas/utils/asyncs.py | 20 ++++-- tests/test_loop.py | 34 +++++++-- tests/test_offscreen.py | 7 ++ 6 files changed, 122 insertions(+), 54 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index b18b0c3b..244dbb96 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -80,14 +80,14 @@ class BaseLoop: """ def __init__(self): - self.__tasks = set() + self.__tasks = set() # only used by the async adapter self.__canvas_groups = set() self.__should_stop = 0 self.__state = LoopState.off self.__is_initialized = False - self.__uses_adapter = False # set to True if using our asyncadapter + self.__hook_data = None + self.__using_adapter = False # set to True if using our asyncadapter self._asyncgens = weakref.WeakSet() - self._previous_asyncgen_hooks = None # self._setup_debug_thread() def _setup_debug_thread(self): @@ -161,7 +161,7 @@ async def wrapper(): self.__is_initialized = True self._rc_init() self._rc_add_task(wrapper, "loop-task") - self.__uses_adapter = len(self.__tasks) > 0 + self.__using_adapter = len(self.__tasks) > 0 async def _loop_task(self): # This task has multiple purposes: @@ -181,6 +181,10 @@ async def _loop_task(self): # because its minimized (applies to backends that implement # _rc_gui_poll). + # In some cases the task may run after the loop was closed + if self.__state == LoopState.off: + return + # The loop has started! self.__start() @@ -314,8 +318,7 @@ def run(self) -> None: self._ensure_initialized() - # Register interrupt handler - prev_sig_handlers = self.__setup_interrupt() + need_unregister = self.__setup_hooks() # Run. We could be in this loop for a long time. Or we can exit immediately if # the backend already has an (interactive) event loop and did not call _mark_as_interactive(). @@ -326,14 +329,15 @@ def run(self) -> None: # Mark state as not 'running', but also not to 'off', that happens elsewhere. if self.__state == LoopState.running: self.__state = LoopState.active - for sig, cb in prev_sig_handlers.items(): - signal.signal(sig, cb) + if need_unregister: + self.__restore_hooks() async def run_async(self) -> None: """ "Alternative to ``run()``, to enter the mainloop from a running async framework. Only supported by the asyncio and trio loops. """ + # Can we enter the loop? if self.__state in (LoopState.off, LoopState.ready): pass @@ -387,23 +391,53 @@ def stop(self, *, force=False) -> None: if len(canvases) == 0 or self.__should_stop >= 2: self.__stop() + def __setup_hooks(self): + """Activate current loop, setup asycgen hooks, and setup interrupts.""" + if self.__hook_data is not None: + return False + + # Set the running loop + prev_loop = get_running_loop() + thread_local.loop = self + + # Setup asyncgen hooks + prev_asyncgen_hooks = self.__setup_asyncgen_hooks() + + # Set interrupts + prev_interrupt_hooks = self.__setup_interrupt_hooks() + + self.__hook_data = prev_loop, prev_asyncgen_hooks, prev_interrupt_hooks + return True + + def __restore_hooks(self): + """Unregister hooks.""" + + # This is separated from stop(), so that a loop can be 'active' by repeated calls to ``run()``, but will only + # actually have registered hooks while inside ``run()``. The StubLoop has this behavior, and it may be a bit silly + # to organize for this special use-case, but it does make it more clean/elegant, and maybe someday we will want another + # loop class that runs for short periods. This now works, even when another loop is running. + + if self.__hook_data is None: + return + + prev_loop, prev_asyncgen_hooks, prev_interrupt_hooks = self.__hook_data + self.__hook_data = None + + thread_local.loop = prev_loop + + if prev_asyncgen_hooks is not None: + sys.set_asyncgen_hooks(*prev_asyncgen_hooks) + + for sig, cb in prev_interrupt_hooks.items(): + signal.signal(sig, cb) + def __start(self): """Move to running state.""" - # Update state, but leave 'interactive' and 'running' if self.__state in (LoopState.off, LoopState.ready): self.__state = LoopState.active - # Set the running loop - if get_running_loop(): - logger.error("Detected the loop starting, while another loop is running.") - - thread_local.loop = self - - # Setup asyncgen hooks. This is done when we detect the loop starting, - # not in run(), because most event-loops will handle interrupts, while - # e.g. qt won't care about async generators. - self.__setup_asyncgen_hooks() + self.__setup_hooks() def __stop(self): """Move to the off-state.""" @@ -415,10 +449,6 @@ def __stop(self): self.__state = LoopState.off self.__should_stop = 0 - thread_local.loop = None - - self.__finish_asyncgen_hooks() - # If we used the async adapter, cancel any tasks. If we could assume # that the backend processes pending events before actually shutting # down, we could only call .cancel(), and leave the event-loop to do the @@ -434,11 +464,20 @@ def __stop(self): # Note that backends that do not use the asyncadapter are responsible # for cancelling pending tasks. + self.__restore_hooks() + + # Cancel async gens + if len(self._asyncgens): + closing_agens = list(self._asyncgens) + self._asyncgens.clear() + for agen in closing_agens: + close_agen(agen) + # Tell the backend to stop the loop. This usually means it will stop # soon, but not *now*; remember that we're currently in a task as well. self._rc_stop() - def __setup_interrupt(self): + def __setup_interrupt_hooks(self): """Setup the interrupt handlers.""" def on_interrupt(sig, _frame): @@ -472,11 +511,12 @@ def __setup_asyncgen_hooks(self): # the generator in the user's code. Note that when a proper async # framework (asyncio or trio) is used, all of this does not apply; only # for the qt/wx/raw loop do we do this, an in these cases we don't - # expect fancy async stuff. + # expect fancy async stuff. Oh, and the sleep and Event actually become no-ops when the + # asyncgen hooks are restored, so that error message should in theory never happen anyway. # Only register hooks if we use the asyncadapter; async frameworks install their own hooks. - if not self.__uses_adapter: - return + if not self.__using_adapter: + return None asyncgens = self._asyncgens @@ -487,24 +527,13 @@ def asyncgen_finalizer_hook(agen): asyncgens.discard(agen) close_agen(agen) - self._previous_asyncgen_hooks = sys.get_asyncgen_hooks() + prev_asyncgen_hooks = sys.get_asyncgen_hooks() sys.set_asyncgen_hooks( firstiter=asyncgen_firstiter_hook, finalizer=asyncgen_finalizer_hook, ) - def __finish_asyncgen_hooks(self): - if self._previous_asyncgen_hooks is not None: - sys.set_asyncgen_hooks(*self._previous_asyncgen_hooks) - self._previous_asyncgen_hooks = None - else: - sys.set_asyncgen_hooks(None, None) - - if len(self._asyncgens): - closing_agens = list(self._asyncgens) - self._asyncgens.clear() - for agen in closing_agens: - close_agen(agen) + return prev_asyncgen_hooks def _rc_init(self): """Put the loop in a ready state. diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index 8ab0d569..98117a62 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -73,7 +73,8 @@ def _rc_stop(self): task = self.__tasks.pop() task.cancel() # is a no-op if the task is no longer running # Signal that we stopped - self._stop_event.set() + if self._stop_event is not None: + self._stop_event.set() self._stop_event = None self._run_loop = None # Note how we don't explicitly stop a loop, not the interactive loop, nor the running loop diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index 95e05226..4801fad7 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -8,7 +8,7 @@ # Support sniffio for older wgpu releases, and for code that relies on sniffio. try: - from sniffio_ import thread_local as sniffio_thread_local + from sniffio import thread_local as sniffio_thread_local except ImportError: sniffio_thread_local = threading.local() @@ -79,7 +79,6 @@ def add_done_callback(self, callback): self._done_callbacks.append(callback) def _close(self): - self.loop = None self.coro = None for callback in self._done_callbacks: try: diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index a10696d1..54ce5e5a 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -51,8 +51,11 @@ async def sleep(delay): await event.wait() else: libname = detect_current_async_lib() - sleep = sys.modules[libname].sleep - await sleep(delay) + if libname is None: + return # the loop is gone, we're probably cleaning up + else: + sleep = sys.modules[libname].sleep + await sleep(delay) class Event: @@ -60,5 +63,14 @@ class Event: def __new__(cls): libname = detect_current_async_lib() - Event = sys.modules[libname].Event # noqa - return Event() + if libname is None: + return object.__new__(cls) + else: + Event = sys.modules[libname].Event # noqa + return Event() + + async def wait(self): + return + + def set(self): + pass diff --git a/tests/test_loop.py b/tests/test_loop.py index 6ba98c3b..782bc272 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -23,6 +23,7 @@ from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop from rendercanvas.raw import RawLoop +from rendercanvas import get_running_loop from rendercanvas.utils.asyncs import sleep as async_sleep @@ -166,12 +167,12 @@ def test_run_loop_and_close_bc_no_canvases(SomeLoop): # Run the loop without canvas; closes immediately loop = SomeLoop() - loop.call_later(0.1, print, "hi from loop!") loop.call_later(1.0, loop.stop) # failsafe t0 = time.perf_counter() loop.run() t1 = time.perf_counter() + assert (t1 - t0) < 0.2 @@ -201,6 +202,17 @@ def test_loop_detects_canvases(SomeLoop): assert len(loop._BaseLoop__canvas_groups) == 2 assert len(loop.get_canvases()) == 3 + # Call stop explicitly. Because we created some canvases, but never ran the + # loops, they are in a 'ready' state, ready to move to the running state + # when the loop-task starts running. For raw/asyncio/trio this is fine, + # because cleanup will cancel all tasks. But for the QtLoop, the QTimer has + # a reference to the callback, which refs asyncadapter.Task, which refs the + # coroutine which refs the loop object. So there will not be any cleanup and + # *this* loop will start running at the next test func. + loop.stop() + loop.stop() + assert loop._BaseLoop__state == "off" + @pytest.mark.parametrize("SomeLoop", loop_classes) def test_run_loop_without_canvases(SomeLoop): @@ -255,6 +267,8 @@ def test_run_loop_without_canvases(SomeLoop): def test_run_loop_and_close_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. + current_loops = [] + loop = SomeLoop() group = CanvasGroup(loop) @@ -263,12 +277,16 @@ def test_run_loop_and_close_canvases(SomeLoop): group._register_canvas(canvas1, fake_task) group._register_canvas(canvas2, fake_task) - loop.call_later(0.1, print, "hi from loop!") + loop.call_later( + 0.1, lambda: current_loops.append(get_running_loop().__class__.__name__) + ) loop.call_later(0.1, canvas1.manually_close) loop.call_later(0.3, canvas2.manually_close) t0 = time.time() + current_loops.append(get_running_loop().__class__.__name__) loop.run() + current_loops.append(get_running_loop().__class__.__name__) et = time.time() - t0 print(et) @@ -277,6 +295,8 @@ def test_run_loop_and_close_canvases(SomeLoop): assert canvas1._events.is_closed assert canvas2._events.is_closed + assert current_loops == ["NoneType", SomeLoop.__name__, "NoneType"], current_loops + @pytest.mark.parametrize("SomeLoop", loop_classes) def test_run_loop_and_close_by_loop_stop(SomeLoop): @@ -855,7 +875,6 @@ async def tester_coroutine(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_async_gens_cleanup_bad_agen(SomeLoop): # Same as last but not with a bad-behaving finalizer. - # This will log an error. g = None @@ -873,13 +892,14 @@ async def tester_coroutine(): loop.call_later(0.2, loop.stop) loop.run() - if SomeLoop in (AsyncioLoop,): + if SomeLoop in (AsyncioLoop, TrioLoop): # Handled properly ref_flag = ["started", "except GeneratorExit", "closed"] else: - # We accept to fail here with our async adapter. - # It looks like trio does too??? - ref_flag = ["started", "except GeneratorExit"] + # Actually, our adapter also works, because the sleep and Event + # become no-ops once the loop is gone, and since there are no other things + # one can wait on with our asyncadapter, we're good! + ref_flag = ["started", "except GeneratorExit", "closed"] assert flag == ref_flag, flag diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index cf29daad..d75469cf 100644 --- a/tests/test_offscreen.py +++ b/tests/test_offscreen.py @@ -7,6 +7,7 @@ import time import weakref +from rendercanvas import get_running_loop from testutils import is_pypy, run_tests @@ -78,6 +79,8 @@ def test_offscreen_event_loop(): def check(arg): ran.add(arg) + assert get_running_loop() is None + loop.call_soon(check, 1) loop.call_later(0, check, 2) loop.call_later(0.001, check, 3) @@ -86,6 +89,8 @@ def check(arg): assert 2 in ran # call_later with zero assert 3 not in ran + assert get_running_loop() is None + # When run is called, the task is started, so the delay kicks in from # that moment, so we need to wait here for the 3d to resolve # The delay starts from @@ -93,6 +98,8 @@ def check(arg): loop.run() assert 3 in ran # call_later nonzero + assert get_running_loop() is None + def test_offscreen_canvas_del(): from rendercanvas.offscreen import RenderCanvas From 75b771bb129dc4c7363bc072735611daa127dc6f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 12 Dec 2025 16:33:53 +0100 Subject: [PATCH 03/10] Add tests from cancelled pr #151 --- tests/test_sniffio.py | 131 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 tests/test_sniffio.py diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py new file mode 100644 index 00000000..bada567a --- /dev/null +++ b/tests/test_sniffio.py @@ -0,0 +1,131 @@ +""" +Test the behaviour of our asyncadapter w.r.t. sniffio. + +We want to make sure that it reports the running lib and loop correctly, +so that other code can use sniffio to get our loop and e.g. +call_soon_threadsafe, without actually knowing about rendercanvas, other +than that it's API is very similar to asyncio. +""" + + +# ruff: noqa: N803 + +import asyncio + +from testutils import run_tests +import rendercanvas +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas +from rendercanvas.asyncio import loop as asyncio_loop + +from rendercanvas.asyncio import AsyncioLoop +from rendercanvas.trio import TrioLoop +from rendercanvas.raw import RawLoop + +import sniffio +import pytest + + +class CanvasGroup(BaseCanvasGroup): + pass + + +class RealRenderCanvas(BaseRenderCanvas): + _rc_canvas_group = CanvasGroup(asyncio_loop) + _is_closed = False + + def _rc_close(self): + self._is_closed = True + self.submit_event({"event_type": "close"}) + + def _rc_get_closed(self): + return self._is_closed + + def _rc_request_draw(self): + loop = self._rc_canvas_group.get_loop() + loop.call_soon(self._draw_frame_and_present) + + +def get_sniffio_name(): + try: + return sniffio.current_async_library() + except sniffio.AsyncLibraryNotFoundError: + return None + + +def test_no_loop_running(): + assert get_sniffio_name() is None + assert rendercanvas.get_running_loop() is None + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_sniffio_on_loop(SomeLoop): + loop = SomeLoop() + + RealRenderCanvas.select_loop(loop) + + c = RealRenderCanvas() + + names = [] + funcs = [] + + @c.request_draw + def draw(): + name = get_sniffio_name() + names.append(("draw", name)) + + # Downstream code like wgpu-py can use this with sniffio + funcs.append(rendercanvas.get_running_loop().call_soon_threadsafe) + + @c.add_event_handler("*") + def on_event(event): + names.append((event["event_type"], get_sniffio_name())) + + async def task(): + names.append(("task", get_sniffio_name())) + + loop.add_task(task) + loop.call_later(0.3, c.close) + # loop.call_later(1.3, loop.stop) # failsafe + + loop.run() + + refname = "nope" + if SomeLoop is RawLoop: + refname = "rendercanvas.utils.asyncadapter" + elif SomeLoop is AsyncioLoop: + refname = "asyncio" + elif SomeLoop is TrioLoop: + refname = "trio" + + for key, val in names: + assert val == refname + + assert len(funcs) == 1 + for func in funcs: + assert callable(func) + + +def test_asyncio(): + # Just make sure that in a call_soon/call_later the get_running_loop stil works + + loop = asyncio.new_event_loop() + + running_loops = [] + + def set_current_loop(name): + running_loops.append((name, asyncio.get_running_loop())) + + loop.call_soon(set_current_loop, "call_soon") + loop.call_later(0.1, set_current_loop, "call_soon") + loop.call_soon(loop.call_soon_threadsafe, set_current_loop, "call_soon_threadsafe") + loop.call_later(0.2, loop.stop) + loop.run_forever() + + print(running_loops) + assert len(running_loops) == 3 + for name, running_loop in running_loops: + assert running_loop is loop + + +if __name__ == "__main__": + run_tests(globals()) From f15bc130f32b59a272df31d3faa8105747d1ffa3 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 12 Dec 2025 16:48:08 +0100 Subject: [PATCH 04/10] fix pyodide --- examples/cube_auto.py | 2 +- rendercanvas/utils/asyncs.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/cube_auto.py b/examples/cube_auto.py index f98e5d9a..15ebe5c6 100644 --- a/examples/cube_auto.py +++ b/examples/cube_auto.py @@ -7,7 +7,7 @@ # run_example = true -from rendercanvas.pyside6 import RenderCanvas, loop +from rendercanvas.auto import RenderCanvas, loop from rendercanvas.utils.cube import setup_drawing_sync canvas = RenderCanvas( diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 54ce5e5a..713add14 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -34,6 +34,8 @@ def detect_current_async_lib(): return None if libname == "rendercanvas": libname = "rendercanvas.utils.asyncadapter" + elif libname == "pyodide": + libname = "asyncio" return libname From 35147a5c08851dc07f431f85fafa53b5ef1c313d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 15 Dec 2025 13:42:39 +0100 Subject: [PATCH 05/10] fix tests --- rendercanvas/offscreen.py | 12 +++++++---- tests/test_loop.py | 6 +++++- tests/test_meta.py | 1 + tests/test_scheduling.py | 42 ++++++++------------------------------- 4 files changed, 22 insertions(+), 39 deletions(-) diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 846ba376..a77910a2 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -119,7 +119,6 @@ def draw(self): This object can be converted to a numpy array (without copying data) using ``np.asarray(arr)``. """ - loop.process_tasks() # Little trick to keep the event loop going self._draw_frame_and_present() return self._last_image @@ -151,7 +150,7 @@ def _rc_init(self): # This gets called when the first canvas is created (possibly after having run and stopped before). pass - def process_tasks(self): + def _process_tasks(self): callbacks_to_run = [] new_callbacks = [] for etime, callback in self._callbacks: @@ -165,10 +164,15 @@ def process_tasks(self): callback() def _rc_run(self): - self.process_tasks() + # Only process tasks inside the run method. While in side ``run()``, the + # loop state is 'running' and its the current loop. If we'd process + # tasks outside the run method, the loop-task triggers, putting the loop + # in the 'active' mode, making it the current loop, and it will stay + # active until it's explicitly stopped. + self._process_tasks() def _rc_stop(self): - pass + self._callbacks.clear() def _rc_add_task(self, async_func, name): super()._rc_add_task(async_func, name) diff --git a/tests/test_loop.py b/tests/test_loop.py index 782bc272..16fe8052 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -892,9 +892,13 @@ async def tester_coroutine(): loop.call_later(0.2, loop.stop) loop.run() - if SomeLoop in (AsyncioLoop, TrioLoop): + if SomeLoop is AsyncioLoop: # Handled properly ref_flag = ["started", "except GeneratorExit", "closed"] + elif SomeLoop is TrioLoop: + # Not handled correctly? It did at some point. + # Anyway, rather adversarial use-case, so I don't care too much. + ref_flag = ["started", "except GeneratorExit"] else: # Actually, our adapter also works, because the sleep and Event # become no-ops once the loop is gone, and since there are no other things diff --git a/tests/test_meta.py b/tests/test_meta.py index 00837f42..468bb831 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -76,6 +76,7 @@ def test_namespace(): @pytest.mark.skipif(sys.version_info < (3, 10), reason="Need py310+") def test_deps_plain_import(): modules = get_loaded_modules("rendercanvas", 1) + modules.discard("sniffio") # sniffio is imported if available assert modules == {"rendercanvas"} # Note, no wgpu diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 01153982..79a9dfca 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -1,49 +1,23 @@ """ -Test scheduling mechanics, by implememting a minimal canvas class to +Test scheduling mechanics, by implementing a minimal canvas class to implement drawing. This tests the basic scheduling mechanics, as well -as the behabior of the different update modes. +as the behavior of the different update modes. """ import time from testutils import run_tests -from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas, BaseLoop +from rendercanvas.base import BaseCanvasGroup, BaseRenderCanvas +from rendercanvas.offscreen import StubLoop class MyCanvasGroup(BaseCanvasGroup): pass -class MyLoop(BaseLoop): - def __init__(self): - super().__init__() - self.__stopped = False - self._callbacks = [] - - def process_tasks(self): - callbacks_to_run = [] - new_callbacks = [] - for etime, callback in self._callbacks: - if time.perf_counter() >= etime: - callbacks_to_run.append(callback) - else: - new_callbacks.append((etime, callback)) - if callbacks_to_run: - self._callbacks = new_callbacks - for callback in callbacks_to_run: - callback() - - def _rc_run(self): - self.__stopped = False - - def _rc_stop(self): - self.__stopped = True - - def _rc_add_task(self, async_func, name): - # Run tasks via call_later - super()._rc_add_task(async_func, name) +class MyLoop(StubLoop): + pass - def _rc_call_later(self, delay, callback): - self._callbacks.append((time.perf_counter() + delay, callback)) + # Note: run() is non-blocking and simply does one iteration to process pending tasks. class MyCanvas(BaseRenderCanvas): @@ -83,7 +57,7 @@ def active_sleep(self, delay): etime = time.perf_counter() + delay while time.perf_counter() < etime: time.sleep(0.001) - loop.process_tasks() + loop.run() self.draw_if_necessary() From e17c2b54b08fa1fa273adaa00bab4787df9f8b08 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 15 Dec 2025 14:35:12 +0100 Subject: [PATCH 06/10] Test for loop deletion --- rendercanvas/qt.py | 6 ++- rendercanvas/utils/asyncadapter.py | 8 +++- tests/test_loop.py | 59 ++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 6ce37237..a9a38c12 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -7,6 +7,7 @@ import sys import ctypes +import weakref import importlib @@ -234,7 +235,10 @@ def _rc_init(self): # when the app closes. So we explicitly detect the app-closing instead. # Note that we should not use app.setQuitOnLastWindowClosed(False), because we (may) rely on the # application's closing mechanic. - self._app.aboutToQuit.connect(lambda: self.stop(force=True)) + loop_ref = weakref.ref(self) + self._app.aboutToQuit.connect( + lambda: (loop := loop_ref()) and loop.stop(force=True) + ) if already_had_app_on_import: self._mark_as_interactive() self._callback_pool = set() diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index 4801fad7..c68c3986 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -3,6 +3,7 @@ Intended for internal use, but is fully standalone. """ +import weakref import logging import threading @@ -73,6 +74,11 @@ def __init__(self, call_later_func, coro, name): self.name = name self.running = False self.cancelled = False + + # Trick to get a callback function that does not hold a ref to the task, and therefore not the loop (via the loop-task coro). + task_ref = weakref.ref(self) + self._step_cb = lambda: (task := task_ref()) and task.step() + self.call_step_later(0) def add_done_callback(self, callback): @@ -88,7 +94,7 @@ def _close(self): self._done_callbacks.clear() def call_step_later(self, delay): - self._call_later(delay, self.step) + self._call_later(delay, self._step_cb) def cancel(self): self.cancelled = True diff --git a/tests/test_loop.py b/tests/test_loop.py index 16fe8052..83f47e26 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -16,6 +16,7 @@ import sys import time import signal +import weakref import asyncio import threading @@ -162,6 +163,64 @@ def _rc_request_draw(self): # %%%%% running and closing +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_deletion1(SomeLoop): + # Loops get gc'd when instantiated but not used. + + loop = SomeLoop() + + loop_ref = weakref.ref(loop) + del loop + gc.collect() + gc.collect() + + assert loop_ref() is None + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_deletion2(SomeLoop): + # Loops get gc'd when in ready state + + async def foo(): + pass + + loop = SomeLoop() + loop.add_task(foo) + assert "ready" in repr(loop) + + loop_ref = weakref.ref(loop) + del loop + for _ in range(4): + time.sleep(0.01) + gc.collect() + + assert loop_ref() is None + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_deletion3(SomeLoop): + # Loops get gc'd when closed after use + + flag = [] + + async def foo(): + flag.append(True) + + loop = SomeLoop() + loop.add_task(foo) + assert "ready" in repr(loop) + loop.run() + assert flag == [True] + + loop_ref = weakref.ref(loop) + del loop + for _ in range(4): + time.sleep(0.01) + gc.collect() + + assert loop_ref() is None + + @pytest.mark.parametrize("SomeLoop", loop_classes) def test_run_loop_and_close_bc_no_canvases(SomeLoop): # Run the loop without canvas; closes immediately From 21eb55d920e773afeb599daf62bcba51419a4284 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 16 Dec 2025 09:45:15 +0100 Subject: [PATCH 07/10] comments --- rendercanvas/_loop.py | 68 ++++++++++++++++++++++++++++++------------- tests/test_loop.py | 4 +++ 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 244dbb96..958aa6d3 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -43,39 +43,67 @@ def get_running_loop(): """ return getattr(thread_local, "loop", None) + # Note: if a loop is run while another is running, this is actually allowed, and the + # new loop object is responsible for resetting the old current loop object. + # When this all happens via ``loop.run()``, it's pretty solid. But when this involves loops + # become active outside of ``run()``, like Qt or interactive asyncio, this *can* get messy if + # the loop stops while an "inner loop" is running. Afaik, neither Qt nor asyncio does this. + # And remember that in practice users typically import one loop and run it once ... + class BaseLoop: """The base class for an event-loop object. + The rendercanvas ``loop`` object is a proxy to a real event-loop. It abstracts away methods like ``run()``, + ``call_later``, ``call_soon_threadsafe()``, and more. + Canvas backends can implement their own loop subclass (like qt and wx do), but a canvas backend can also rely on one of multiple loop implementations (like glfw running on asyncio or trio). - The lifecycle states of a loop are: - - * off: the initial state, the subclass should probably not even import dependencies yet. - * ready: the first canvas is created, ``_rc_init()`` is called to get the loop ready for running. - * active: the loop is active (we detect it because our task is running), but we don't know how. - * interactive: the loop is inter-active in e.g. an IDE, reported by the backend. - * running: the loop is running via ``_rc_run()`` or ``_rc_run_async()``. - - Notes: + In the majority of use-cases, users don't need to know much about the loop. It will typically + run once. In more complex scenario's the section below explains the working of the loop in more detail. + + **Details about loop lifetime** + + The rendercanvas loop object is a proxy, which has to support a variety of backends. + To realize this, it has the following lifetime model: + + * off: + * Entered when the loop is instantiated, and when the loop has stopped. + * This is the 'idle' state. + * The backend probably has not even imported dependencies yet. + * ready: + * Entered when the first canvas is created that is associated with this loop, or when a task is added. + * It is assumed that the loop will become active soon. + * This is when ``_rc_init()`` is called to get the backend ready for running. + * A special 'loop-task' is created (a coroutine, which is not yet running). + * running: + * Entered when ``loop.run()`` is called. + * The loop is now running. + * Signal handlers and asyncgen hooks are installed if applicable. + * This is now the current loop (see ``get_running_loop()``). + * interactive: + * Entered in ``_rc_init()`` when the backend detects that the loop is interactive. + * Example use-cases are a notebook or interactive IDE, usually via asyncio. + * This means there is a persistent native loop already running, which rendercanvas makes use of. + * active: + * Entered when the backend-loop starts running, but not via the loop's ``run()`` method. + * This is detected via the loop-task. + * Signal handlers and asyncgen hooks are installed if applicable. + * Detecting loop stopping occurs by the loop-task being cancelled. + + Notes related to starting and stopping: * The loop goes back to the "off" state once all canvases are closed. * Stopping the loop (via ``.stop()``) closes the canvases, which will then stop the loop. * From there it can go back to the ready state (which would call ``_rc_init()`` again). * In backends like Qt, the native loop can be started without us knowing: state "active". * In interactive settings like an IDE that runs an asyncio or Qt loop, the - loop can become "active" as soon as the first canvas is created. - - The lifecycle of this loop does not necessarily co-inside with the native loop's cycle: - - * The rendercanvas loop can be in the 'off' state while the native loop is running. - * When we stop the loop, the native loop likely runs slightly longer. - * When the loop is interactive (asyncio or Qt) the native loop keeps running when rendercanvas' loop stops. - * For async loops (asyncio or trio), the native loop may run before and after this loop. - * On Qt, we detect the app's aboutToQuit to stop this loop. - * On wx, we detect all windows closed to stop this loop. + loop becomes "interactive" as soon as the first canvas is created. + * The rendercanvas loop can be in the 'off' state while the native loop is running (especially for the 'interactive' case). + * On Qt, the app's 'aboutToQuit' signal is used to stop this loop. + * On wx, the loop is stopped when all windows are closed. """ @@ -360,7 +388,7 @@ async def run_async(self) -> None: def stop(self, *, force=False) -> None: """Close all windows and stop the currently running event-loop. - If the loop is active but not running via our ``run()`` method, the loop + If the loop is active but not running via the ``run()`` method, the loop moves back to its off-state, but the underlying loop is not stopped. Normally, the windows are closed and the underlying event loop is given diff --git a/tests/test_loop.py b/tests/test_loop.py index 83f47e26..83b72e72 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -8,6 +8,10 @@ * Run "pytest -k PySide6Loop" etc. * Run "python tests/test_loop.py WxLoop" etc. + +Note that in here we create *a lot* of different kind of loop objects. +In practice though, an application will use (and even import) a single +loop object and run it one time for the duration of the application. """ # ruff: noqa: N803 From d1ea59b7af7014de3c252ed2293fe68f26410453 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 16 Dec 2025 12:15:40 +0100 Subject: [PATCH 08/10] If async hooks are a method of loop, looking up the loop is much easier --- rendercanvas/_loop.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 958aa6d3..dfdf9bee 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -546,23 +546,21 @@ def __setup_asyncgen_hooks(self): if not self.__using_adapter: return None - asyncgens = self._asyncgens - - def asyncgen_firstiter_hook(agen): - asyncgens.add(agen) - - def asyncgen_finalizer_hook(agen): - asyncgens.discard(agen) - close_agen(agen) - prev_asyncgen_hooks = sys.get_asyncgen_hooks() sys.set_asyncgen_hooks( - firstiter=asyncgen_firstiter_hook, - finalizer=asyncgen_finalizer_hook, + firstiter=self._asyncgen_firstiter_hook, + finalizer=self._asyncgen_finalizer_hook, ) return prev_asyncgen_hooks + def _asyncgen_firstiter_hook(self, agen): + self._asyncgens.add(agen) + + def _asyncgen_finalizer_hook(self, agen): + self._asyncgens.discard(agen) + close_agen(agen) + def _rc_init(self): """Put the loop in a ready state. From 39c1e1b903633436b4c34a18e5d98e1899a44053 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 16 Dec 2025 13:25:05 +0100 Subject: [PATCH 09/10] Remove get_running_loop again --- rendercanvas/__init__.py | 10 +---- rendercanvas/_coreutils.py | 5 --- rendercanvas/_loop.py | 38 ++---------------- rendercanvas/utils/asyncs.py | 77 ++++++++++++++++++++++++++++-------- tests/test_loop.py | 59 +++++++++++++++++++++------ tests/test_offscreen.py | 7 ---- tests/test_sniffio.py | 13 +----- 7 files changed, 114 insertions(+), 95 deletions(-) diff --git a/rendercanvas/__init__.py b/rendercanvas/__init__.py index 5897235a..d619902b 100644 --- a/rendercanvas/__init__.py +++ b/rendercanvas/__init__.py @@ -8,16 +8,8 @@ from . import _coreutils from ._enums import CursorShape, EventType, UpdateMode from .base import BaseRenderCanvas, BaseLoop -from ._loop import get_running_loop from . import contexts from . import utils -__all__ = [ - "BaseLoop", - "BaseRenderCanvas", - "CursorShape", - "EventType", - "UpdateMode", - "get_running_loop", -] +__all__ = ["BaseLoop", "BaseRenderCanvas", "CursorShape", "EventType", "UpdateMode"] diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 78d5e6b2..ccba8132 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -21,11 +21,6 @@ IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide -# %% One place to store thread-local info (like the current loop object) - -thread_local = threading.local() - - # %% Logging diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index dfdf9bee..f409aee4 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -11,13 +11,7 @@ from typing import TYPE_CHECKING from ._enums import LoopState -from ._coreutils import ( - logger, - log_exception, - call_later_from_thread, - close_agen, - thread_local, -) +from ._coreutils import logger, log_exception, call_later_from_thread, close_agen from .utils.asyncs import sleep from .utils import asyncadapter @@ -34,23 +28,6 @@ ) -def get_running_loop(): - """Get the running loop (for the current thread) or None. - - This method is intended to obtain the currently running rendercanvas - loop object. To detect what async event-loop is actually running, - it's recommended to check ``sys.get_asyncgen_hooks`` or use ``sniffio``. - """ - return getattr(thread_local, "loop", None) - - # Note: if a loop is run while another is running, this is actually allowed, and the - # new loop object is responsible for resetting the old current loop object. - # When this all happens via ``loop.run()``, it's pretty solid. But when this involves loops - # become active outside of ``run()``, like Qt or interactive asyncio, this *can* get messy if - # the loop stops while an "inner loop" is running. Afaik, neither Qt nor asyncio does this. - # And remember that in practice users typically import one loop and run it once ... - - class BaseLoop: """The base class for an event-loop object. @@ -82,7 +59,6 @@ class BaseLoop: * Entered when ``loop.run()`` is called. * The loop is now running. * Signal handlers and asyncgen hooks are installed if applicable. - * This is now the current loop (see ``get_running_loop()``). * interactive: * Entered in ``_rc_init()`` when the backend detects that the loop is interactive. * Example use-cases are a notebook or interactive IDE, usually via asyncio. @@ -420,21 +396,17 @@ def stop(self, *, force=False) -> None: self.__stop() def __setup_hooks(self): - """Activate current loop, setup asycgen hooks, and setup interrupts.""" + """Setup asycgen hooks and interrupt hooks.""" if self.__hook_data is not None: return False - # Set the running loop - prev_loop = get_running_loop() - thread_local.loop = self - # Setup asyncgen hooks prev_asyncgen_hooks = self.__setup_asyncgen_hooks() # Set interrupts prev_interrupt_hooks = self.__setup_interrupt_hooks() - self.__hook_data = prev_loop, prev_asyncgen_hooks, prev_interrupt_hooks + self.__hook_data = prev_asyncgen_hooks, prev_interrupt_hooks return True def __restore_hooks(self): @@ -448,11 +420,9 @@ def __restore_hooks(self): if self.__hook_data is None: return - prev_loop, prev_asyncgen_hooks, prev_interrupt_hooks = self.__hook_data + prev_asyncgen_hooks, prev_interrupt_hooks = self.__hook_data self.__hook_data = None - thread_local.loop = prev_loop - if prev_asyncgen_hooks is not None: sys.set_asyncgen_hooks(*prev_asyncgen_hooks) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 713add14..59b0506e 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -12,20 +12,18 @@ import sys -from .._coreutils import IS_WIN, call_later_from_thread, thread_local +from .._coreutils import IS_WIN, call_later_from_thread USE_THREADED_TIMER = IS_WIN -def detect_current_async_lib(): - """Get the lib name of the currently active async lib, or None. +# Below detection methods use ``sys.get_asyncgen_hooks()`` for fast and robust detection. +# Compared to sniffio, this is faster and also works when not inside a task. - This uses ``sys.get_asyncgen_hooks()`` for fast and robust detection. - Compared to sniffio, this is faster and also works when not inside a task. - Compared to ``rendercanvas.get_running_loop()`` this also works when asyncio - is running while the rendercanvas loop is not. - """ + +def detect_current_async_lib(): + """Get the lib name of the currently active async lib, or None.""" ob = sys.get_asyncgen_hooks()[0] if ob is not None: try: @@ -39,23 +37,68 @@ def detect_current_async_lib(): return libname +def detect_current_call_soon_threadsafe(): + """Get the current applicable call_soon_threadsafe function, or None""" + + # Get asyncgen hook func, return fast when no async loop active + ob = sys.get_asyncgen_hooks()[0] + if ob is None: + return None + + # Super-fast path that works for loop objects that have call_soon_threadsafe() + # and use sys.set_asyncgen_hooks() on a method of the same loop object. + # Works with asyncio, rendercanvas' asyncadapter, and also custom (direct) loops. + try: + return ob.__self__.call_soon_threadsafe + except AttributeError: + pass + + # Otherwise, checkout the module name + try: + libname = ob.__module__.partition(".")[0] + except AttributeError: + return None + + if libname == "trio": + # Still pretty fast for trio + trio = sys.modules[libname] + token = trio.lowlevel.current_trio_token() + return token.run_sync_soon + else: + # Ok, it looks like there is an async loop, try to get the func. + # This is also a fallback for asyncio (in case the ob.__self__ stops working) + # Note: we have a unit test for the asyncio fast-path, so we will know when we need to update, + # but the code below makes sure that it keeps working regardless (just a tiiiny bit slower). + if libname == "pyodide": + libname = "asyncio" + mod = sys.modules.get(libname, None) + if mod is None: + return None + try: + return mod.call_soon_threadsafe + except AttributeError: + pass + try: + return mod.get_running_loop().call_soon_threadsafe + except Exception: # (RuntimeError, AttributeError) but accept any error + pass + + async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native. On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the ``sleep()`` of asyncio/trio. """ - rc_loop = getattr(thread_local, "loop", None) # == get_running_loop - - if delay > 0 and USE_THREADED_TIMER and rc_loop is not None: - event = Event() - call_later_from_thread(delay, rc_loop.call_soon_threadsafe, event.set) - await event.wait() + if delay > 0 and USE_THREADED_TIMER: + call_soon_threadsafe = detect_current_call_soon_threadsafe() + if call_soon_threadsafe: + event = Event() + call_later_from_thread(delay, call_soon_threadsafe, event.set) + await event.wait() else: libname = detect_current_async_lib() - if libname is None: - return # the loop is gone, we're probably cleaning up - else: + if libname is not None: sleep = sys.modules[libname].sleep await sleep(delay) diff --git a/tests/test_loop.py b/tests/test_loop.py index 83b72e72..e4413bf4 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -28,7 +28,6 @@ from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop from rendercanvas.raw import RawLoop -from rendercanvas import get_running_loop from rendercanvas.utils.asyncs import sleep as async_sleep @@ -164,7 +163,7 @@ def _rc_request_draw(self): loop.call_soon(self._draw_frame_and_present) -# %%%%% running and closing +# %%%%% deleting loops @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -225,6 +224,53 @@ async def foo(): assert loop_ref() is None +# %%%%% loop detection + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_loop_detection(SomeLoop): + from rendercanvas.utils.asyncs import ( + detect_current_async_lib, + detect_current_call_soon_threadsafe, + ) + + loop = SomeLoop() + + flag = [] + + async def task(): + # Our methods + flag.append(detect_current_async_lib()) + flag.append(detect_current_call_soon_threadsafe()) + # Test that the fast-path works + if SomeLoop is not TrioLoop: + flag.append(sys.get_asyncgen_hooks()[0].__self__.call_soon_threadsafe) + loop.stop() + + loop.add_task(task) + loop.run() + + if SomeLoop is AsyncioLoop: + assert flag[0] == "asyncio" + assert callable(flag[1]) + assert flag[1].__name__ == "call_soon_threadsafe" + assert flag[1].__func__ is flag[2].__func__ + # !! here we double-check that the fast-path for loop detection works for asyncio + elif SomeLoop is TrioLoop: + assert flag[0] == "trio" + assert callable(flag[1]) + assert flag[1].__name__ == "run_sync_soon" + else: + # RawLoop or QtLoop + assert flag[0] == "rendercanvas.utils.asyncadapter" + assert callable(flag[1]) + assert flag[1].__name__ == "call_soon_threadsafe" + assert flag[1].__func__ is flag[2].__func__ + + +# %%%%% running and closing + + @pytest.mark.parametrize("SomeLoop", loop_classes) def test_run_loop_and_close_bc_no_canvases(SomeLoop): # Run the loop without canvas; closes immediately @@ -330,8 +376,6 @@ def test_run_loop_without_canvases(SomeLoop): def test_run_loop_and_close_canvases(SomeLoop): # After all canvases are closed, it can take one tick before its detected. - current_loops = [] - loop = SomeLoop() group = CanvasGroup(loop) @@ -340,16 +384,11 @@ def test_run_loop_and_close_canvases(SomeLoop): group._register_canvas(canvas1, fake_task) group._register_canvas(canvas2, fake_task) - loop.call_later( - 0.1, lambda: current_loops.append(get_running_loop().__class__.__name__) - ) loop.call_later(0.1, canvas1.manually_close) loop.call_later(0.3, canvas2.manually_close) t0 = time.time() - current_loops.append(get_running_loop().__class__.__name__) loop.run() - current_loops.append(get_running_loop().__class__.__name__) et = time.time() - t0 print(et) @@ -358,8 +397,6 @@ def test_run_loop_and_close_canvases(SomeLoop): assert canvas1._events.is_closed assert canvas2._events.is_closed - assert current_loops == ["NoneType", SomeLoop.__name__, "NoneType"], current_loops - @pytest.mark.parametrize("SomeLoop", loop_classes) def test_run_loop_and_close_by_loop_stop(SomeLoop): diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index d75469cf..cf29daad 100644 --- a/tests/test_offscreen.py +++ b/tests/test_offscreen.py @@ -7,7 +7,6 @@ import time import weakref -from rendercanvas import get_running_loop from testutils import is_pypy, run_tests @@ -79,8 +78,6 @@ def test_offscreen_event_loop(): def check(arg): ran.add(arg) - assert get_running_loop() is None - loop.call_soon(check, 1) loop.call_later(0, check, 2) loop.call_later(0.001, check, 3) @@ -89,8 +86,6 @@ def check(arg): assert 2 in ran # call_later with zero assert 3 not in ran - assert get_running_loop() is None - # When run is called, the task is started, so the delay kicks in from # that moment, so we need to wait here for the 3d to resolve # The delay starts from @@ -98,8 +93,6 @@ def check(arg): loop.run() assert 3 in ran # call_later nonzero - assert get_running_loop() is None - def test_offscreen_canvas_del(): from rendercanvas.offscreen import RenderCanvas diff --git a/tests/test_sniffio.py b/tests/test_sniffio.py index bada567a..c3cb6814 100644 --- a/tests/test_sniffio.py +++ b/tests/test_sniffio.py @@ -1,10 +1,5 @@ """ Test the behaviour of our asyncadapter w.r.t. sniffio. - -We want to make sure that it reports the running lib and loop correctly, -so that other code can use sniffio to get our loop and e.g. -call_soon_threadsafe, without actually knowing about rendercanvas, other -than that it's API is very similar to asyncio. """ @@ -52,11 +47,6 @@ def get_sniffio_name(): return None -def test_no_loop_running(): - assert get_sniffio_name() is None - assert rendercanvas.get_running_loop() is None - - @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_sniffio_on_loop(SomeLoop): loop = SomeLoop() @@ -73,8 +63,7 @@ def draw(): name = get_sniffio_name() names.append(("draw", name)) - # Downstream code like wgpu-py can use this with sniffio - funcs.append(rendercanvas.get_running_loop().call_soon_threadsafe) + funcs.append(rendercanvas.utils.asyncs.detect_current_call_soon_threadsafe()) @c.add_event_handler("*") def on_event(event): From cec8db2d60ebf25b841508dbad415b7950f84519 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 17 Dec 2025 09:13:24 +0100 Subject: [PATCH 10/10] textual tweaks --- rendercanvas/_loop.py | 2 +- rendercanvas/offscreen.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index f409aee4..6221ed5a 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -573,7 +573,7 @@ def _rc_stop(self): raise NotImplementedError() def _rc_add_task(self, async_func, name): - """Add an async task to the running loop. + """Add an async task to this loop. True async loop-backends (like asyncio and trio) should implement this. When they do, ``_rc_call_later`` is not used. diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index a77910a2..3a40d312 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -164,11 +164,11 @@ def _process_tasks(self): callback() def _rc_run(self): - # Only process tasks inside the run method. While in side ``run()``, the + # Only process tasks inside the run method. While inside ``run()``, the # loop state is 'running' and its the current loop. If we'd process # tasks outside the run method, the loop-task triggers, putting the loop - # in the 'active' mode, making it the current loop, and it will stay - # active until it's explicitly stopped. + # in the 'active' mode, making it the current loop (via asyncgen hooks), + # and it will stay active until it's explicitly stopped. self._process_tasks() def _rc_stop(self):