From 163b00770b324cdbeb7dcd38f22067db96213a1d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 18 Nov 2024 23:18:32 +0100 Subject: [PATCH 1/6] Handle ctrl-c --- rendercanvas/_loop.py | 47 +++++++++++++++++++++++++++++++++++------ rendercanvas/asyncio.py | 11 +++------- rendercanvas/glfw.py | 22 ++++++++----------- rendercanvas/qt.py | 4 ++-- rendercanvas/wx.py | 11 +++++++--- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index b820e81..7dd57b2 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -3,6 +3,7 @@ """ import time +import signal import weakref from ._coreutils import log_exception, BaseEnum @@ -143,6 +144,7 @@ class BaseLoop: def __init__(self): self._schedulers = set() + self._is_inside_run = False self._stop_when_no_canvases = False # The loop object runs a lightweight timer for a few reasons: @@ -173,7 +175,8 @@ def _tick(self): for scheduler in schedulers_to_close: self._schedulers.discard(scheduler) - # Check whether we must stop the loop + # Check whether we must stop the loop. Note that if the loop is not + # actually running, but is in an interactive mode, this has no effect. if self._stop_when_no_canvases and not self._schedulers: self.stop() @@ -219,19 +222,50 @@ def run(self, stop_when_no_canvases=True): 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. """ + + # Cannot run if already running + if self._is_inside_run: + raise RuntimeError("loop.run() is not reentrant.") + self._stop_when_no_canvases = bool(stop_when_no_canvases) - self._rc_run() + + # Handle interrupts + try: + prev_int_handler = signal.signal(signal.SIGINT, self.__on_interrupt) + except ValueError: + prev_int_handler = None + + # Run. We could be in this loop for a loong time. Or we can + # exit emmidiately if the backend already has an (interactive) + # event loop. In the latter case, see how we disable the signal + # again, so we don't interfere with that loop. + self._is_inside_run = True + try: + self._rc_run() + except KeyboardInterrupt: + pass + finally: + self._is_inside_run = False + if prev_int_handler is not None: + signal.signal(signal.SIGINT, prev_int_handler) def stop(self): """Stop the currently running event loop.""" - self._rc_stop() + # Only take action when we're inside the run() method + if self._is_inside_run: + self._rc_stop() + + def __on_interrupt(self, *args): + self.stop() 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 +273,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() 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/glfw.py b/rendercanvas/glfw.py index 209a46a..12e2cc4 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -12,7 +12,6 @@ import sys import time import atexit -import weakref import glfw @@ -172,9 +171,6 @@ 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)) @@ -306,6 +302,8 @@ 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() @@ -340,7 +338,6 @@ def _check_close(self, *args): 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 @@ -529,26 +526,25 @@ 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): 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..1366e32 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -500,11 +500,16 @@ def _rc_call_soon(self, delay, callback, *args): def _rc_run(self): self._frame_to_keep_loop_alive = wx.Frame(None) - self._app.MainLoop() + try: + self._app.MainLoop() + finally: + self._rc_stop() def _rc_stop(self): - self._frame_to_keep_loop_alive.Destroy() - _frame_to_keep_loop_alive = None + # Stop the loop by closing the last frame + if self._frame_to_keep_loop_alive: + self._frame_to_keep_loop_alive.Destroy() + self._frame_to_keep_loop_alive = None def _rc_gui_poll(self): pass # We can assume the wx loop is running. From 75809a3da2aab3f93b1c0df1ee5009650beb3bc0 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 19 Nov 2024 08:53:52 +0100 Subject: [PATCH 2/6] test and small refactor --- tests/test_loop.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/test_loop.py diff --git a/tests/test_loop.py b/tests/test_loop.py new file mode 100644 index 0000000..bc458a3 --- /dev/null +++ b/tests/test_loop.py @@ -0,0 +1,29 @@ +""" +Some tests for the base loop and asyncio loop. +""" + +import threading + +from rendercanvas.asyncio import AsyncioLoop +from testutils import run_tests + + +def run_loop_briefly(): + loop = AsyncioLoop() + loop.call_later(0.1, print, "hi from loop!") + loop.call_later(0.2, loop.stop) + loop.run() + + +def test_loop_main(): + run_loop_briefly() + + +def test_loop_threaded(): + t = threading.Thread(target=run_loop_briefly) + t.start() + t.join() + + +if __name__ == "__main__": + run_tests(globals()) From 72aa825521cd096dd13087a12a914aeff00bf6c1 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 20 Nov 2024 13:17:19 +0100 Subject: [PATCH 3/6] Refactor loop/canvas closing a bit --- rendercanvas/_loop.py | 130 ++++++++++++++++++++++++++++++------------ rendercanvas/base.py | 10 +--- rendercanvas/glfw.py | 33 +++++------ 3 files changed, 113 insertions(+), 60 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 7dd57b2..3885c3f 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -6,12 +6,21 @@ 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 `. +) +# if sys.platform == "win32": +# HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break. + + class BaseTimer: """The Base class for a timer object. @@ -143,9 +152,9 @@ class BaseLoop: _TimerClass = None # subclases must set this def __init__(self): - self._schedulers = set() + self._schedulers = [] self._is_inside_run = False - self._stop_when_no_canvases = 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). @@ -158,27 +167,52 @@ 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. Note that if the loop is not - # actually running, but is in an interactive mode, this has no effect. - 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. @@ -216,47 +250,71 @@ 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. """ + # 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.") - self._stop_when_no_canvases = bool(stop_when_no_canvases) + # Register interrupt handler + prev_sig_handlers = self.__setup_interrupt() - # Handle interrupts - try: - prev_int_handler = signal.signal(signal.SIGINT, self.__on_interrupt) - except ValueError: - prev_int_handler = None - - # Run. We could be in this loop for a loong time. Or we can - # exit emmidiately if the backend already has an (interactive) - # event loop. In the latter case, see how we disable the signal - # again, so we don't interfere with that loop. + # 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() - except KeyboardInterrupt: - pass finally: self._is_inside_run = False - if prev_int_handler is not None: - signal.signal(signal.SIGINT, prev_int_handler) + for sig, cb in prev_sig_handlers.items(): + signal.signal(sig, cb) def stop(self): - """Stop the currently running event loop.""" + """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._rc_stop() + 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() - def __on_interrupt(self, *args): - 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. 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 12e2cc4..b348701 100644 --- a/rendercanvas/glfw.py +++ b/rendercanvas/glfw.py @@ -174,7 +174,7 @@ def __init__(self, *args, present_method=None, **kwargs): # 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( @@ -230,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): @@ -305,8 +315,9 @@ 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 @@ -330,19 +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): - 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 = { @@ -539,6 +537,9 @@ def _terminate_glfw(self): 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() From f873ad915e734e3a3f2c2eb5a578350de19fa07f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 20 Nov 2024 13:22:46 +0100 Subject: [PATCH 4/6] tweak for wx --- rendercanvas/wx.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 1366e32..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,17 +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) - try: - self._app.MainLoop() - finally: - self._rc_stop() + self._app.MainLoop() def _rc_stop(self): - # Stop the loop by closing the last frame - if self._frame_to_keep_loop_alive: - self._frame_to_keep_loop_alive.Destroy() - self._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. From 7bfb572fda0922ed9921c4062be75a5f6a4c5774 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 21 Nov 2024 11:02:33 +0100 Subject: [PATCH 5/6] fix tests --- rendercanvas/_loop.py | 11 ++-- tests/test_loop.py | 131 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 9 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 3885c3f..5b2142b 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -176,7 +176,7 @@ def get_canvases(self): schedulers = [] for scheduler in self._schedulers: - canvas = scheduler._get_canvas() + canvas = scheduler.get_canvas() if canvas is not None: canvases.append(canvas) schedulers.append(scheduler) @@ -199,7 +199,6 @@ def _tick(self): return # Should we stop? - if not self._schedulers: # Stop when there are no more canvases self._rc_stop() @@ -265,6 +264,9 @@ def run(self): 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() @@ -448,7 +450,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 @@ -489,7 +492,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/tests/test_loop.py b/tests/test_loop.py index bc458a3..cc3f3b6 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -2,25 +2,146 @@ 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 -def run_loop_briefly(): +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!") - loop.call_later(0.2, loop.stop) + + 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() -def test_loop_main(): - run_loop_briefly() + print(et) + assert 0.6 < et < 0.75 def test_loop_threaded(): - t = threading.Thread(target=run_loop_briefly) + t = threading.Thread(target=test_run_loop_and_close_with_method) t.start() t.join() From bbd08e7f917de34ccce5e903cd61e150feb5d5a8 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 21 Nov 2024 09:16:07 +0100 Subject: [PATCH 6/6] Remove commented lines. These seem to have no effect (turned on or off) --- rendercanvas/_loop.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 5b2142b..15ae8ec 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -17,8 +17,6 @@ signal.SIGINT, # Unix signal 2. Sent by Ctrl+C. signal.SIGTERM, # Unix signal 15. Sent by `kill `. ) -# if sys.platform == "win32": -# HANDLED_SIGNALS += (signal.SIGBREAK,) # Windows signal 21. Sent by Ctrl+Break. class BaseTimer: