diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 5522fec..ccba813 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/_enums.py b/rendercanvas/_enums.py index d468917..ffc07be 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 cba3023..523d681 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -4,11 +4,14 @@ from __future__ import annotations +import sys import signal +import weakref from inspect import iscoroutinefunction from typing import TYPE_CHECKING -from ._coreutils import logger, log_exception, call_later_from_thread +from ._enums import LoopState +from ._coreutils import logger, log_exception, call_later_from_thread, close_agen from .utils.asyncs import sleep from .utils import asyncadapter @@ -34,124 +37,159 @@ 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, but not running via our entrypoints. - * active (3): the loop is inter-active in e.g. an IDE. - * 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: - * 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 - ) + self.__state = LoopState.off + 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. + # 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(): + 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) + + self._debug_thread = threading.Thread(target=thread, daemon=True) + 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] - 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 self.__state in (1, 2): - self.__state = 3 + # 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 (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. # 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 + def _ensure_initialized(self): + """Make sure that the loop is ready to run.""" + if self.__is_initialized: + return + + if self.__state == LoopState.off: + self.__state = LoopState.ready + + 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 + + self.__is_initialized = True + self._rc_init() + self._rc_add_task(wrapper, "loop-task") + 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()} + # * 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: - 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, @@ -171,6 +209,7 @@ 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: @@ -191,6 +230,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: @@ -224,6 +264,7 @@ async def wrapper(): await sleep(delay) callback(*args) + self._ensure_initialized() self._rc_add_task(wrapper, "call_later") def run(self) -> None: @@ -237,33 +278,35 @@ 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 + if self.__state in (LoopState.off, LoopState.ready): + # 'off': no canvases, but allow running one iteration. + # 'ready': normal operation. pass - elif self.__state == 2: - # We look active, but have not been marked interactive + 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 == 3: - # No, already marked active (interactive mode) + elif self.__state == LoopState.interactive: + # 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 {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 = LoopState.running try: self._rc_run() finally: - self.__state = min(self.__state, 1) + # 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) @@ -272,44 +315,100 @@ async def run_async(self) -> None: Only supported by the asyncio and trio loops. """ - # 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 + if self.__state in (LoopState.off, LoopState.ready): pass else: raise RuntimeError( f"loop.run_async() can only be awaited once ({self.__state})." ) - await self._rc_run_async() + # 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 + + try: + await self._rc_run_async() + finally: + self.__state = LoopState.off - 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 == LoopState.off: + 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.""" + + # Update state, but leave 'interactive' and 'running' + if self.__state in (LoopState.off, LoopState.ready): + self.__state = LoopState.active + + # 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.""" - # If we used the async adapter, cancel any tasks - while self.__tasks: - task = self.__tasks.pop() + + # 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 = LoopState.off + self.__should_stop = 0 + + 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 + # 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() - # Turn off - self.__state = 0 - self.__should_stop = 0 + 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): @@ -336,6 +435,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. @@ -347,6 +487,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 b8206f3..e54cb4e 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}.") @@ -59,9 +59,9 @@ 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(-1)) + self._rc_add_task(*self.__pending_tasks.pop(0)) # Wait for loop to finish if self._stop_event is None: @@ -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 1bd5225..bb04f00 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 f840855..0153787 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 f5c8745..846ba37 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 5df5494..5ca13f7 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 599f47a..a7502be 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 308f447..ee49802 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 e1f44ab..be6d35a 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 400f13b..eb20015 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 f188475..aa1277e 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 @@ -13,6 +14,8 @@ 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 +49,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 +59,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 +85,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 +263,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 +327,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 +359,263 @@ 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 + + +# %%%%% 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. + # 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) + + 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 + + +@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 + + +# %%%%% 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]) @@ -383,5 +653,130 @@ def test_async_loops_check_lib(): trio.run(trio_loop.run_async) +# %%%%% async generator cleanup + + +async def a_generator(flag, *, await_in_finalizer=False): + flag.append("started") + try: + 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__}") + raise + else: + flag.append("finished") + finally: + if await_in_finalizer: + await async_sleep(0) + flag.append("closed") + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_async_gens_cleanup0(SomeLoop): + # Don't even start the generator. + # Just works, because code of generator has not stated running. + + async def tester_coroutine(): + _g = a_generator(flag) + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + loop.call_later(0.2, loop.stop) + loop.run() + + assert flag == [], flag + + +@pytest.mark.parametrize("SomeLoop", [RawLoop, AsyncioLoop]) +def test_async_gens_cleanup1(SomeLoop): + # Run the generator to completion. + # Just works, because code of generator is done. + + 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.2, 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. + # Just works, because gen.aclose() is called from gen.__del__ + + async def tester_coroutine(): + g = a_generator(flag) + # await async_sleep(0) # this sleep made a difference at some point + async for i in g: + if i > 1: + break + + flag = [] + loop = SomeLoop() + loop.add_task(tester_coroutine) + 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, 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) + 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", "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())