From 4cf65953607b2d160b621243641033ded62c1c76 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 11:25:25 +0100 Subject: [PATCH 1/8] Streamline internal sleep and call_later --- rendercanvas/_loop.py | 5 +++- rendercanvas/_scheduler.py | 6 ++--- rendercanvas/qt.py | 38 +----------------------------- rendercanvas/raw.py | 7 +++++- rendercanvas/utils/asyncadapter.py | 5 ++++ rendercanvas/utils/asyncs.py | 19 ++++++++++++--- rendercanvas/wx.py | 7 ------ tests/test_loop.py | 1 + tests/test_offscreen.py | 1 - 9 files changed, 36 insertions(+), 53 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 6221ed5..e5fa64e 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -222,7 +222,7 @@ def add_task( self, async_func: Callable[[], Coroutine], *args: Any, - name: str = "unnamed", + name: str | None = None, ) -> None: """Run an async function in the event-loop. @@ -231,6 +231,8 @@ def add_task( """ if not (callable(async_func) and iscoroutinefunction(async_func)): raise TypeError("add_task() expects an async function.") + if name is None: + name = async_func.__name__ async def wrapper(): with log_exception(f"Error in {name} task:"): @@ -606,6 +608,7 @@ def _rc_call_later(self, delay, callback): internally. * Return None. """ + # Default that always works, subclasses probably want to do something different when delay <= 0 call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback) def _rc_call_soon_threadsafe(self, callback): diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 1a196f8..5e0be96 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -6,7 +6,7 @@ import weakref from ._enums import UpdateMode -from .utils.asyncs import sleep, Event +from .utils.asyncs import precise_sleep, Event class Scheduler: @@ -104,7 +104,7 @@ async def __scheduler_task(self): last_tick_time = 0 # Little startup sleep - await sleep(0.05) + await precise_sleep(0.02) while True: # Determine delay @@ -119,7 +119,7 @@ async def __scheduler_task(self): # Wait. Even if delay is zero, it gives control back to the loop, # allowing other tasks to do work. - await sleep(max(0, sleep_time)) + await precise_sleep(max(0, sleep_time)) # Below is the "tick" diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index a9a38c1..5d92ab6 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -18,12 +18,8 @@ get_alt_x11_display, get_alt_wayland_display, select_qt_lib, - IS_WIN, - call_later_from_thread, ) -USE_THREADED_TIMER = False # Default False, because we use Qt PreciseTimer instead - # Select GUI toolkit libname, already_had_app_on_import = select_qt_lib() @@ -195,22 +191,6 @@ def enable_hidpi(): ) -class CallbackWrapperHelper(QtCore.QObject): - """Little helper for _rc_call_later with PreciseTimer""" - - def __init__(self, pool, cb): - super().__init__() - self.pool = pool - self.pool.add(self) - self.cb = cb - - @Slot() - def callback(self): - self.pool.discard(self) - self.pool = None - self.cb() - - class CallerHelper(QtCore.QObject): """Little helper for _rc_call_soon_threadsafe""" @@ -241,7 +221,6 @@ def _rc_init(self): ) if already_had_app_on_import: self._mark_as_interactive() - self._callback_pool = set() self._caller = CallerHelper() def _rc_run(self): @@ -279,23 +258,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): if delay <= 0: QtCore.QTimer.singleShot(0, callback) - elif USE_THREADED_TIMER: - call_later_from_thread(delay, self._caller.call.emit, callback) - elif IS_WIN: - # To get high-precision call_later in Windows, we can either use the threaded - # approach, or use Qt's own high-precision timer. We default to the latter, - # which seems slightly more accurate. It's a bit involved, because we need to - # make use of slots, and the signature for singleShot is not well-documented and - # differs between PyQt/PySide. - callback_wrapper = CallbackWrapperHelper(self._callback_pool, callback) - wrapper_args = (callback_wrapper.callback,) - if is_pyside: - wrapper_args = (callback_wrapper, QtCore.SLOT("callback()")) - QtCore.QTimer.singleShot( - int(max(delay * 1000, 1)), PreciseTimer, *wrapper_args - ) + # or self._caller.call.emit(callback) else: - # Normal timer. Already precise for MacOS/Linux. QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) def _rc_call_soon_threadsafe(self, callback): diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index a7502be..1e24461 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -47,7 +47,12 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback) + if delay <= 0: + self._queue.put(callback) + else: + # Using call_later_from_thread keeps the loop super-simple. + # Note that its high-precision-on-Windows feature is not why we use it; precision is handled in asyns.py. + call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback) def _rc_call_soon_threadsafe(self, callback): self._queue.put(callback) diff --git a/rendercanvas/utils/asyncadapter.py b/rendercanvas/utils/asyncadapter.py index 05d4664..3e1c6da 100644 --- a/rendercanvas/utils/asyncadapter.py +++ b/rendercanvas/utils/asyncadapter.py @@ -29,6 +29,7 @@ def __await__(self): async def sleep(delay): """Async sleep for delay seconds.""" + # This effectively runs via the call_later_func passed to the task. await Sleeper(delay) @@ -39,6 +40,10 @@ def __init__(self): self._is_set = False self._tasks = [] + def __repr__(self): + set = "set" if self._is_set else "unset" + return f"<{self.__module__}.{self.__class__.__name__} object ({set}) at {hex(id(self))}>" + async def wait(self): if self._is_set: pass diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 59b0506..9ca3201 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -85,11 +85,24 @@ def detect_current_call_soon_threadsafe(): async def sleep(delay): - """Generic async sleep. Works with trio, asyncio and rendercanvas-native. + """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" + # Note that we could decide to run all tasks (created via loop.add_task) + # via the asyncadapter, which would converge the async code paths more. + # But we deliberately chose for the async bits to be 'real'; on asyncio + # they are actual asyncio tasks using e.g. asyncio.Event. + + libname = detect_current_async_lib() + if libname is not None: + sleep = sys.modules[libname].sleep + await sleep(delay) - On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the ``sleep()`` of asyncio/trio. - """ +async def precise_sleep(delay): + """Generic async sleep that is precise. Works with trio, asyncio and rendercanvas-native. + + On Windows, OS timers are notoriously imprecise, with a resolution of 15.625 ms (64 ticks per second). + This function, when on Windows, uses a thread to sleep with a much better precision. + """ if delay > 0 and USE_THREADED_TIMER: call_soon_threadsafe = detect_current_call_soon_threadsafe() if call_soon_threadsafe: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 03a2a24..0609c91 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -17,8 +17,6 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, - IS_WIN, - call_later_from_thread, ) from .base import ( WrapperRenderCanvas, @@ -28,9 +26,6 @@ ) -USE_THREADED_TIMER = IS_WIN - - BUTTON_MAP = { wx.MOUSE_BTN_LEFT: 1, wx.MOUSE_BTN_RIGHT: 2, @@ -189,8 +184,6 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): if delay <= 0: wx.CallAfter(callback) - elif USE_THREADED_TIMER: - call_later_from_thread(delay, wx.CallAfter, callback) else: wx.CallLater(int(max(delay * 1000, 1)), callback) diff --git a/tests/test_loop.py b/tests/test_loop.py index a6044c9..3c7e92f 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -181,6 +181,7 @@ def test_loop_deletion1(SomeLoop): assert loop_ref() is None +@pytest.mark.filterwarnings("ignore:.*was never awaited") @pytest.mark.parametrize("SomeLoop", loop_classes) def test_loop_deletion2(SomeLoop): # Loops get gc'd when in ready state diff --git a/tests/test_offscreen.py b/tests/test_offscreen.py index cf29daa..96b84b9 100644 --- a/tests/test_offscreen.py +++ b/tests/test_offscreen.py @@ -88,7 +88,6 @@ def check(arg): # When run is called, the task is started, so the delay kicks in from # that moment, so we need to wait here for the 3d to resolve - # The delay starts from time.sleep(0.01) loop.run() assert 3 in ran # call_later nonzero From bcf2188168c61034eb514ab5b1cf4caf651f7e29 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 12:56:36 +0100 Subject: [PATCH 2/8] Add test and tweak tests --- tests/test_asyncadapter.py | 8 ++-- tests/test_asyncs.py | 88 ++++++++++++++++++++++++++++++++++++++ tests/test_scheduling.py | 2 +- 3 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 tests/test_asyncs.py diff --git a/tests/test_asyncadapter.py b/tests/test_asyncadapter.py index 01b6604..3521bae 100644 --- a/tests/test_asyncadapter.py +++ b/tests/test_asyncadapter.py @@ -28,8 +28,8 @@ async def coro(): sleep_time1 = times[1] - times[0] sleep_time2 = times[2] - times[1] - assert 0.04 < sleep_time1 < 0.6 - assert 0.09 < sleep_time2 < 0.12 + assert 0.04 < sleep_time1 < 0.15 + assert 0.09 < sleep_time2 < 0.20 def test_event(): @@ -61,8 +61,8 @@ async def coro2(): sleep_time1 = times[1] - times[0] sleep_time2 = times[2] - times[1] - assert 0.04 < sleep_time1 < 0.6 - assert 0.09 < sleep_time2 < 0.12 + assert 0.04 < sleep_time1 < 0.15 + assert 0.09 < sleep_time2 < 0.20 if __name__ == "__main__": diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py new file mode 100644 index 0000000..2d7b8c7 --- /dev/null +++ b/tests/test_asyncs.py @@ -0,0 +1,88 @@ +""" +Test basics of rendercanvas.utils.asyncs. +""" + +# ruff: noqa: N803 + +import time + +from rendercanvas.asyncio import AsyncioLoop +from rendercanvas.trio import TrioLoop +from rendercanvas.raw import RawLoop + +from rendercanvas.utils import asyncs +from testutils import run_tests + +import pytest + + +loop_classes = [RawLoop, AsyncioLoop, TrioLoop] + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_sleep(SomeLoop): + times = [] + + async def coro(): + times.append(time.perf_counter()) + await asyncs.sleep(0.05) + times.append(time.perf_counter()) + + loop = SomeLoop() + loop.add_task(coro) + loop.run() + + sleep_time1 = times[1] - times[0] + assert 0.04 < sleep_time1 < 0.15 + + +@pytest.mark.parametrize("SomeLoop", loop_classes) +def test_precise_sleep(SomeLoop): + # This test uses the threaded timer on all os's + prev_use_threaded_timer = asyncs.USE_THREADED_TIMER + asyncs.USE_THREADED_TIMER = True + + try: + times = [] + + async def coro(): + times.append(time.perf_counter()) + await asyncs.precise_sleep(0.05) + times.append(time.perf_counter()) + + loop = SomeLoop() + loop.add_task(coro) + loop.run() + + sleep_time1 = times[1] - times[0] + assert 0.04 < sleep_time1 < 0.15 + + finally: + asyncs.USE_THREADED_TIMER = prev_use_threaded_timer + + +def test_event(SomeLoop): + event1 = asyncs.Event() + + times = [] + + async def coro1(): + await asyncs.sleep(0.05) + event1.set() + + async def coro2(): + times.append(time.perf_counter()) + await event1.wait() + times.append(time.perf_counter()) + + loop = SomeLoop() + loop.add_task(coro1) + loop.add_task(coro2) + loop.run() + + sleep_time1 = times[1] - times[0] + assert 0.04 < sleep_time1 < 0.15 + + +if __name__ == "__main__": + run_tests(globals()) diff --git a/tests/test_scheduling.py b/tests/test_scheduling.py index 7cb80cc..f2688dd 100644 --- a/tests/test_scheduling.py +++ b/tests/test_scheduling.py @@ -80,7 +80,7 @@ def test_scheduling_manual(): canvas.request_draw() canvas.active_sleep(0.11) assert canvas.draw_count == 0 - assert canvas.events_count in range(1, 20) + assert canvas.events_count in range(1, 30) # Only when we force one canvas.force_draw() From e9b3cd59dea3acedf9fcf4c3fecdd6f756f7d0f5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 13:05:57 +0100 Subject: [PATCH 3/8] fix that new test --- tests/test_asyncs.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 2d7b8c7..4e00f61 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -61,18 +61,21 @@ async def coro(): asyncs.USE_THREADED_TIMER = prev_use_threaded_timer +@pytest.mark.parametrize("SomeLoop", loop_classes) def test_event(SomeLoop): - event1 = asyncs.Event() + event = None times = [] async def coro1(): await asyncs.sleep(0.05) - event1.set() + event.set() async def coro2(): + nonlocal event + event = asyncs.Event() times.append(time.perf_counter()) - await event1.wait() + await event.wait() times.append(time.perf_counter()) loop = SomeLoop() From 01761abbb168f6030612ad1ec4a7e239b99107e3 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 13:14:22 +0100 Subject: [PATCH 4/8] properly fix test --- rendercanvas/_loop.py | 4 +++- tests/test_asyncs.py | 32 +++++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index e5fa64e..55ffbd3 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -83,6 +83,8 @@ class BaseLoop: """ + _stop_when_no_canvases = True + def __init__(self): self.__tasks = set() # only used by the async adapter self.__canvas_groups = set() @@ -207,7 +209,7 @@ async def _loop_task(self): # Break? canvas_count = len(canvases) del canvases - if not canvas_count: + if not canvas_count and self._stop_when_no_canvases: break finally: diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 4e00f61..7db15e0 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -27,13 +27,19 @@ async def coro(): times.append(time.perf_counter()) await asyncs.sleep(0.05) times.append(time.perf_counter()) + await asyncs.sleep(0.1) + times.append(time.perf_counter()) loop = SomeLoop() + loop._stop_when_no_canvases = False + loop.call_later(0.25, loop.stop) loop.add_task(coro) loop.run() sleep_time1 = times[1] - times[0] + sleep_time2 = times[2] - times[1] assert 0.04 < sleep_time1 < 0.15 + assert 0.09 < sleep_time2 < 0.20 @pytest.mark.parametrize("SomeLoop", loop_classes) @@ -49,13 +55,19 @@ async def coro(): times.append(time.perf_counter()) await asyncs.precise_sleep(0.05) times.append(time.perf_counter()) + await asyncs.precise_sleep(0.1) + times.append(time.perf_counter()) loop = SomeLoop() + loop._stop_when_no_canvases = False + loop.call_later(0.25, loop.stop) loop.add_task(coro) loop.run() sleep_time1 = times[1] - times[0] + sleep_time2 = times[2] - times[1] assert 0.04 < sleep_time1 < 0.15 + assert 0.09 < sleep_time2 < 0.20 finally: asyncs.USE_THREADED_TIMER = prev_use_threaded_timer @@ -63,28 +75,38 @@ async def coro(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_event(SomeLoop): - event = None + event1 = None + event2 = None times = [] async def coro1(): await asyncs.sleep(0.05) - event.set() + event1.set() + await asyncs.sleep(0.1) + event2.set() async def coro2(): - nonlocal event - event = asyncs.Event() + nonlocal event1, event2 + event1 = asyncs.Event() + event2 = asyncs.Event() + times.append(time.perf_counter()) + await event1.wait() times.append(time.perf_counter()) - await event.wait() + await event2.wait() times.append(time.perf_counter()) loop = SomeLoop() + loop._stop_when_no_canvases = False + loop.call_later(0.25, loop.stop) loop.add_task(coro1) loop.add_task(coro2) loop.run() sleep_time1 = times[1] - times[0] + sleep_time2 = times[2] - times[1] assert 0.04 < sleep_time1 < 0.15 + assert 0.09 < sleep_time2 < 0.20 if __name__ == "__main__": From 7de9968226a40b2a79a94826f4ae8474e8f54af5 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 13:17:42 +0100 Subject: [PATCH 5/8] sigh leeway for osx runner --- tests/test_asyncs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 7db15e0..5231919 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -60,7 +60,7 @@ async def coro(): loop = SomeLoop() loop._stop_when_no_canvases = False - loop.call_later(0.25, loop.stop) + loop.call_later(0.35, loop.stop) loop.add_task(coro) loop.run() From cfcf3e398dc7819586775983fb4edbc1ca508c7a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 13:20:40 +0100 Subject: [PATCH 6/8] ah, of course --- tests/test_asyncs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 5231919..edbcf61 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -29,10 +29,10 @@ async def coro(): times.append(time.perf_counter()) await asyncs.sleep(0.1) times.append(time.perf_counter()) + loop.stop() loop = SomeLoop() loop._stop_when_no_canvases = False - loop.call_later(0.25, loop.stop) loop.add_task(coro) loop.run() @@ -57,10 +57,10 @@ async def coro(): times.append(time.perf_counter()) await asyncs.precise_sleep(0.1) times.append(time.perf_counter()) + loop.stop() loop = SomeLoop() loop._stop_when_no_canvases = False - loop.call_later(0.35, loop.stop) loop.add_task(coro) loop.run() @@ -95,10 +95,10 @@ async def coro2(): times.append(time.perf_counter()) await event2.wait() times.append(time.perf_counter()) + loop.stop() loop = SomeLoop() loop._stop_when_no_canvases = False - loop.call_later(0.25, loop.stop) loop.add_task(coro1) loop.add_task(coro2) loop.run() From 2e6592c50147898a50425539a3d67af79fcd307b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 13:26:18 +0100 Subject: [PATCH 7/8] sigh --- tests/test_asyncs.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index edbcf61..0368f9a 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -4,6 +4,7 @@ # ruff: noqa: N803 +import os import time from rendercanvas.asyncio import AsyncioLoop @@ -21,6 +22,9 @@ @pytest.mark.parametrize("SomeLoop", loop_classes) def test_sleep(SomeLoop): + + leeway = 0.20 if os.getenv("CI") else 0 + times = [] async def coro(): @@ -38,12 +42,15 @@ async def coro(): sleep_time1 = times[1] - times[0] sleep_time2 = times[2] - times[1] - assert 0.04 < sleep_time1 < 0.15 - assert 0.09 < sleep_time2 < 0.20 + assert 0.04 < sleep_time1 < 0.08 + leeway + assert 0.09 < sleep_time2 < 0.13 + leeway @pytest.mark.parametrize("SomeLoop", loop_classes) def test_precise_sleep(SomeLoop): + + leeway = 0.20 if os.getenv("CI") else 0 + # This test uses the threaded timer on all os's prev_use_threaded_timer = asyncs.USE_THREADED_TIMER asyncs.USE_THREADED_TIMER = True @@ -66,8 +73,8 @@ async def coro(): sleep_time1 = times[1] - times[0] sleep_time2 = times[2] - times[1] - assert 0.04 < sleep_time1 < 0.15 - assert 0.09 < sleep_time2 < 0.20 + assert 0.04 < sleep_time1 < 0.08 + leeway + assert 0.09 < sleep_time2 < 0.13 + leeway finally: asyncs.USE_THREADED_TIMER = prev_use_threaded_timer @@ -75,6 +82,9 @@ async def coro(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_event(SomeLoop): + + leeway = 0.20 if os.getenv("CI") else 0 + event1 = None event2 = None @@ -105,8 +115,8 @@ async def coro2(): sleep_time1 = times[1] - times[0] sleep_time2 = times[2] - times[1] - assert 0.04 < sleep_time1 < 0.15 - assert 0.09 < sleep_time2 < 0.20 + assert 0.04 < sleep_time1 < 0.08 + leeway + assert 0.09 < sleep_time2 < 0.13 + leeway if __name__ == "__main__": From 180aba4df34e77f0a1927107e128c88501f1cf30 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Fri, 19 Dec 2025 13:27:50 +0100 Subject: [PATCH 8/8] ruff --- tests/test_asyncs.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_asyncs.py b/tests/test_asyncs.py index 0368f9a..8077dfb 100644 --- a/tests/test_asyncs.py +++ b/tests/test_asyncs.py @@ -22,7 +22,6 @@ @pytest.mark.parametrize("SomeLoop", loop_classes) def test_sleep(SomeLoop): - leeway = 0.20 if os.getenv("CI") else 0 times = [] @@ -48,7 +47,6 @@ async def coro(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_precise_sleep(SomeLoop): - leeway = 0.20 if os.getenv("CI") else 0 # This test uses the threaded timer on all os's @@ -82,7 +80,6 @@ async def coro(): @pytest.mark.parametrize("SomeLoop", loop_classes) def test_event(SomeLoop): - leeway = 0.20 if os.getenv("CI") else 0 event1 = None