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()