From 95fabd4cbb7cbdbcffffd1963134cf4edb36fbc4 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 4 Dec 2025 12:38:23 +0100 Subject: [PATCH 1/8] Add tests for async generator cleanup --- rendercanvas/_loop.py | 15 +++++++ rendercanvas/asyncio.py | 4 +- tests/test_loop.py | 96 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index cba3023e..1f9977df 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -5,6 +5,7 @@ from __future__ import annotations import signal +import weakref from inspect import iscoroutinefunction from typing import TYPE_CHECKING @@ -58,6 +59,7 @@ def __init__(self): self.__state = ( 0 # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running ) + self._asyncgens = weakref.WeakSet() def __repr__(self): full_class_name = f"{self.__class__.__module__}.{self.__class__.__name__}" @@ -105,6 +107,18 @@ async def _loop_task(self): # Keep track of event emitter objects event_emitters = {id(c): c._events for c in self.get_canvases()} + # def init(gen): + # print("init gen", gen) + + # def fin(gen): + # print("fin gen", gen) + + # print("in loop task", self._using_adapter) + # import sys + + # old_agen_hooks = sys.get_asyncgen_hooks() + # sys.set_asyncgen_hooks(init, fin) + try: while True: await sleep(0.1) @@ -151,6 +165,7 @@ async def _loop_task(self): del canvas finally: + # sys.set_asyncgen_hooks(*old_agen_hooks) -> move into __stop self.__stop() def add_task( diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index b8206f32..d837e467 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -42,7 +42,7 @@ def _rc_run(self): async def _rc_run_async(self): import asyncio - # Protect agsinst usage of wrong loop object + # Protect against usage of wrong loop object libname = sniffio.current_async_library() if libname != "asyncio": raise TypeError(f"Attempt to run AsyncioLoop with {libname}.") @@ -61,7 +61,7 @@ async def _rc_run_async(self): # Create tasks if necessay while self.__pending_tasks: - self._rc_add_task(*self.__pending_tasks.pop(-1)) + self._rc_add_task(*self.__pending_tasks.pop(0)) # Wait for loop to finish if self._stop_event is None: diff --git a/tests/test_loop.py b/tests/test_loop.py index f1884754..dd02c177 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -383,5 +383,101 @@ def test_async_loops_check_lib(): trio.run(trio_loop.run_async) +async def a_generator(flag): + flag.append("started") + try: + for i in range(10): + await async_sleep(0) # yield back to the loop + yield i + except BaseException as err: + flag.append(f"except {err.__class__.__name__}") + raise + else: + flag.append("finished") + finally: + flag.append("closed") + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_async_gens_cleanup0(SomeLoop): + # Don't even start the generator + + async def tester_coroutine(): + _g = a_generator(flag) + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + loop.call_later(0.1, loop.stop) + loop.run() + + assert flag == [], flag + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_async_gens_cleanup1(SomeLoop): + # Run the generator to completion + + async def tester_coroutine(): + g = a_generator(flag) + async for i in g: + pass + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + loop.call_later(0.1, loop.stop) + loop.run() + + assert flag == ["started", "finished", "closed"], flag + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_async_gens_cleanup2(SomeLoop): + # Break out of the generator, leaving it in a pending state + + async def tester_coroutine(): + g = a_generator(flag) + # await async_sleep(0) + async for i in g: + if i > 2: + break + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + loop.call_later(0.1, loop.stop) + loop.run() + + assert flag == ["started", "except GeneratorExit", "closed"], flag + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_async_gens_cleanup3(SomeLoop): + # Break out of the generator, with one extra sleep. + # This made a difference in the outcome at some point. + + async def tester_coroutine(): + g = a_generator(flag) + await async_sleep(0) + async for i in g: + if i > 2: + break + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + loop.call_later(0.1, loop.stop) + loop.run() + + # Ok, this is stupid. This breaks it for asyncio too!! + # I am not able to create a test that + + assert flag == ["started", "except GeneratorExit", "closed"], flag + # assert flag == ["started"] + + if __name__ == "__main__": run_tests(globals()) + # test_async_gens_cleanup3(AsyncioLoop) + # test_async_gens_cleanup3(RawLoop) From 181658f46c020380b152f95049a83652285ca90b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 4 Dec 2025 13:09:08 +0100 Subject: [PATCH 2/8] aha, I found the test to trigger the problem --- tests/test_loop.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/tests/test_loop.py b/tests/test_loop.py index dd02c177..e2e58d96 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -386,8 +386,8 @@ def test_async_loops_check_lib(): async def a_generator(flag): flag.append("started") try: - for i in range(10): - await async_sleep(0) # yield back to the loop + for i in range(4): + await async_sleep(0.01) # yield back to the loop yield i except BaseException as err: flag.append(f"except {err.__class__.__name__}") @@ -400,7 +400,8 @@ async def a_generator(flag): @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_async_gens_cleanup0(SomeLoop): - # Don't even start the generator + # Don't even start the generator. + # Just works, because code of generator has not stated running. async def tester_coroutine(): _g = a_generator(flag) @@ -408,7 +409,7 @@ async def tester_coroutine(): flag = [] loop = SomeLoop() loop.add_task(tester_coroutine) - loop.call_later(0.1, loop.stop) + loop.call_later(0.2, loop.stop) loop.run() assert flag == [], flag @@ -416,7 +417,8 @@ async def tester_coroutine(): @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_async_gens_cleanup1(SomeLoop): - # Run the generator to completion + # Run the generator to completion. + # Just works, because code of generator is done. async def tester_coroutine(): g = a_generator(flag) @@ -426,7 +428,7 @@ async def tester_coroutine(): flag = [] loop = SomeLoop() loop.add_task(tester_coroutine) - loop.call_later(0.1, loop.stop) + loop.call_later(0.2, loop.stop) loop.run() assert flag == ["started", "finished", "closed"], flag @@ -434,32 +436,37 @@ async def tester_coroutine(): @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_async_gens_cleanup2(SomeLoop): - # Break out of the generator, leaving it in a pending state + # Break out of the generator, leaving it in a pending state. + # Just works, because gen.aclose() is called from gen.__del__ somehow? async def tester_coroutine(): g = a_generator(flag) - # await async_sleep(0) + # await async_sleep(0) # this sleep made a difference at some point async for i in g: - if i > 2: + if i > 1: break flag = [] loop = SomeLoop() loop.add_task(tester_coroutine) - loop.call_later(0.1, loop.stop) + loop.call_later(0.2, loop.stop) loop.run() assert flag == ["started", "except GeneratorExit", "closed"], flag + @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_async_gens_cleanup3(SomeLoop): - # Break out of the generator, with one extra sleep. - # This made a difference in the outcome at some point. + # Break out of the generator, but hold a ref to the generator. + # For this case we need sys.set_asyncgen_hooks(). + + g = None async def tester_coroutine(): + nonlocal g g = a_generator(flag) - await async_sleep(0) + # await async_sleep(0) async for i in g: if i > 2: break @@ -467,17 +474,11 @@ async def tester_coroutine(): flag = [] loop = SomeLoop() loop.add_task(tester_coroutine) - loop.call_later(0.1, loop.stop) + loop.call_later(0.2, loop.stop) loop.run() - # Ok, this is stupid. This breaks it for asyncio too!! - # I am not able to create a test that - assert flag == ["started", "except GeneratorExit", "closed"], flag - # assert flag == ["started"] if __name__ == "__main__": run_tests(globals()) - # test_async_gens_cleanup3(AsyncioLoop) - # test_async_gens_cleanup3(RawLoop) From 67a9e72303b6bf6d6f2a06c5c9094aae6ff0a605 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 5 Dec 2025 22:20:42 +0100 Subject: [PATCH 3/8] A day's worth of improving the loop lifecycle --- rendercanvas/_loop.py | 273 ++++++++++++++++++----------- rendercanvas/asyncio.py | 4 +- rendercanvas/base.py | 18 +- rendercanvas/glfw.py | 8 +- rendercanvas/offscreen.py | 6 +- rendercanvas/qt.py | 18 +- rendercanvas/raw.py | 4 +- rendercanvas/trio.py | 1 + rendercanvas/utils/asyncadapter.py | 8 +- rendercanvas/wx.py | 39 ++--- tests/test_loop.py | 116 +++++++++++- 11 files changed, 351 insertions(+), 144 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 1f9977df..7710eb72 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -37,136 +37,157 @@ class BaseLoop: * off (0): the initial state, the subclass should probably not even import dependencies yet. * ready (1): the first canvas is created, ``_rc_init()`` is called to get the loop ready for running. - * active (2): the loop is active, but not running via our entrypoints. - * active (3): the loop is inter-active in e.g. an IDE. + * active (2): the loop is active (we detect it because our task is running), but we don't know how. + * active (3): the loop is inter-active in e.g. an IDE, reported by the backend. * running (4): the loop is running via ``_rc_run()`` or ``_rc_run_async()``. Notes: - * The loop goes back to the "off" state after all canvases are closed. + * 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. + """ def __init__(self): self.__tasks = set() self.__canvas_groups = set() self.__should_stop = 0 - self.__state = ( - 0 # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running - ) + # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running + self.__state = 0 + self.__is_initialized = False self._asyncgens = weakref.WeakSet() + # self._setup_debug_thread() + + def _setup_debug_thread(self): + # Super-useful to track the loop's lifetime while running various examples / use-cases. + + import threading, time # noqa + + def thread(): + state = self.__state + print(f"loop state: {state}") + while True: + time.sleep(0.01) + cur_state = self.__state + if cur_state != state: + state = cur_state + print(f"loop state: {state}") + if state == 0: + print("bye") + + self._debug_thread = threading.Thread(target=thread) + self._debug_thread.start() def __repr__(self): full_class_name = f"{self.__class__.__module__}.{self.__class__.__name__}" state = self.__state - state_str = ["off", "ready", "active", "active", "running"][state] + statemap = {0: "off", 1: "ready", 2: "active", 3: "active", 4: "running"} + state_str = statemap.get(state, str(state)) return f"<{full_class_name} '{state_str}' ({state}) at {hex(id(self))}>" def _mark_as_interactive(self): - """For subclasses to set active from ``_rc_init()``""" + # For subclasses to set active from ``_rc_init()`` If the loop is + # interactive, run() becomes a no-op. The stop() will still close all + # canvases, but the backend loop should keep running. if self.__state in (1, 2): self.__state = 3 def _register_canvas_group(self, canvas_group): # A CanvasGroup will call this every time that a new canvas is created for this loop. # So now is also a good time to initialize. - if self.__state == 0: - self.__state = 1 - self._rc_init() - self.add_task(self._loop_task, name="loop-task") + self._ensure_initialized() self.__canvas_groups.add(canvas_group) def _unregister_canvas_group(self, canvas_group): # A CanvasGroup will call this when it selects a different loop. self.__canvas_groups.discard(canvas_group) - def get_canvases(self) -> list[BaseRenderCanvas]: + def get_canvases(self, *, close_closed=False) -> list[BaseRenderCanvas]: """Get a list of currently active (not-closed) canvases.""" canvases = [] for canvas_group in self.__canvas_groups: - canvases += canvas_group.get_canvases() + canvases += canvas_group.get_canvases(close_closed=close_closed) return canvases - async def _loop_task(self): - # This task has multiple purposes: - # - # * Detect closed windows. Relying on the backend alone is tricky, since the - # loop usually stops when the last window is closed, so the close event may - # not be fired. - # * Keep the GUI going even when the canvas loop is on pause e.g. because its - # minimized (applies to backends that implement _rc_gui_poll). - - # Detect active loop - self.__state = max(self.__state, 2) - - # Keep track of event emitter objects - event_emitters = {id(c): c._events for c in self.get_canvases()} + def _ensure_initialized(self): + """Make sure that the loop is ready to run.""" + if self.__is_initialized: + return - # def init(gen): - # print("init gen", gen) + if self.__state == 0: + self.__state = 1 - # def fin(gen): - # print("fin gen", gen) + async def wrapper(): + try: + with log_exception("Error in loop-task:"): + await self._loop_task() + finally: + # We get here when the task is finished or cancelled. + self.__is_initialized = False - # print("in loop task", self._using_adapter) - # import sys + self.__is_initialized = True + self._rc_init() + self._rc_add_task(wrapper, "loop-task") - # old_agen_hooks = sys.get_asyncgen_hooks() - # sys.set_asyncgen_hooks(init, fin) + async def _loop_task(self): + # This task has multiple purposes: + # + # * Detect when the the loop starts running. When this code runs, it + # means something is running the task. + # * Detect closed windows while the loop is running. This is nice, + # because it means backends only have to mark the canvas as closed, + # and the base canvas takes care that .close() is called and the close + # event is emitted. + # * Stop the loop when there are no more canvases. Note that the loop + # may also be stopped from the outside, in which case *this* task is + # cancelled along with the other tasks. + # * Detect when the loop stops running, in case the native loop stops in + # a friendly way, cancelling tasks, including *this* task. + # * Keep the GUI going even when the canvas loop is on pause e.g. + # because its minimized (applies to backends that implement + # _rc_gui_poll). + + # The loop has started! + self.__start() try: while True: await sleep(0.1) - # Get list of canvases, beware to delete the list when we're done with it! - canvases = self.get_canvases() - - # Send close event for closed canvases - new_event_emitters = {id(c): c._events for c in canvases} - closed_canvas_ids = set(event_emitters) - set(new_event_emitters) - for canvas_id in closed_canvas_ids: - events = event_emitters[canvas_id] - events.close() + # Note that this triggers .close() on closed canvases, for proper cleanup and sending close event. + canvases = self.get_canvases(close_closed=True) # Keep canvases alive for canvas in canvases: canvas._rc_gui_poll() del canvas + # Break? canvas_count = len(canvases) del canvases - - # Should we stop? - - if canvas_count == 0: - # Stop when there are no more canvases + if not canvas_count: break - elif self.__should_stop >= 2: - # Force a stop without waiting for the canvases to close. - # We could call event.close() for the remaining canvases, but technically they have not closed. - # Since this case is considered a failure, better be honest than consistent, I think. - break - elif self.__should_stop: - # Close all remaining canvases. Loop will stop in a next iteration. - # We store a flag on the canvas, that we only use here. - for canvas in self.get_canvases(): - try: - closed_by_loop = canvas._rc_closed_by_loop # type: ignore - except AttributeError: - closed_by_loop = False - if not closed_by_loop: - canvas._rc_closed_by_loop = True # type: ignore - canvas.close() - del canvas finally: - # sys.set_asyncgen_hooks(*old_agen_hooks) -> move into __stop - self.__stop() + # We get here when we break the while-loop, but also when the task + # is cancelled (e.g. because the asyncio loop stops). In both cases + # we call stop from the *end* of the task, which is important since + # __stop() cancels all tasks, but cannot cancel the task that it is + # currently in. + self.stop(force=True) def add_task( self, @@ -182,6 +203,8 @@ def add_task( if not (callable(async_func) and iscoroutinefunction(async_func)): raise TypeError("add_task() expects an async function.") + self._ensure_initialized() + async def wrapper(): with log_exception(f"Error in {name} task:"): await async_func(*args) @@ -253,32 +276,34 @@ def run(self) -> None: # Can we enter the loop? if self.__state == 0: - # Euhm, I guess we can run it one iteration, just make sure our loop-task is running! - self._register_canvas_group(0) - self.__canvas_groups.discard(0) - if self.__state == 1: - # Yes we can + # We're in the off state, no canvases. Allow running one iteration. + pass + elif self.__state == 1: + # Yes we can. pass elif self.__state == 2: - # We look active, but have not been marked interactive + # The loop is running, but not sure how. Maybe natively, or maybe this is the offscreen's stub loop. Allow. pass elif self.__state == 3: - # No, already marked active (interactive mode) + # Already marked active (interactive mode). For code compat, silent return! return else: - # No, what are you doing?? - raise RuntimeError(f"loop.run() is not reentrant ({self.__state}).") + # Already running via this method. Disallow re-entrance! + raise RuntimeError(f"loop is already running ({self.__state}).") + + self._ensure_initialized() # Register interrupt handler prev_sig_handlers = self.__setup_interrupt() # 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(). - self.__state = 3 + self.__state = 4 try: self._rc_run() finally: - self.__state = min(self.__state, 1) + # Lower state to not 4, but also not 0 because we may still be running + self.__state = min(self.__state, 2) for sig, cb in prev_sig_handlers.items(): signal.signal(sig, cb) @@ -289,42 +314,93 @@ async def run_async(self) -> None: """ # Can we enter the loop? - if self.__state == 0: - # Euhm, I guess we can run it one iteration, just make sure our loop-task is running! - self._register_canvas_group(0) - self.__canvas_groups.discard(0) - if self.__state == 1: - # Yes we can - pass - else: + if self.__state >= 2: raise RuntimeError( f"loop.run_async() can only be awaited once ({self.__state})." ) + self._ensure_initialized() await self._rc_run_async() - def stop(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 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 + time to clean up and actually destroy the window. If ``force`` is set, + the loop stops immediately. This can be an effective way to stop the + loop when the native event loop has stopped. """ + + if self.__state == 0: + return + # Only take action when we're inside the run() method - self.__should_stop += 1 - if self.__should_stop >= 4: - # If for some reason the tick method is no longer being called, but the loop is still running, we can still stop it by spamming stop() :) + self.__should_stop += 2 if force else 1 + + # Close all canvases + canvases = self.get_canvases(close_closed=True) + for canvas in canvases: + try: + closed_by_loop = canvas._rc_closed_by_loop # type: ignore + except AttributeError: + closed_by_loop = False + if not closed_by_loop: + canvas._rc_closed_by_loop = True # type: ignore + canvas.close() + del canvas + + # Do a real stop? + if len(canvases) == 0 or self.__should_stop >= 2: self.__stop() + def __start(self): + """Move to running state.""" + self.__state = max(self.__state, 2) + + # def init(gen): + # print("init gen", gen) + + # def fin(gen): + # print("fin gen", gen) + + # print("in loop task", self._using_adapter) + # import sys + + # old_agen_hooks = sys.get_asyncgen_hooks() + # sys.set_asyncgen_hooks(init, fin) + def __stop(self): """Move to the off-state.""" - # If we used the async adapter, cancel any tasks - while self.__tasks: - task = self.__tasks.pop() - with log_exception("task cancel:"): - task.cancel() - # Turn off + + # Note that in here, we must fully bring our loop to a stop. + # We cannot rely on future loop cycles. + + # Set flags to off state self.__state = 0 self.__should_stop = 0 + + # sys.set_asyncgen_hooks(*old_agen_hooks) -> move into __stop + + # 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 + # final .step() that will do the cancellation (i.e. running code in + # finally blocks), but (I found) we cannot make that assumption, so we + # do it ourselves. + for task in list(self.__tasks): + with log_exception("task cancel:"): + task.cancel() + if not task.running: # not *this* task + task.step() + + # Note that backends that do not use the asyncadapter are responsible + # for cancelling pending tasks. + + # 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): @@ -362,6 +438,7 @@ def _rc_init(self): * Import any dependencies. * If this loop supports some kind of interactive mode, activate it! * Optionally call ``_mark_as_interactive()``. + * Make sure its ok if this is called a second time, after a run. * Return None. """ pass diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index d837e467..e54cb4e8 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -59,7 +59,7 @@ async def _rc_run_async(self): "Attempt to run AsyncioLoop with a different asyncio-loop than the initialized loop." ) - # Create tasks if necessay + # Create tasks if necessary while self.__pending_tasks: self._rc_add_task(*self.__pending_tasks.pop(0)) @@ -69,7 +69,7 @@ async def _rc_run_async(self): await self._stop_event.wait() def _rc_stop(self): - # Clean up our tasks + # Clean up our tasks. This includes the loop-task and scheduler tasks. while self.__tasks: task = self.__tasks.pop() task.cancel() # is a no-op if the task is no longer running diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 1bd5225c..bb04f000 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -75,16 +75,24 @@ def get_loop(self) -> BaseLoop | None: """Get the currently associated loop (can be None for canvases that don't run a scheduler).""" return self._loop - def get_canvases(self) -> list[BaseRenderCanvas]: - """Get a list of currently active (not-closed) canvases for this group.""" - return [canvas for canvas in self._canvases if not canvas.get_closed()] + def get_canvases(self, *, close_closed=False) -> list[BaseRenderCanvas]: + if close_closed: + closed_canvases = [ + canvas for canvas in self._canvases if canvas.get_closed() + ] + for canvas in closed_canvases: + canvas.close() + self._canvases.discard(canvas) + return self._canvases + else: + return [canvas for canvas in self._canvases if not canvas.get_closed()] class BaseRenderCanvas: """The base canvas class. This base class defines a uniform canvas API so render systems can use code - that is portable accross multiple GUI libraries and canvas targets. The + that is portable across multiple GUI libraries and canvas targets. The scheduling mechanics are generic, even though they run on different backend event systems. @@ -563,7 +571,7 @@ def close(self) -> None: pass self._canvas_context = None # Clean events. Should already have happened in loop, but the loop may not be running. - self._events._release() + self._events.close() # Let the subclass clean up. self._rc_close() diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index f840855f..01537875 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -341,10 +341,10 @@ def _rc_set_logical_size(self, width, height): self._set_logical_size((float(width), float(height))) def _rc_close(self): - if self._window is not None: - glfw.destroy_window(self._window) # not just glfw.hide_window - self._window = None - self.submit_event({"event_type": "close"}) + if self._window is None: + return + glfw.destroy_window(self._window) # not just glfw.hide_window + self._window = None # If this is the last canvas to close, the loop will stop, and glfw will not be polled anymore. # But on some systems glfw needs a bit of time to properly close the window. if not self._rc_canvas_group.get_canvases(): diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index f5c87452..846ba376 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -147,6 +147,10 @@ def __init__(self): super().__init__() self._callbacks = [] + 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): callbacks_to_run = [] new_callbacks = [] @@ -164,7 +168,7 @@ def _rc_run(self): self.process_tasks() def _rc_stop(self): - self._callbacks = [] + pass def _rc_add_task(self, async_func, name): super()._rc_add_task(async_func, name) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 5df54943..5ca13f78 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -226,16 +226,22 @@ class QtLoop(BaseLoop): def _rc_init(self): if self._app is None: - app = QtWidgets.QApplication.instance() - if app is None: + self._app = QtWidgets.QApplication.instance() + if self._app is None: self._app = QtWidgets.QApplication([]) + # We do detect when the canvas-widget is closed, and also when *our* toplevel wrapper is closed, + # but when embedded in an application, it seems hard/impossible to detect the canvas being closed + # when the app closes. So we explicitly detect that 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)) if already_had_app_on_import: self._mark_as_interactive() self._callback_pool = set() self._caller = CallerHelper() def _rc_run(self): - # Note: we could detect if asyncio is running (interactive session) and wheter + # Note: we could detect if asyncio is running (interactive session) and whether # we can use QtAsyncio. However, there's no point because that's up for the # end-user to decide. @@ -250,7 +256,6 @@ def _rc_run(self): self._we_run_the_loop = True try: app = self._app - app.setQuitOnLastWindowClosed(False) app.exec() if hasattr(app, "exec") else app.exec_() finally: self._we_run_the_loop = False @@ -493,6 +498,8 @@ def _rc_set_logical_size(self, width, height): self.resize(width, height) # See comment on pixel ratio def _rc_close(self): + if self._is_closed: + return parent = self.parent() if isinstance(parent, QRenderCanvas): QtWidgets.QWidget.close(parent) @@ -652,8 +659,9 @@ def resizeEvent(self, event): # noqa: N802 # self.update() / self.request_draw() is implicit def closeEvent(self, event): # noqa: N802 + # Happens e.g. when closing the widget from within an app that dynamically created and closes canvases. + super().closeEvent(event) self._is_closed = True - self.submit_event({"event_type": "close"}) class QRenderCanvas(WrapperRenderCanvas, QtWidgets.QWidget): diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 599f47ac..a7502be2 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -22,7 +22,7 @@ def __init__(self): def _rc_init(self): # This gets called when the first canvas is created (possibly after having run and stopped before). - pass + self._should_stop = False def _rc_run(self): while not self._should_stop: @@ -31,6 +31,8 @@ def _rc_run(self): callback() except Exception as err: logger.error(f"Error in RawLoop callback: {err}") + # Note that the queue may still contain pending callbacks, but these will + # mostly be task.step() for finished tasks (coro already deleted), so its ok. async def _rc_run_async(self): raise NotImplementedError() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index 308f4472..ee498023 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -39,6 +39,7 @@ async def _rc_run_async(self): def _rc_stop(self): # Cancel the main task and all its child tasks. + # So this also cancels the loop-task and scheduler tasks, like we want. if self._cancel_scope is not None: self._cancel_scope.cancel() self._token = None diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index e1f44abb..be6d35a4 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -59,13 +59,14 @@ class CancelledError(BaseException): class Task: - """Representation of task, exectuting a co-routine.""" + """Representation of task, executing a co-routine.""" def __init__(self, call_later_func, coro, name): self._call_later = call_later_func self._done_callbacks = [] self.coro = coro self.name = name + self.running = False self.cancelled = False self.call_step_later(0) @@ -87,6 +88,7 @@ def call_step_later(self, delay): def cancel(self): self.cancelled = True + self.call_step_later(0) def step(self): if self.coro is None: @@ -96,6 +98,7 @@ def step(self): stop = False old_name, sniffio_thread_local.name = sniffio_thread_local.name, __name__ + self.running = True try: if self.cancelled: stop = True @@ -108,10 +111,11 @@ def step(self): except StopIteration: stop = True except Exception as err: - # This should not happen, because the loop catches and logs all errors. But just in case. + # This catches some special cases where Python raises an error, such as 'coroutine already executing' logger.error(f"Error in task: {err}") stop = True finally: + self.running = False sniffio_thread_local.name = old_name # Clean up to help gc diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 400f13be..eb20015b 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -168,16 +168,19 @@ def _rc_init(self): wx.App.SetInstance(self._app) def _rc_run(self): + # In wx we can, it seems, reliably detect widget destruction, so we don't rely on detecting the + # app from quitting (which we cannot reliably detect in wx). We could prevent the app from exiting, + # but we cannot do that when the wx app is started from the outside (which is likely), so we need + # to make it work without it anyway. + # self._app.SetExitOnFrameDelete(False) + self._app.MainLoop() async def _rc_run_async(self): raise NotImplementedError() def _rc_stop(self): - # It looks like we cannot make wx stop the loop. - # In general not a problem, because the BaseLoop will try - # to close all windows before stopping a loop. - pass + self._app.ExitMainLoop() def _rc_add_task(self, async_func, name): # we use the async adapter with call_later @@ -198,7 +201,9 @@ def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() event_loop = wx.GUIEventLoop() wx.EventLoop.SetActive(event_loop) - while event_loop.Pending(): + count = 0 + while event_loop.Pending() and count < 3: + count += 1 event_loop.Dispatch() wx.EventLoop.SetActive(old_loop) @@ -257,6 +262,7 @@ def __init__(self, *args, present_method=None, **kwargs): self.Bind(wx.EVT_LEAVE_WINDOW, self._on_window_enter) self.Bind(wx.EVT_SET_FOCUS, self._on_focus) self.Bind(wx.EVT_KILL_FOCUS, self._on_focus) + self.Bind(wx.EVT_WINDOW_DESTROY, self._on_close) self.Show() self._final_canvas_init() @@ -551,6 +557,14 @@ def _on_focus(self, event: wx.FocusEvent): self._pointer_inside = False self.submit_event(ev) + def _on_close(self, _event): + if self._is_closed: + return + self._is_closed = True + loop = self._rc_canvas_group.get_loop() + if not loop.get_canvases(): + loop.stop(force=True) + class WxRenderCanvas(WrapperRenderCanvas, wx.Frame): """A toplevel wx Frame providing a render canvas.""" @@ -571,21 +585,6 @@ def __init__(self, parent=None, **kwargs): self.Show() self._final_canvas_init() - # wx methods - - def Destroy(self): # noqa: N802 - this is a wx method - self._subwidget._is_closed = True - super().Destroy() - - # wx stops running its loop as soon as the last canvas closes. - # So when that happens, we manually run the loop for a short while - # so that we can clean up properly - if not self._subwidget._rc_canvas_group.get_canvases(): - etime = time.perf_counter() + 0.15 - while time.perf_counter() < etime: - time.sleep(0.01) - loop.process_wx_events() - # Make available under a name that is the same for all gui backends RenderWidget = WxRenderWidget diff --git a/tests/test_loop.py b/tests/test_loop.py index e2e58d96..a3282051 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -13,6 +13,7 @@ from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop from rendercanvas.raw import RawLoop +# from rendercanvas.pyside6 import QtLoop from rendercanvas.utils.asyncs import sleep as async_sleep from testutils import run_tests import trio @@ -46,6 +47,7 @@ def _rc_gui_poll(self): def close(self): # Called by the loop to close a canvas + self._events.close() # Mimic BaseRenderCanvas if not self.refuse_close: self.is_closed = True @@ -55,6 +57,13 @@ def get_closed(self): def manually_close(self): self.is_closed = True + def __del__(self): + # Mimic BaseRenderCanvas + try: + self.close() + except Exception: + pass + real_loop = AsyncioLoop() @@ -74,6 +83,9 @@ def _rc_request_draw(self): loop.call_soon(self._draw_frame_and_present) +# %%%%% running and closing + + @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_run_loop_and_close_bc_no_canvases(SomeLoop): # Run the loop without canvas; closes immediately @@ -249,7 +261,6 @@ def test_run_loop_and_close_by_deletion(SomeLoop): loop.call_later(0.3, canvases.clear) loop.call_later(1.3, loop.stop) # failsafe - t0 = time.time() loop.run() et = time.time() - t0 @@ -314,7 +325,10 @@ def interrupt_soon(): @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_run_loop_and_interrupt_harder(SomeLoop): - # In the next tick after the second interupt, it stops the loop without closing the canvases + # In the first tick it attempts to close the canvas, clearing some + # stuff of the BaseRenderCanvase, like the events, but the native canvas + # won't close, so in the second try, the loop is closed regardless. + # after the second interupt, it stops the loop and closes the canvases loop = SomeLoop() group = CanvasGroup(loop) @@ -343,9 +357,97 @@ def interrupt_soon(): print(et) assert 0.6 < et < 0.75 - # Now the close event is not send! - assert not canvas1._events.is_closed - assert not canvas2._events.is_closed + # The events are closed + assert canvas1._events.is_closed + assert canvas2._events.is_closed + + # But the canvases themselves are still marked not-closed + assert not canvas1.is_closed + assert not canvas2.is_closed + + +# %%%%% tasks + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop, TrioLoop]) +def test_loop_task_order(SomeLoop): + # Test that added tasks are started in their original order, + # and that the loop task always goes first. + + flag = [] + + class MyLoop(SomeLoop): + async def _loop_task(self): + flag.append("loop-task") + return await super()._loop_task() + + async def user_task(id): + flag.append(f"user-task{id}") + + loop = MyLoop() + + loop.add_task(user_task, 1) + loop.add_task(user_task, 2) + loop.call_later(0.2, loop.stop) + loop.run() + + assert flag == ["loop-task", "user-task1", "user-task2"], flag + + # Again + + flag.clear() + + loop.add_task(user_task, 1) + loop.add_task(user_task, 2) + loop.call_later(0.2, loop.stop) + loop.run() + + assert flag == ["loop-task", "user-task1", "user-task2"], flag + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop, TrioLoop]) +def test_loop_task_cancellation(SomeLoop): + flag = [] + + async def user_task(): + flag.append("start") + try: + await async_sleep(10) + finally: + flag.append("stop") + + loop = SomeLoop() + + loop.add_task(user_task) + loop.call_later(0.2, loop.stop) + loop.run() + + assert flag == ["start", "stop"], flag + + # Again + + flag.clear() + + loop.add_task(user_task) + loop.call_later(0.2, loop.stop) + loop.run() + + assert flag == ["start", "stop"], flag + + +# test_loop_task_cancellation(AsyncioLoop) +# test_loop_task_cancellation(TrioLoop) +# test_loop_task_cancellation(QtLoop) +# test_loop_task_cancellation(RawLoop) +# +# +# test_loop_task_order(AsyncioLoop) +# test_loop_task_order(TrioLoop) +# test_loop_task_order(QtLoop) +# test_loop_task_order(RawLoop) + + +# %%%%% Misc @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) @@ -383,6 +485,9 @@ def test_async_loops_check_lib(): trio.run(trio_loop.run_async) +# %%%%% async generator cleanup + + async def a_generator(flag): flag.append("started") try: @@ -455,7 +560,6 @@ async def tester_coroutine(): assert flag == ["started", "except GeneratorExit", "closed"], flag - @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_async_gens_cleanup3(SomeLoop): # Break out of the generator, but hold a ref to the generator. From 2f5b4360aa990bed2977628f8fbcf4dd2a81543c Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 8 Dec 2025 11:55:14 +0100 Subject: [PATCH 4/8] use enum for loop state --- rendercanvas/_enums.py | 8 +++++++ rendercanvas/_loop.py | 54 +++++++++++++++++++++--------------------- tests/test_loop.py | 1 + 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/rendercanvas/_enums.py b/rendercanvas/_enums.py index d468917c..ffc07bee 100644 --- a/rendercanvas/_enums.py +++ b/rendercanvas/_enums.py @@ -167,6 +167,14 @@ class EventType(BaseEnum): UpdateModeEnum = Literal["manual", "ondemand", "continuous", "fastest"] +class LoopState(BaseEnum): + off = None #: The loop is in the 'off' state. + ready = None #: The loop is likely to be used, and is ready to start running. + active = None #: The loop is active, but we don't know how. + interactive = None #: The loop is in interactive mode, e.g. in an IDE or notebook. + running = None #: The loop is running via our ``loop.run()``. + + class UpdateMode(BaseEnum): """The UpdateMode enum specifies the different modes to schedule draws for the canvas.""" diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 7710eb72..8fb36962 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -9,6 +9,7 @@ from inspect import iscoroutinefunction from typing import TYPE_CHECKING +from ._enums import LoopState from ._coreutils import logger, log_exception, call_later_from_thread from .utils.asyncs import sleep from .utils import asyncadapter @@ -25,6 +26,9 @@ signal.SIGTERM, # Unix signal 15. Sent by `kill `. ) +STATEMAP = {0: "off", 1: "ready", 2: "active", 3: "interactive", 4: "running"} +STATEMAP_REVERSED = {s: i for i, s in STATEMAP.items()} + class BaseLoop: """The base class for an event-loop object. @@ -65,8 +69,7 @@ def __init__(self): self.__tasks = set() self.__canvas_groups = set() self.__should_stop = 0 - # 0: off, 1: ready, 2: detected-active, 3: inter-active, 4: running - self.__state = 0 + self.__state = LoopState.off self.__is_initialized = False self._asyncgens = weakref.WeakSet() # self._setup_debug_thread() @@ -93,17 +96,14 @@ def thread(): def __repr__(self): full_class_name = f"{self.__class__.__module__}.{self.__class__.__name__}" - state = self.__state - statemap = {0: "off", 1: "ready", 2: "active", 3: "active", 4: "running"} - state_str = statemap.get(state, str(state)) - return f"<{full_class_name} '{state_str}' ({state}) at {hex(id(self))}>" + return f"<{full_class_name} '{self.__state}' at {hex(id(self))}>" def _mark_as_interactive(self): # For subclasses to set active from ``_rc_init()`` If the loop is # interactive, run() becomes a no-op. The stop() will still close all # canvases, but the backend loop should keep running. - if self.__state in (1, 2): - self.__state = 3 + if self.__state in (LoopState.ready, LoopState.running): + self.__state = LoopState.interactive def _register_canvas_group(self, canvas_group): # A CanvasGroup will call this every time that a new canvas is created for this loop. @@ -127,8 +127,8 @@ def _ensure_initialized(self): if self.__is_initialized: return - if self.__state == 0: - self.__state = 1 + if self.__state == LoopState.off: + self.__state = LoopState.ready async def wrapper(): try: @@ -275,21 +275,17 @@ def run(self) -> None: """ # Can we enter the loop? - if self.__state == 0: - # We're in the off state, no canvases. Allow running one iteration. - pass - elif self.__state == 1: - # Yes we can. + if self.__state in (LoopState.off, LoopState.ready, LoopState.active): + # 'off': no canvases, but allow running one iteration. + # 'ready': normal operation. + # 'active': the loop is active, but not sure how. Maybe natively, or maybe this is the offscreen's stub loop. Allow. pass - elif self.__state == 2: - # The loop is running, but not sure how. Maybe natively, or maybe this is the offscreen's stub loop. Allow. - pass - elif self.__state == 3: + elif self.__state == LoopState.interactive: # Already marked active (interactive mode). For code compat, silent return! return else: # Already running via this method. Disallow re-entrance! - raise RuntimeError(f"loop is already running ({self.__state}).") + raise RuntimeError(f"loop is already {self.__state}.") self._ensure_initialized() @@ -298,12 +294,13 @@ def run(self) -> None: # 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(). - self.__state = 4 + self.__state = LoopState.running try: self._rc_run() finally: - # Lower state to not 4, but also not 0 because we may still be running - self.__state = min(self.__state, 2) + # 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) @@ -314,7 +311,7 @@ async def run_async(self) -> None: """ # Can we enter the loop? - if self.__state >= 2: + if self.__state in (LoopState.active, LoopState.interactive, LoopState.running): raise RuntimeError( f"loop.run_async() can only be awaited once ({self.__state})." ) @@ -334,7 +331,7 @@ def stop(self, *, force=False) -> None: loop when the native event loop has stopped. """ - if self.__state == 0: + if self.__state == LoopState.off: return # Only take action when we're inside the run() method @@ -358,7 +355,10 @@ def stop(self, *, force=False) -> None: def __start(self): """Move to running state.""" - self.__state = max(self.__state, 2) + + # Update state, but leave 'interactive' and 'running' + if self.__state in (LoopState.off, LoopState.ready): + self.__state = LoopState.active # def init(gen): # print("init gen", gen) @@ -379,7 +379,7 @@ def __stop(self): # We cannot rely on future loop cycles. # Set flags to off state - self.__state = 0 + self.__state = LoopState.off self.__should_stop = 0 # sys.set_asyncgen_hooks(*old_agen_hooks) -> move into __stop diff --git a/tests/test_loop.py b/tests/test_loop.py index a3282051..6b68def4 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -13,6 +13,7 @@ from rendercanvas.asyncio import AsyncioLoop from rendercanvas.trio import TrioLoop from rendercanvas.raw import RawLoop + # from rendercanvas.pyside6 import QtLoop from rendercanvas.utils.asyncs import sleep as async_sleep from testutils import run_tests From da8729c1b9dad006469f2ca9fd0b430d8d381acb Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 8 Dec 2025 12:40:46 +0100 Subject: [PATCH 5/8] Add unit tests for lifetime --- rendercanvas/_loop.py | 10 ++- tests/test_loop.py | 155 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 8fb36962..18a8de02 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -203,12 +203,11 @@ def add_task( if not (callable(async_func) and iscoroutinefunction(async_func)): raise TypeError("add_task() expects an async function.") - self._ensure_initialized() - async def wrapper(): with log_exception(f"Error in {name} task:"): await async_func(*args) + self._ensure_initialized() self._rc_add_task(wrapper, name) def call_soon(self, callback: CallbackFunction, *args: Any) -> None: @@ -229,6 +228,7 @@ async def wrapper(): with log_exception("Callback error:"): callback(*args) + self._ensure_initialized() self._rc_add_task(wrapper, "call_soon") def call_soon_threadsafe(self, callback: CallbackFunction, *args: Any) -> None: @@ -262,6 +262,7 @@ async def wrapper(): await sleep(delay) callback(*args) + self._ensure_initialized() self._rc_add_task(wrapper, "call_later") def run(self) -> None: @@ -316,7 +317,12 @@ async def run_async(self) -> None: f"loop.run_async() can only be awaited once ({self.__state})." ) + # Get ready. If we were not initialized yet, this will probably mark us as interactive, because the loop is already running. self._ensure_initialized() + + # Mark as active, but not running, because we may just be a task in the native loop. + self.__state = LoopState.active + await self._rc_run_async() def stop(self, *, force=False) -> None: diff --git a/tests/test_loop.py b/tests/test_loop.py index 6b68def4..0648afd2 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -367,6 +367,161 @@ def interrupt_soon(): assert not canvas2.is_closed +# %%%%% lifetime + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_loop_lifetime_normal(SomeLoop): + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) + + loop = SomeLoop() + log_state(loop) + + loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) + + loop.run() + log_state(loop) + + assert states == ["off", "running", "off"] + + # Again + + states.clear() + log_state(loop) + + loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) + + loop.run() + log_state(loop) + + assert states == ["off", "running", "off"] + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_loop_lifetime_with_ready(SomeLoop): + # Creating a canvas, or addding a task puts the loop in its ready state + + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) + + async def noop(): + pass + + loop = SomeLoop() + log_state(loop) + + loop.add_task(noop) + log_state(loop) + + loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) + + loop.run() + log_state(loop) + + assert states == ["off", "ready", "running", "off"] + + # Again + + states.clear() + log_state(loop) + + loop.add_task(noop) + log_state(loop) + + loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) + + loop.run() + log_state(loop) + + assert states == ["off", "ready", "running", "off"] + + +@pytest.mark.parametrize("SomeLoop", [AsyncioLoop, TrioLoop]) +def test_loop_lifetime_async(SomeLoop): + # Run using loop.run_async + + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) + + loop = SomeLoop() + log_state(loop) + + loop.call_later(0.01, log_state, loop) + + if SomeLoop is AsyncioLoop: + asyncio.run(loop.run_async()) + elif SomeLoop is TrioLoop: + trio.run(loop.run_async) + else: + raise NotImplementedError() + + log_state(loop) + + assert states == ["off", "active", "off"] + + +def test_loop_lifetime_running_outside(): + # Run using asyncio.run. + # Note how the rendercanvas loop is stopped earlier than the asyncio loop. + + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) + + loop = AsyncioLoop() + log_state(loop) + + loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) + + async def main(): + lop = asyncio.get_running_loop() + task = lop.create_task(loop.run_async()) + lop.call_later(0.15, log_state, loop) # by this time rc has stopped + await asyncio.sleep(0.25) + del task # for ruff and good practice, we kept a ref to task + + asyncio.run(main()) + + log_state(loop) + + assert states == ["off", "active", "off", "off"] + + +def test_loop_lifetime_interactive(): + # Run using loop.run, but asyncio is already running: interactive mode. + + times = [] + states = [] + log_state = lambda loop: states.append(loop._BaseLoop__state) + + loop = AsyncioLoop() + + async def main(): + log_state(loop) + + loop.call_later(0.01, log_state, loop) + loop.call_later(0.1, loop.stop) + times.append(time.perf_counter()) + loop.run() + times.append(time.perf_counter()) + await asyncio.sleep(0.25) + times.append(time.perf_counter()) + + asyncio.run(main()) + + log_state(loop) + + assert states == ["off", "interactive", "off"] + + assert (times[1] - times[0]) < 0.01 + assert (times[2] - times[1]) > 0.20 + + # %%%%% tasks From a223391e8412b62978d330ea29c806a38a3d3a84 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 8 Dec 2025 15:45:51 +0100 Subject: [PATCH 6/8] Implement asyncgen hooks --- rendercanvas/_coreutils.py | 39 ++++++++++++++++++++++++ rendercanvas/_loop.py | 61 ++++++++++++++++++++++++++++++-------- tests/test_loop.py | 42 +++++++++++++++++--------- 3 files changed, 115 insertions(+), 27 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 5522fecd..ccba8132 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -292,6 +292,45 @@ def asyncio_is_running(): return loop is not None +# %% Async generators + + +# Taken from trio._util.py +def name_asyncgen(agen) -> str: + """Return the fully-qualified name of the async generator function + that produced the async generator iterator *agen*. + """ + if not hasattr(agen, "ag_code"): # pragma: no cover + return repr(agen) + try: + module = agen.ag_frame.f_globals["__name__"] + except (AttributeError, KeyError): + module = f"<{agen.ag_code.co_filename}>" + try: + qualname = agen.__qualname__ + except AttributeError: + qualname = agen.ag_code.co_name + return f"{module}.{qualname}" + + +def close_agen(agen): + """Try to sync-close an async generator.""" + closer = agen.aclose() + try: + # If the next thing is a yield, this will raise RuntimeError which we allow to propagate + closer.send(None) + except StopIteration: + pass + else: + # If the next thing is an await, we get here. + # Give a nicer error than the default "async generator ignored GeneratorExit" + agen_name = name_asyncgen(agen) + logger.error( + f"Async generator {agen_name!r} awaited something during finalization, " + "so we could not clean it up. Wrap it in 'async with aclosing(...):'", + ) + + # %% Linux window managers diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 18a8de02..7287327b 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -4,13 +4,14 @@ from __future__ import annotations +import sys import signal import weakref from inspect import iscoroutinefunction from typing import TYPE_CHECKING from ._enums import LoopState -from ._coreutils import logger, log_exception, call_later_from_thread +from ._coreutils import logger, log_exception, call_later_from_thread, close_agen from .utils.asyncs import sleep from .utils import asyncadapter @@ -366,17 +367,10 @@ def __start(self): if self.__state in (LoopState.off, LoopState.ready): self.__state = LoopState.active - # def init(gen): - # print("init gen", gen) - - # def fin(gen): - # print("fin gen", gen) - - # print("in loop task", self._using_adapter) - # import sys - - # old_agen_hooks = sys.get_asyncgen_hooks() - # sys.set_asyncgen_hooks(init, fin) + # 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() def __stop(self): """Move to the off-state.""" @@ -388,7 +382,7 @@ def __stop(self): self.__state = LoopState.off self.__should_stop = 0 - # sys.set_asyncgen_hooks(*old_agen_hooks) -> move into __stop + 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 @@ -433,6 +427,47 @@ def on_interrupt(sig, _frame): break return prev_handlers + def __setup_asyncgen_hooks(self): + # We employ a simple strategy to deal with lingering async generators, + # in which we attempt to sync-close them. This fails (only) when the + # finalizer of the agen has an await in it. Technically this is allowed, + # but it's probably not a good idea, and it would make it hard for us, + # because we want to be able to stop synchronously. So when this happens + # we log an error with a hint on how to cleanly (asynchronously) close + # 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. + + 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 + + def __finish_asyncgen_hooks(self): + 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) + + 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/tests/test_loop.py b/tests/test_loop.py index 0648afd2..a9603899 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -591,18 +591,6 @@ async def user_task(): assert flag == ["start", "stop"], flag -# test_loop_task_cancellation(AsyncioLoop) -# test_loop_task_cancellation(TrioLoop) -# test_loop_task_cancellation(QtLoop) -# test_loop_task_cancellation(RawLoop) -# -# -# test_loop_task_order(AsyncioLoop) -# test_loop_task_order(TrioLoop) -# test_loop_task_order(QtLoop) -# test_loop_task_order(RawLoop) - - # %%%%% Misc @@ -644,7 +632,7 @@ def test_async_loops_check_lib(): # %%%%% async generator cleanup -async def a_generator(flag): +async def a_generator(flag, *, await_in_finalizer=False): flag.append("started") try: for i in range(4): @@ -656,6 +644,8 @@ async def a_generator(flag): else: flag.append("finished") finally: + if await_in_finalizer: + await async_sleep(0) flag.append("closed") @@ -698,7 +688,7 @@ async def tester_coroutine(): @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_async_gens_cleanup2(SomeLoop): # Break out of the generator, leaving it in a pending state. - # Just works, because gen.aclose() is called from gen.__del__ somehow? + # Just works, because gen.aclose() is called from gen.__del__ async def tester_coroutine(): g = a_generator(flag) @@ -740,5 +730,29 @@ async def tester_coroutine(): assert flag == ["started", "except GeneratorExit", "closed"], flag +@pytest.mark.parametrize("SomeLoop", [RawLoop]) +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 + + async def tester_coroutine(): + nonlocal g + g = a_generator(flag, await_in_finalizer=True) + # await async_sleep(0) + async for i in g: + if i > 2: + break + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + loop.call_later(0.2, loop.stop) + loop.run() + + assert flag == ["started", "except GeneratorExit"], flag + + if __name__ == "__main__": run_tests(globals()) From e007d69da7f7d25b29a3739375f9786051bb6c72 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 8 Dec 2025 16:25:10 +0100 Subject: [PATCH 7/8] some tweaks and extra test --- rendercanvas/_loop.py | 43 +++++++++++++++++++++++++++---------------- tests/test_loop.py | 24 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 7287327b..ee18ebd9 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -77,22 +77,26 @@ def __init__(self): def _setup_debug_thread(self): # Super-useful to track the loop's lifetime while running various examples / use-cases. + # In test_loop.py -> test_not_using_loop_debug_thread() we make sure that it's not accidentally active by default. import threading, time # noqa + weakself = weakref.ref(self) + + def get_state(): + self = weakself() + if self is not None: + return self.__state + def thread(): - state = self.__state - print(f"loop state: {state}") - while True: + prev_state = None + while cur_state := get_state(): + if cur_state != prev_state: + prev_state = cur_state + print(f"loop state: {cur_state}") time.sleep(0.01) - cur_state = self.__state - if cur_state != state: - state = cur_state - print(f"loop state: {state}") - if state == 0: - print("bye") - - self._debug_thread = threading.Thread(target=thread) + + self._debug_thread = threading.Thread(target=thread, daemon=True) self._debug_thread.start() def __repr__(self): @@ -277,10 +281,13 @@ def run(self) -> None: """ # Can we enter the loop? - if self.__state in (LoopState.off, LoopState.ready, LoopState.active): + if self.__state in (LoopState.off, LoopState.ready): # 'off': no canvases, but allow running one iteration. # 'ready': normal operation. - # 'active': the loop is active, but not sure how. Maybe natively, or maybe this is the offscreen's stub loop. Allow. + pass + elif self.__state == LoopState.active: + # The loop is active, but not sure how. Maybe natively, or maybe this is the offscreen's stub loop. + # Allow, maybe the backend raises an error. pass elif self.__state == LoopState.interactive: # Already marked active (interactive mode). For code compat, silent return! @@ -311,9 +318,10 @@ async def run_async(self) -> None: Only supported by the asyncio and trio loops. """ - # Can we enter the loop? - if self.__state in (LoopState.active, LoopState.interactive, LoopState.running): + if self.__state in (LoopState.off, LoopState.ready): + pass + else: raise RuntimeError( f"loop.run_async() can only be awaited once ({self.__state})." ) @@ -324,7 +332,10 @@ async def run_async(self) -> None: # Mark as active, but not running, because we may just be a task in the native loop. self.__state = LoopState.active - await self._rc_run_async() + try: + await self._rc_run_async() + finally: + self.__state = LoopState.off def stop(self, *, force=False) -> None: """Close all windows and stop the currently running event-loop. diff --git a/tests/test_loop.py b/tests/test_loop.py index a9603899..aa1277e3 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -4,6 +4,7 @@ # ruff: noqa: N803 +import gc import time import signal import asyncio @@ -468,6 +469,10 @@ def test_loop_lifetime_async(SomeLoop): def test_loop_lifetime_running_outside(): # Run using asyncio.run. # Note how the rendercanvas loop is stopped earlier than the asyncio loop. + # Note that we use asyncio.run() here which has the logic to + # clean up tasks. When using asyncio.new_event_loop().run_xx() then + # it does *not* work, the user is expected to cancel tasks then. + # Or ... just exit Python when done *shrug*. states = [] log_state = lambda loop: states.append(loop._BaseLoop__state) @@ -594,6 +599,25 @@ async def user_task(): # %%%%% Misc +def test_not_using_loop_debug_thread(): + key = "_debug_thread" + loop = RawLoop() + assert not hasattr(loop, key) + + loop._setup_debug_thread() + + thread = getattr(loop, key) + assert thread + assert thread.is_alive() + + del loop + gc.collect() + gc.collect() + time.sleep(0.02) + + assert not thread.is_alive() + + @pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) def test_loop_threaded(SomeLoop): t = threading.Thread(target=test_run_loop_and_close_by_loop_stop, args=(SomeLoop,)) From badd4725937c0318dd74e150ebb46f5d9d35e9e0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 8 Dec 2025 16:28:45 +0100 Subject: [PATCH 8/8] cleanup --- rendercanvas/_loop.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index ee18ebd9..523d6815 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -27,9 +27,6 @@ signal.SIGTERM, # Unix signal 15. Sent by `kill `. ) -STATEMAP = {0: "off", 1: "ready", 2: "active", 3: "interactive", 4: "running"} -STATEMAP_REVERSED = {s: i for i, s in STATEMAP.items()} - class BaseLoop: """The base class for an event-loop object. @@ -40,11 +37,11 @@ class BaseLoop: The lifecycle states of a loop are: - * off (0): the initial state, the subclass should probably not even import dependencies yet. - * ready (1): the first canvas is created, ``_rc_init()`` is called to get the loop ready for running. - * active (2): the loop is active (we detect it because our task is running), but we don't know how. - * active (3): the loop is inter-active in e.g. an IDE, reported by the backend. - * running (4): the loop is running via ``_rc_run()`` or ``_rc_run_async()``. + * 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: