From ea6718772437f1638dc0c867b654956fdfb6b6b0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 21 Nov 2024 11:23:37 +0100 Subject: [PATCH] Handle ctrl-c (#25) --- rendercanvas/_loop.py | 144 +++++++++++++++++++++++++++++++------- rendercanvas/asyncio.py | 11 +-- rendercanvas/base.py | 10 +-- rendercanvas/glfw.py | 55 +++++++-------- rendercanvas/qt.py | 4 +- rendercanvas/wx.py | 8 +-- tests/test_loop.py | 150 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+), 76 deletions(-) create mode 100644 tests/test_loop.py diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index b820e81..15ae8ec 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -3,14 +3,22 @@ """ import time +import signal import weakref -from ._coreutils import log_exception, BaseEnum +from ._coreutils import logger, log_exception, BaseEnum + # Note: technically, we could have a global loop proxy object that defers to any of the other loops. # That would e.g. allow using glfw with qt together. Probably a too weird use-case for the added complexity. +HANDLED_SIGNALS = ( + signal.SIGINT, # Unix signal 2. Sent by Ctrl+C. + signal.SIGTERM, # Unix signal 15. Sent by `kill `. +) + + class BaseTimer: """The Base class for a timer object. @@ -142,8 +150,9 @@ class BaseLoop: _TimerClass = None # subclases must set this def __init__(self): - self._schedulers = set() - self._stop_when_no_canvases = False + self._schedulers = [] + self._is_inside_run = False + self._should_stop = 0 # The loop object runs a lightweight timer for a few reasons: # * Support running the loop without windows (e.g. to keep animations going). @@ -156,26 +165,51 @@ def __init__(self): def _register_scheduler(self, scheduler): # Gets called whenever a canvas in instantiated - self._schedulers.add(scheduler) + self._schedulers.append(scheduler) self._gui_timer.start(0.1) # (re)start our internal timer + def get_canvases(self): + """Get a list of currently active (not-closed) canvases.""" + canvases = [] + schedulers = [] + + for scheduler in self._schedulers: + canvas = scheduler.get_canvas() + if canvas is not None: + canvases.append(canvas) + schedulers.append(scheduler) + + # Forget schedulers that no longer have a live canvas + self._schedulers = schedulers + + return canvases + def _tick(self): # Keep the GUI alive on every tick self._rc_gui_poll() - # Check all schedulers - schedulers_to_close = [] - for scheduler in self._schedulers: - if scheduler._get_canvas() is None: - schedulers_to_close.append(scheduler) + # Clean internal schedulers list + self.get_canvases() - # Forget schedulers that no longer have an live canvas - for scheduler in schedulers_to_close: - self._schedulers.discard(scheduler) + # Our loop can still tick, even if the loop is not started via our run() method. + # If this is the case, we don't run the close/stop logic + if not self._is_inside_run: + return - # Check whether we must stop the loop - if self._stop_when_no_canvases and not self._schedulers: - self.stop() + # Should we stop? + if not self._schedulers: + # Stop when there are no more canvases + self._rc_stop() + elif self._should_stop >= 2: + # force a stop without waiting for the canvases to close + self._rc_stop() + elif self._should_stop: + # Close all remaining canvases. Loop will stop in a next iteration. + for canvas in self.get_canvases(): + if not getattr(canvas, "_rc_closed_by_loop", False): + canvas._rc_closed_by_loop = True + canvas._rc_close() + del canvas def call_soon(self, callback, *args): """Arrange for a callback to be called as soon as possible. @@ -213,25 +247,83 @@ def call_repeated(self, interval, callback, *args): timer.start() return timer - def run(self, stop_when_no_canvases=True): + def run(self): """Enter the main loop. This provides a generic API to start the loop. When building an application (e.g. with Qt) its fine to start the loop in the normal way. """ - self._stop_when_no_canvases = bool(stop_when_no_canvases) - self._rc_run() + # Note that when the loop is started via this method, we always stop + # when the last canvas is closed. Keeping the loop alive is a use-case + # for interactive sessions, where the loop is already running, or started + # "behind our back". So we don't need to accomodate for this. + + # Cannot run if already running + if self._is_inside_run: + raise RuntimeError("loop.run() is not reentrant.") + + # Make sure that the internal timer is running, even if no canvases. + self._gui_timer.start(0.1) + + # 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. In the latter case, note how we restore the sigint + # handler again, so we don't interfere with that loop. + self._is_inside_run = True + try: + self._rc_run() + finally: + self._is_inside_run = False + for sig, cb in prev_sig_handlers.items(): + signal.signal(sig, cb) def stop(self): - """Stop the currently running event loop.""" - self._rc_stop() + """Close all windows and stop the currently running event loop. + + This only has effect when the event loop is currently running via ``.run()``. + I.e. not when a Qt app is started with ``app.exec()``, or when Qt or asyncio + is running interactively in your IDE. + """ + # Only take action when we're inside the run() method + if self._is_inside_run: + 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._rc_stop() + + def __setup_interrupt(self): + def on_interrupt(sig, _frame): + logger.warning(f"Received signal {signal.strsignal(sig)}") + self.stop() + + prev_handlers = {} + + for sig in HANDLED_SIGNALS: + prev_handler = signal.getsignal(sig) + if prev_handler in (None, signal.SIG_IGN, signal.SIG_DFL): + # Only register if the old handler for SIGINT was not None, + # which means that a non-python handler was installed, i.e. in + # Julia, and not SIG_IGN which means we should ignore the interrupts. + pass + else: + # Setting the signal can raise ValueError if this is not the main thread/interpreter + try: + prev_handlers[sig] = signal.signal(signal.SIGINT, on_interrupt) + except ValueError: + break + return prev_handlers def _rc_run(self): """Start running the event-loop. * Start the event loop. - * The rest of the loop object must work just fine, also when the loop is - started in the "normal way" (i.e. this method may not be called). + * The loop object must also work when the native loop is started + in the GUI-native way (i.e. this method may not be called). + * If the backend is in interactive mode (i.e. there already is + an active native loop) this may return directly. """ raise NotImplementedError() @@ -239,7 +331,8 @@ def _rc_stop(self): """Stop the event loop. * Stop the running event loop. - * When running in an interactive session, this call should probably be ignored. + * This will only be called when the process is inside _rc_run(). + I.e. not for interactive mode. """ raise NotImplementedError() @@ -355,7 +448,8 @@ def __init__(self, canvas, events, loop, *, mode="ondemand", min_fps=1, max_fps= # Register this scheduler/canvas at the loop object loop._register_scheduler(self) - def _get_canvas(self): + def get_canvas(self): + """Get the canvas, or None if it is closed or gone.""" canvas = self._canvas_ref() if canvas is None or canvas.is_closed(): # Pretty nice, we can send a close event, even if the canvas no longer exists @@ -396,7 +490,7 @@ def _tick(self): self._last_tick_time = time.perf_counter() # Get canvas or stop - if (canvas := self._get_canvas()) is None: + if (canvas := self.get_canvas()) is None: return # Process events, handlers may request a draw diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index a3c283a..2649f97 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -40,7 +40,6 @@ def _rc_stop(self): class AsyncioLoop(BaseLoop): _TimerClass = AsyncioTimer _the_loop = None - _is_interactive = True # When run() is not called, assume interactive @property def _loop(self): @@ -58,16 +57,12 @@ def _get_loop(self): return loop def _rc_run(self): - if self._loop.is_running(): - self._is_interactive = True - else: - self._is_interactive = False + if not self._loop.is_running(): self._loop.run_forever() def _rc_stop(self): - if not self._is_interactive: - self._loop.stop() - self._is_interactive = True + # Note: is only called when we're inside _rc_run + self._loop.stop() def _rc_call_soon(self, callback, *args): self._loop.call_soon(callback, *args) diff --git a/rendercanvas/base.py b/rendercanvas/base.py index 9c93bf5..8d1f609 100644 --- a/rendercanvas/base.py +++ b/rendercanvas/base.py @@ -116,9 +116,9 @@ def _final_canvas_init(self): self.set_title(kwargs["title"]) def __del__(self): - # On delete, we call the custom close method. + # On delete, we call the custom destroy method. try: - self.close() + self._rc_close() except Exception: pass # Since this is sometimes used in a multiple inheritance, the @@ -497,9 +497,6 @@ def _rc_set_logical_size(self, width, height): def _rc_close(self): """Close the canvas. - For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably - close the wrapper instead. - Note that ``BaseRenderCanvas`` implements the ``close()`` method, which is a rather common name; it may be necessary to re-implement that too. """ @@ -512,9 +509,6 @@ def _rc_is_closed(self): def _rc_set_title(self, title): """Set the canvas title. May be ignored when it makes no sense. - For widgets that are wrapped by a ``WrapperRenderCanvas``, this should probably - set the title of the wrapper instead. - The default implementation does nothing. """ pass diff --git a/rendercanvas/glfw.py b/rendercanvas/glfw.py index 209a46a..b348701 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -12,7 +12,6 @@ import sys import time import atexit -import weakref import glfw @@ -172,13 +171,10 @@ def __init__(self, *args, present_method=None, **kwargs): self._changing_pixel_ratio = False self._is_minimized = False - # Register ourselves - loop.all_glfw_canvases.add(self) - # Register callbacks. We may get notified too often, but that's # ok, they'll result in a single draw. glfw.set_framebuffer_size_callback(self._window, weakbind(self._on_size_change)) - glfw.set_window_close_callback(self._window, weakbind(self._check_close)) + glfw.set_window_close_callback(self._window, weakbind(self._on_want_close)) glfw.set_window_refresh_callback(self._window, weakbind(self._on_window_dirty)) glfw.set_window_focus_callback(self._window, weakbind(self._on_window_dirty)) set_window_content_scale_callback( @@ -234,6 +230,16 @@ def _determine_size(self): } self.submit_event(ev) + def _on_want_close(self, *args): + # Called when the user attempts to close the window, for example by clicking the close widget in the title bar. + # We could prevent closing the window here. But we don't :) + pass # Prevent closing: glfw.set_window_should_close(self._window, 0) + + def _maybe_close(self): + if self._window is not None: + if glfw.window_should_close(self._window): + self._rc_close() + # %% Methods to implement RenderCanvas def _set_logical_size(self, new_logical_size): @@ -306,9 +312,12 @@ def _rc_set_logical_size(self, width, height): self._set_logical_size((float(width), float(height))) def _rc_close(self): + if not loop._glfw_initialized: + return # glfw is probably already terminated if self._window is not None: - glfw.set_window_should_close(self._window, True) - self._check_close() + glfw.destroy_window(self._window) # not just glfw.hide_window + self._window = None + self.submit_event({"event_type": "close"}) def _rc_is_closed(self): return self._window is None @@ -332,20 +341,6 @@ def _on_size_change(self, *args): self._determine_size() self.request_draw() - def _check_close(self, *args): - # Follow the close flow that glfw intended. - # This method can be overloaded and the close-flag can be set to False - # using set_window_should_close() if now is not a good time to close. - if self._window is not None and glfw.window_should_close(self._window): - self._on_close() - - def _on_close(self, *args): - loop.all_glfw_canvases.discard(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"}) - def _on_mouse_button(self, window, but, action, mods): # Map button being changed, which we use to update self._pointer_buttons. button_map = { @@ -529,26 +524,28 @@ def _on_char(self, window, char): class GlfwAsyncioLoop(AsyncioLoop): def __init__(self): super().__init__() - self.all_glfw_canvases = weakref.WeakSet() - self.stop_if_no_more_canvases = True self._glfw_initialized = False + atexit.register(self._terminate_glfw) def init_glfw(self): - glfw.init() # Safe to call multiple times if not self._glfw_initialized: + glfw.init() # Note: safe to call multiple times self._glfw_initialized = True - atexit.register(glfw.terminate) + + def _terminate_glfw(self): + self._glfw_initialized = False + glfw.terminate() def _rc_gui_poll(self): + for canvas in self.get_canvases(): + canvas._maybe_close() + del canvas glfw.post_empty_event() # Awake the event loop, if it's in wait-mode glfw.poll_events() - if self.stop_if_no_more_canvases and not tuple(self.all_glfw_canvases): - self.stop() def _rc_run(self): super()._rc_run() - if not self._is_interactive: - poll_glfw_briefly() + poll_glfw_briefly() loop = GlfwAsyncioLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 5cee615..6323f0f 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -577,8 +577,8 @@ def _rc_run(self): app.exec() if hasattr(app, "exec") else app.exec_() def _rc_stop(self): - if not already_had_app_on_import: - self._app.quit() + # Note: is only called when we're inside _rc_run + self._app.quit() def _rc_gui_poll(self): pass # we assume the Qt event loop is running. Calling processEvents() will cause recursive repaints. diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e6725bf..a98cc18 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -482,7 +482,6 @@ def _rc_stop(self): class WxLoop(BaseLoop): _TimerClass = WxTimer _the_app = None - _frame_to_keep_loop_alive = None def init_wx(self): _ = self._app @@ -499,12 +498,13 @@ def _rc_call_soon(self, delay, callback, *args): wx.CallSoon(callback, args) def _rc_run(self): - self._frame_to_keep_loop_alive = wx.Frame(None) self._app.MainLoop() def _rc_stop(self): - self._frame_to_keep_loop_alive.Destroy() - _frame_to_keep_loop_alive = None + # 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 def _rc_gui_poll(self): pass # We can assume the wx loop is running. diff --git a/tests/test_loop.py b/tests/test_loop.py new file mode 100644 index 0000000..cc3f3b6 --- /dev/null +++ b/tests/test_loop.py @@ -0,0 +1,150 @@ +""" +Some tests for the base loop and asyncio loop. +""" + +import time +import signal +import threading + +from rendercanvas.asyncio import AsyncioLoop +from testutils import run_tests + + +class FakeCanvas: + def __init__(self, refuse_close): + self.refuse_close = refuse_close + self.is_closed = False + + def _rc_close(self): + # Called by the loop to close a canvas + if not self.refuse_close: + self.is_closed = True + + +class FakeScheduler: + def __init__(self, refuse_close=False): + self._canvas = FakeCanvas(refuse_close) + + def get_canvas(self): + if self._canvas and not self._canvas.is_closed: + return self._canvas + + def close_canvas(self): + self._canvas = None + + +def test_run_loop_and_close_bc_no_canvases(): + # Run the loop without canvas; closes immediately + loop = AsyncioLoop() + loop.call_later(0.1, print, "hi from loop!") + loop.run() + + +def test_run_loop_and_close_canvases(): + # After all canvases are closed, it can take one tick before its detected. + + loop = AsyncioLoop() + + scheduler1 = FakeScheduler() + scheduler2 = FakeScheduler() + loop._register_scheduler(scheduler1) + loop._register_scheduler(scheduler2) + + loop.call_later(0.1, print, "hi from loop!") + loop.call_later(0.1, scheduler1.close_canvas) + loop.call_later(0.3, scheduler2.close_canvas) + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.45 + + +def test_run_loop_and_close_with_method(): + # Close, then wait at most one tick to close canvases, and another to conform close. + loop = AsyncioLoop() + + scheduler1 = FakeScheduler() + scheduler2 = FakeScheduler() + loop._register_scheduler(scheduler1) + loop._register_scheduler(scheduler2) + + loop.call_later(0.1, print, "hi from loop!") + loop.call_later(0.3, loop.stop) + + t0 = time.time() + loop.run() + et = time.time() - t0 + + print(et) + assert 0.25 < et < 0.55 + + +def test_run_loop_and_interrupt(): + # Interrupt, calls close, can take one tick to close canvases, and anoter to conform close. + + loop = AsyncioLoop() + + scheduler1 = FakeScheduler() + scheduler2 = FakeScheduler() + loop._register_scheduler(scheduler1) + loop._register_scheduler(scheduler2) + + loop.call_later(0.1, print, "hi from loop!") + + def interrupt_soon(): + time.sleep(0.3) + signal.raise_signal(signal.SIGINT) + + t = threading.Thread(target=interrupt_soon) + t.start() + + t0 = time.time() + loop.run() + et = time.time() - t0 + t.join() + + print(et) + assert 0.25 < et < 0.55 + + +def test_run_loop_and_interrupt_harder(): + # In the next tick after the second interupt, it stops the loop without closing the canvases + + loop = AsyncioLoop() + + scheduler1 = FakeScheduler(refuse_close=True) + scheduler2 = FakeScheduler(refuse_close=True) + loop._register_scheduler(scheduler1) + loop._register_scheduler(scheduler2) + + loop.call_later(0.1, print, "hi from loop!") + + def interrupt_soon(): + time.sleep(0.3) + signal.raise_signal(signal.SIGINT) + time.sleep(0.3) + signal.raise_signal(signal.SIGINT) + + t = threading.Thread(target=interrupt_soon) + t.start() + + t0 = time.time() + loop.run() + et = time.time() - t0 + t.join() + + print(et) + assert 0.6 < et < 0.75 + + +def test_loop_threaded(): + t = threading.Thread(target=test_run_loop_and_close_with_method) + t.start() + t.join() + + +if __name__ == "__main__": + run_tests(globals())