From 6dbb1e1a9146a0853f003b4cd5d1f9dde59e99a2 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 15:36:53 +0100 Subject: [PATCH 01/22] Re-implement precise sleep --- rendercanvas/_scheduler.py | 20 +++----------------- rendercanvas/qt.py | 20 +++++++++++++++++++- rendercanvas/utils/asyncs.py | 23 +++++++++++++++++++++-- 3 files changed, 43 insertions(+), 20 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 5185318..26d52af 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -10,9 +10,6 @@ from .utils.asyncs import sleep, Event -IS_WIN = sys.platform.startswith("win") - - class Scheduler: """Helper class to schedule event processing and drawing.""" @@ -121,20 +118,9 @@ async def __scheduler_task(self): # Determine amount of sleep sleep_time = delay - (time.perf_counter() - last_tick_time) - if IS_WIN: - # On Windows OS-level timers have an in accuracy of 15.6 ms. - # This can cause sleep to take longer than intended. So we sleep - # less, and then do a few small sync-sleeps that have high accuracy. - await sleep(max(0, sleep_time - 0.0156)) - sleep_time = delay - (time.perf_counter() - last_tick_time) - while sleep_time > 0: - time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms - await sleep(0) # Allow other tasks to run but don't wait - sleep_time = delay - (time.perf_counter() - last_tick_time) - else: - # 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)) + # 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)) # Below is the "tick" diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index bafbcd8..4176bad 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -34,6 +34,7 @@ WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled KeyboardModifiers = QtCore.Qt.KeyboardModifier + PreciseTimer = QtCore.Qt.TimerType.PreciseTimer FocusPolicy = QtCore.Qt.FocusPolicy CursorShape = QtCore.Qt.CursorShape Keys = QtCore.Qt.Key @@ -43,6 +44,7 @@ WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled + PreciseTimer = QtCore.Qt.PreciseTimer KeyboardModifiers = QtCore.Qt FocusPolicy = QtCore.Qt CursorShape = QtCore.Qt @@ -180,6 +182,20 @@ def enable_hidpi(): "Qt falling back to offscreen rendering, which is less performant." ) +class CallbackWrapper(QtCore.QObject): + + def __init__(self, pool, cb): + super().__init__() + self.pool = pool + self.pool.add(self) + self.cb = cb + + @QtCore.Slot() + def callback(self): + self.pool.discard(self) + self.pool = None + self.cb() + class QtLoop(BaseLoop): _app = None @@ -192,6 +208,7 @@ def _rc_init(self): self._app = QtWidgets.QApplication([]) if already_had_app_on_import: self._mark_as_interactive() + self._callback_pool = set() def _rc_run(self): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -228,7 +245,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): delay_ms = int(max(0, delay * 1000)) - QtCore.QTimer.singleShot(delay_ms, callback) + callback_wrapper = CallbackWrapper(self._callback_pool, callback) + QtCore.QTimer.singleShot(delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()")) loop = QtLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 3307885..193ada6 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -11,15 +11,34 @@ """ +from time import sleep as time_sleep import sys import sniffio +IS_WIN = sys.platform.startswith("win") + +thread_pool = None + + +def get_thread_pool_executor(): + global thread_pool + if thread_pool is not None: + + from concurrent.futures import ThreadPoolExecutor + thread_pool = ThreadPoolExecutor(16, "rendercanvas-threadpool") + return thread_pool + + async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" libname = sniffio.current_async_library() - sleep = sys.modules[libname].sleep - await sleep(delay) + if IS_WIN and libname == 'asyncio': + executor = get_thread_pool_executor() + await sys.modules[libname].get_running_loop().run_in_executor(executor, time_sleep, delay) + else: + sleep = sys.modules[libname].sleep + await sleep(delay) class Event: From ecd3804745a4bb6ba21755bfa44d48e022689a8a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 16:16:21 +0100 Subject: [PATCH 02/22] improve accuracy of raw loop --- rendercanvas/raw.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 351144d..48d52f2 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -46,8 +46,11 @@ def _rc_init(self): pass def _rc_run(self): + perf_counter = time.perf_counter + event = self._event + while not self._should_stop: - self._event.clear() + event.clear() # Get wrapper for callback that is first to be called try: @@ -61,11 +64,18 @@ def _rc_run(self): else: # Wait until its time for it to be called # Note that on Windows, the accuracy of the timeout is 15.6 ms - wait_time = wrapper.time - time.perf_counter() - self._event.wait(max(wait_time, 0)) + sleep_time = wrapper.time - perf_counter() + sleep_time = max(0, sleep_time - 0.0156) + event.wait(sleep_time) + + # Wait some more + sleep_time = wrapper.time - perf_counter() + while not event.is_set() and sleep_time > 0: + time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms + sleep_time = wrapper.time - perf_counter() # Put it back or call it? - if time.perf_counter() < wrapper.time: + if perf_counter() < wrapper.time: heapq.heappush(self._queue, wrapper) elif wrapper.callback is not None: try: From b91a4ace9fde032cf8046601279fc7b97ae70880 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 16:17:42 +0100 Subject: [PATCH 03/22] ruff --- rendercanvas/_scheduler.py | 1 - rendercanvas/qt.py | 6 ++++-- rendercanvas/utils/asyncs.py | 10 +++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/rendercanvas/_scheduler.py b/rendercanvas/_scheduler.py index 26d52af..1a196f8 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -2,7 +2,6 @@ The scheduler class/loop. """ -import sys import time import weakref diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 4176bad..c05fc06 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -182,8 +182,8 @@ def enable_hidpi(): "Qt falling back to offscreen rendering, which is less performant." ) -class CallbackWrapper(QtCore.QObject): +class CallbackWrapper(QtCore.QObject): def __init__(self, pool, cb): super().__init__() self.pool = pool @@ -246,7 +246,9 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): delay_ms = int(max(0, delay * 1000)) callback_wrapper = CallbackWrapper(self._callback_pool, callback) - QtCore.QTimer.singleShot(delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()")) + QtCore.QTimer.singleShot( + delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()") + ) loop = QtLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 193ada6..5de9db2 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -24,8 +24,8 @@ def get_thread_pool_executor(): global thread_pool if thread_pool is not None: - from concurrent.futures import ThreadPoolExecutor + thread_pool = ThreadPoolExecutor(16, "rendercanvas-threadpool") return thread_pool @@ -33,9 +33,13 @@ def get_thread_pool_executor(): async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" libname = sniffio.current_async_library() - if IS_WIN and libname == 'asyncio': + if IS_WIN and libname == "asyncio": executor = get_thread_pool_executor() - await sys.modules[libname].get_running_loop().run_in_executor(executor, time_sleep, delay) + await ( + sys.modules[libname] + .get_running_loop() + .run_in_executor(executor, time_sleep, delay) + ) else: sleep = sys.modules[libname].sleep await sleep(delay) From 39903dcd0b6a386d0580dee7f4113bc7d0f4a257 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Mon, 17 Nov 2025 16:22:17 +0100 Subject: [PATCH 04/22] docs --- rendercanvas/utils/asyncs.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 5de9db2..94149dd 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -16,7 +16,7 @@ import sniffio -IS_WIN = sys.platform.startswith("win") +IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide thread_pool = None @@ -31,7 +31,10 @@ def get_thread_pool_executor(): async def sleep(delay): - """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" + """Generic async sleep. Works with trio, asyncio and rendercanvas-native. + + For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. + """ libname = sniffio.current_async_library() if IS_WIN and libname == "asyncio": executor = get_thread_pool_executor() From dd3b3302f7eb4df97c8ebeb15e56b9052e6ba2b9 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 13:16:19 +0100 Subject: [PATCH 05/22] Try a scheduler thread util --- rendercanvas/_coreutils.py | 116 +++++++++++++++++++++++++++++++++++ rendercanvas/utils/asyncs.py | 27 +++++--- tests/test_utils.py | 57 +++++++++++++++++ 3 files changed, 192 insertions(+), 8 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 9114462..6c567b5 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -6,12 +6,21 @@ import re import sys import time +import heapq +import queue import weakref import logging +import threading import ctypes.util from contextlib import contextmanager +# %% Constants + + +IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide + + # %% Logging @@ -93,6 +102,113 @@ def proxy(*args, **kwargs): return proxy +# %% Helper for scheduling call-laters + + +class SchedulerTimeoutThread(threading.Thread): + class Item: + def __init__(self, index, time, callback, args): + self.index = index + self.time = time # measured in time.perf_counter + self.callback = callback + self.args = args + + def __lt__(self, other): + return (self.time, self.index) < (other.time, other.index) + + def cancel(self): + self.callback = None + self.args = None + + def __init__(self): + super().__init__() + self._queue = queue.SimpleQueue() + self._count = 0 + self._shutdown = False + self.daemon = True # don't let this thread prevent shutdown + self.start() + + def call_later_from_thread(self, delay, callback, *args): + """In delay seconds, call the callback from the scheduling thread.""" + self._count += 1 + item = SchedulerTimeoutThread.Item( + self._count, time.perf_counter() + float(delay), callback, args + ) + self._queue.put(item) + + def run(self): + perf_counter = time.perf_counter + Empty = queue.Empty # noqa: N806 + q = self._queue + priority = [] + is_win = IS_WIN + + wait_until = None + + while True: + # == Wait for input + + if wait_until is None: + # Nothing to do but wait + new_item = q.get(True, None) + elif not is_win: + # Wait for as long we can + try: + new_item = q.get(True, max(0, wait_until - perf_counter())) + except Empty: + new_item = None + else: + # Trickery to work around limited Windows timer precision + try: + new_item = q.get(True, max(0, wait_until - perf_counter() - 0.0156)) + except Empty: + new_item = None + while perf_counter() < wait_until: + time.sleep(0.001) # sleep hard for 1ms + try: + new_item = q.get_nowait() + break + except Empty: + pass + + # Put it in our priority queue + if new_item is not None: + heapq.heappush(priority, new_item) + + del new_item + + # == Process items until we have to wait + + item = None + while True: + # Get item that is up next + try: + item = heapq.heappop(priority) + except IndexError: + wait_until = None + break + + # If it's not yet time for the item, put it back, and go wait + if perf_counter() < item.time: + heapq.heappush(priority, item) + wait_until = item.time + break + + # Otherwise, handle the callback + try: + item.callback(*item.args) + except Exception as err: + logger.error(f"Error in callback: {err}") + + # Clear + item.cancel() + + del item + + +scheduler_timeout_thread = SchedulerTimeoutThread() + + # %% lib support diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 94149dd..3515a83 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -15,8 +15,8 @@ import sys import sniffio +from .._coreutils import IS_WIN, scheduler_timeout_thread -IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide thread_pool = None @@ -36,13 +36,24 @@ async def sleep(delay): For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. """ libname = sniffio.current_async_library() - if IS_WIN and libname == "asyncio": - executor = get_thread_pool_executor() - await ( - sys.modules[libname] - .get_running_loop() - .run_in_executor(executor, time_sleep, delay) - ) + # if IS_WIN and libname == "asyncio" and delay > 0: + if True and libname == "asyncio" and delay > 0: + if True: + asyncio = sys.modules[libname] + loop = asyncio.get_running_loop() + event = asyncio.Event() + offset = 0.002 # there is some overhead for going to a thread and back + scheduler_timeout_thread.call_later_from_thread( + delay - offset, loop.call_soon_threadsafe, event.set + ) + await event.wait() + else: + executor = get_thread_pool_executor() + await ( + sys.modules[libname] + .get_running_loop() + .run_in_executor(executor, time_sleep, delay) + ) else: sleep = sys.modules[libname].sleep await sleep(delay) diff --git a/tests/test_utils.py b/tests/test_utils.py index ebe43b2..f32635f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import gc +import time import rendercanvas from testutils import run_tests, is_pypy @@ -38,5 +39,61 @@ def bar(self): assert len(xx) == 3 # f2 is gone! +def test_call_later_thread(): + t = rendercanvas._coreutils.scheduler_timeout_thread + + results = [] + + # Call now + t.call_later_from_thread(0, results.append, 5) + + time.sleep(0.01) + assert results == [5] + + # Call later + t.call_later_from_thread(0.5, results.append, 5) + + time.sleep(0.1) + assert results == [5] + + time.sleep(0.5) + assert results == [5, 5] + + # Call multiple at same time + results.clear() + t.call_later_from_thread(0, results.append, 1) + t.call_later_from_thread(0, results.append, 2) + t.call_later_from_thread(0, results.append, 3) + t.call_later_from_thread(0.1, results.append, 4) + t.call_later_from_thread(0.1, results.append, 5) + t.call_later_from_thread(0.1, results.append, 6) + + time.sleep(0.11) + assert results == [1, 2, 3, 4, 5, 6] + + # Out of order + + def set(x): + results.append((x, time.perf_counter())) + + results.clear() + t.call_later_from_thread(0.9, set, 1) + t.call_later_from_thread(0.8, set, 2) + t.call_later_from_thread(0.41, set, 3) + t.call_later_from_thread(0.40, set, 4) + t.call_later_from_thread(0.11, set, 5) + t.call_later_from_thread(0.10, set, 6) + + now = time.perf_counter() + time.sleep(1.1) + + indices = [r[0] for r in results] + times = [r[1] - now for r in results] + + assert indices == [6, 5, 4, 3, 2, 1] + assert times[1] - times[0] < 0.015 + assert times[2] - times[3] < 0.015 + + if __name__ == "__main__": run_tests(globals()) From 2b74282de793bfc6dbfbb013c9e362bca6745887 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 23:11:10 +0100 Subject: [PATCH 06/22] improvinh --- rendercanvas/_coreutils.py | 41 ++++++++++++++++++++++++------------ rendercanvas/utils/asyncs.py | 14 ++++++------ tests/test_utils.py | 2 +- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 6c567b5..8411f21 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -105,7 +105,14 @@ def proxy(*args, **kwargs): # %% Helper for scheduling call-laters -class SchedulerTimeoutThread(threading.Thread): +class CallLaterThread(threading.Thread): + """A thread object that can be used to do "call later" from a dedicated thread. + + Care is taken to realize precise timing, so it can be used to implement + precise sleeping and call_later on Windows (to overcome Windows notorious + 15.6ms ticks). + """ + class Item: def __init__(self, index, time, callback, args): self.index = index @@ -131,10 +138,11 @@ def __init__(self): def call_later_from_thread(self, delay, callback, *args): """In delay seconds, call the callback from the scheduling thread.""" self._count += 1 - item = SchedulerTimeoutThread.Item( + item = CallLaterThread.Item( self._count, time.perf_counter() + float(delay), callback, args ) self._queue.put(item) + # TODO: could return a futures.Promise? def run(self): perf_counter = time.perf_counter @@ -144,6 +152,7 @@ def run(self): is_win = IS_WIN wait_until = None + leeway = 0.0005 # a little 0.5ms offset, because we take 1 ms steps while True: # == Wait for input @@ -151,16 +160,14 @@ def run(self): if wait_until is None: # Nothing to do but wait new_item = q.get(True, None) - elif not is_win: - # Wait for as long we can - try: - new_item = q.get(True, max(0, wait_until - perf_counter())) - except Empty: - new_item = None else: - # Trickery to work around limited Windows timer precision + # We wait for the queue with a timeout. But because the timeout is not very precise, + # we wait shorter, and then go in a loop with some hard sleeps. + # Windows has 15.6 ms resolution ticks. But also on other OSes, + # it benefits precision to do the last bit with hard sleeps. + offset = 0.016 if is_win else 0.004 try: - new_item = q.get(True, max(0, wait_until - perf_counter() - 0.0156)) + new_item = q.get(True, max(0, wait_until - perf_counter() - offset)) except Empty: new_item = None while perf_counter() < wait_until: @@ -189,9 +196,10 @@ def run(self): break # If it's not yet time for the item, put it back, and go wait - if perf_counter() < item.time: + item_time_threshold = item.time - leeway + if perf_counter() < item_time_threshold: heapq.heappush(priority, item) - wait_until = item.time + wait_until = item_time_threshold break # Otherwise, handle the callback @@ -206,7 +214,14 @@ def run(self): del item -scheduler_timeout_thread = SchedulerTimeoutThread() +call_later_thread = None + + +def get_call_later_thread(): + global call_later_thread + if call_later_thread is None: + call_later_thread = CallLaterThread() + return call_later_thread # %% lib support diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 3515a83..cb1212d 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -13,9 +13,11 @@ from time import sleep as time_sleep import sys +from concurrent.futures import Future as _Future + import sniffio -from .._coreutils import IS_WIN, scheduler_timeout_thread +from .._coreutils import IS_WIN, get_call_later_thread thread_pool = None @@ -40,13 +42,9 @@ async def sleep(delay): if True and libname == "asyncio" and delay > 0: if True: asyncio = sys.modules[libname] - loop = asyncio.get_running_loop() - event = asyncio.Event() - offset = 0.002 # there is some overhead for going to a thread and back - scheduler_timeout_thread.call_later_from_thread( - delay - offset, loop.call_soon_threadsafe, event.set - ) - await event.wait() + f = _Future() + get_call_later_thread().call_later_from_thread(delay, f.set_result, None) + await asyncio.wrap_future(f) else: executor = get_thread_pool_executor() await ( diff --git a/tests/test_utils.py b/tests/test_utils.py index f32635f..382d8aa 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -40,7 +40,7 @@ def bar(self): def test_call_later_thread(): - t = rendercanvas._coreutils.scheduler_timeout_thread + t = rendercanvas._coreutils.CallLaterThread() results = [] From 8554cdac423153ca0e83762323bc619131d09753 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 23:29:32 +0100 Subject: [PATCH 07/22] Also apply for trio --- rendercanvas/utils/asyncs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index cb1212d..1701118 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -52,6 +52,14 @@ async def sleep(delay): .get_running_loop() .run_in_executor(executor, time_sleep, delay) ) + elif True and libname == "trio" and delay > 0: + trio = sys.modules[libname] + f = _Future() + event = trio.Event() + token = trio.lowlevel.current_trio_token() + get_call_later_thread().call_later_from_thread(delay, token.run_sync_soon, event.set) + await event.wait() + else: sleep = sys.modules[libname].sleep await sleep(delay) From 493924ed355b6aac86eaa446726ca1ce9c4776bb Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Tue, 18 Nov 2025 23:39:32 +0100 Subject: [PATCH 08/22] tiny tweak --- rendercanvas/_coreutils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 8411f21..77332dd 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -152,7 +152,8 @@ def run(self): is_win = IS_WIN wait_until = None - leeway = 0.0005 # a little 0.5ms offset, because we take 1 ms steps + timestep = 0.001 # for doing small sleeps + leeway = timestep / 2 # a little offset so waiting exactly right on average while True: # == Wait for input @@ -171,7 +172,7 @@ def run(self): except Empty: new_item = None while perf_counter() < wait_until: - time.sleep(0.001) # sleep hard for 1ms + time.sleep(timestep) try: new_item = q.get_nowait() break From aba63ccfd70ecd788723777a884046d804d44c8f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 08:56:52 +0100 Subject: [PATCH 09/22] Implement for wx --- rendercanvas/wx.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 09b4cd0..e940125 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -17,6 +17,8 @@ SYSTEM_IS_WAYLAND, get_alt_x11_display, get_alt_wayland_display, + IS_WIN, + get_call_later_thread, ) from .base import ( WrapperRenderCanvas, @@ -179,7 +181,14 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - wx.CallLater(max(int(delay * 1000), 1), callback) + if delay <= 0: + wx.CallAfter(callback) + elif IS_WIN: + get_call_later_thread().call_later_from_thread( + delay, wx.CallAfter, callback + ) + else: + wx.CallLater(max(int(delay * 1000), 1), callback) def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() From 2591017ac34a7ea4555e0362ab33722d1904ca9a Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 10:26:11 +0100 Subject: [PATCH 10/22] Make precise timers and threaded timers work for all qt backends --- rendercanvas/qt.py | 71 +++++++++++++++++++++++++++--------- rendercanvas/utils/asyncs.py | 4 +- rendercanvas/wx.py | 2 +- 3 files changed, 58 insertions(+), 19 deletions(-) diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index c05fc06..ed1c942 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -17,6 +17,8 @@ get_alt_x11_display, get_alt_wayland_display, select_qt_lib, + IS_WIN, + get_call_later_thread, ) @@ -28,28 +30,35 @@ QtWidgets = importlib.import_module(".QtWidgets", libname) # Uncomment the line below to try QtOpenGLWidgets.QOpenGLWidget instead of QWidget # QtOpenGLWidgets = importlib.import_module(".QtOpenGLWidgets", libname) - try: - # pyqt6 - WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose + if libname.startswith("PyQt"): + # PyQt5 or PyQt6 WA_InputMethodEnabled = QtCore.Qt.WidgetAttribute.WA_InputMethodEnabled KeyboardModifiers = QtCore.Qt.KeyboardModifier + WA_PaintOnScreen = QtCore.Qt.WidgetAttribute.WA_PaintOnScreen + WA_DeleteOnClose = QtCore.Qt.WidgetAttribute.WA_DeleteOnClose PreciseTimer = QtCore.Qt.TimerType.PreciseTimer FocusPolicy = QtCore.Qt.FocusPolicy CursorShape = QtCore.Qt.CursorShape - Keys = QtCore.Qt.Key WinIdChange = QtCore.QEvent.Type.WinIdChange - except AttributeError: - # pyside6 + Signal = QtCore.pyqtSignal + Slot = QtCore.pyqtSlot + Keys = QtCore.Qt.Key + is_pyside = False + else: + # Pyside2 or PySide6 + WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled + KeyboardModifiers = QtCore.Qt WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose - WA_InputMethodEnabled = QtCore.Qt.WA_InputMethodEnabled PreciseTimer = QtCore.Qt.PreciseTimer - KeyboardModifiers = QtCore.Qt FocusPolicy = QtCore.Qt CursorShape = QtCore.Qt - Keys = QtCore.Qt WinIdChange = QtCore.QEvent.WinIdChange + Signal = QtCore.Signal + Slot = QtCore.Slot + Keys = QtCore.Qt + is_pyside = True + else: raise ImportError( "Before importing rendercanvas.qt, import one of PySide6/PySide2/PyQt6/PyQt5 to select a Qt toolkit." @@ -183,20 +192,32 @@ def enable_hidpi(): ) -class CallbackWrapper(QtCore.QObject): +class CallbackWrapperHelper(QtCore.QObject): + """Little helper for the high-precision-timer call-laters.""" + def __init__(self, pool, cb): super().__init__() self.pool = pool self.pool.add(self) self.cb = cb - @QtCore.Slot() + @Slot() def callback(self): self.pool.discard(self) self.pool = None self.cb() +class CallerHelper(QtCore.QObject): + """Little helper class for the threaded call-laters.""" + + call = Signal(object) + + def __init__(self): + super().__init__() + self.call.connect(lambda f: f()) + + class QtLoop(BaseLoop): _app = None _we_run_the_loop = False @@ -209,6 +230,7 @@ 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): # Note: we could detect if asyncio is running (interactive session) and wheter @@ -244,11 +266,26 @@ def _rc_add_task(self, async_func, name): return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - delay_ms = int(max(0, delay * 1000)) - callback_wrapper = CallbackWrapper(self._callback_pool, callback) - QtCore.QTimer.singleShot( - delay_ms, PreciseTimer, callback_wrapper, QtCore.SLOT("callback()") - ) + if delay <= 0: + QtCore.QTimer.singleShot(0, callback) + elif IS_WIN: + # Use high precision timer. We can use the threaded approach, + # or use Qt's own high precision timer. + # We chose the latter, which seems slightly more accurate. + if False: + get_call_later_thread().call_later_from_thread( + delay, self._caller.call.emit, callback + ) + else: + 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 + ) + else: + QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) loop = QtLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 1701118..f3b390c 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -57,7 +57,9 @@ async def sleep(delay): f = _Future() event = trio.Event() token = trio.lowlevel.current_trio_token() - get_call_later_thread().call_later_from_thread(delay, token.run_sync_soon, event.set) + get_call_later_thread().call_later_from_thread( + delay, token.run_sync_soon, event.set + ) await event.wait() else: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index e940125..ca01219 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -188,7 +188,7 @@ def _rc_call_later(self, delay, callback): delay, wx.CallAfter, callback ) else: - wx.CallLater(max(int(delay * 1000), 1), callback) + wx.CallLater(int(max(delay * 1000, 1)), callback) def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() From 19aa7a4c65ec01179bbca2cf207046048cfe5aed Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:06:12 +0100 Subject: [PATCH 11/22] Clean up --- rendercanvas/_coreutils.py | 18 ++++++++++------ rendercanvas/qt.py | 34 +++++++++++++++-------------- rendercanvas/utils/asyncs.py | 42 ++++++++++-------------------------- rendercanvas/wx.py | 11 +++++----- 4 files changed, 47 insertions(+), 58 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 77332dd..3871656 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -215,14 +215,20 @@ def run(self): del item -call_later_thread = None +_call_later_thread = None -def get_call_later_thread(): - global call_later_thread - if call_later_thread is None: - call_later_thread = CallLaterThread() - return call_later_thread +def call_later_from_thread(delay: float, callback: object, *args: object): + """Utility that calls a callback after a specified delay, from a separate thread. + + The caller is responsible for the given callback to be thread-safe. + There is one global thread that handles all callbacks. This thread is spawned the first time + that this function is called. + """ + global _call_later_thread + if _call_later_thread is None: + _call_later_thread = CallLaterThread() + return _call_later_thread.call_later_from_thread(delay, callback, *args) # %% lib support diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index ed1c942..50ec151 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -18,9 +18,11 @@ get_alt_wayland_display, select_qt_lib, IS_WIN, - get_call_later_thread, + 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() @@ -268,23 +270,23 @@ 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: - # Use high precision timer. We can use the threaded approach, - # or use Qt's own high precision timer. - # We chose the latter, which seems slightly more accurate. - if False: - get_call_later_thread().call_later_from_thread( - delay, self._caller.call.emit, callback - ) - else: - 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 - ) + # 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 + ) else: + # Normal timer. Already precise for MacOS/Linux. QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index f3b390c..42ce8a3 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -11,25 +11,15 @@ """ -from time import sleep as time_sleep import sys from concurrent.futures import Future as _Future import sniffio -from .._coreutils import IS_WIN, get_call_later_thread +from .._coreutils import IS_WIN, call_later_from_thread -thread_pool = None - - -def get_thread_pool_executor(): - global thread_pool - if thread_pool is not None: - from concurrent.futures import ThreadPoolExecutor - - thread_pool = ThreadPoolExecutor(16, "rendercanvas-threadpool") - return thread_pool +USE_THREADED_TIMER = IS_WIN async def sleep(delay): @@ -38,30 +28,20 @@ async def sleep(delay): For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. """ libname = sniffio.current_async_library() - # if IS_WIN and libname == "asyncio" and delay > 0: - if True and libname == "asyncio" and delay > 0: - if True: - asyncio = sys.modules[libname] - f = _Future() - get_call_later_thread().call_later_from_thread(delay, f.set_result, None) - await asyncio.wrap_future(f) - else: - executor = get_thread_pool_executor() - await ( - sys.modules[libname] - .get_running_loop() - .run_in_executor(executor, time_sleep, delay) - ) - elif True and libname == "trio" and delay > 0: + if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: + asyncio = sys.modules[libname] + f = _Future() + call_later_from_thread(delay, f.set_result, None) + await asyncio.wrap_future(f) + return + elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: trio = sys.modules[libname] f = _Future() event = trio.Event() token = trio.lowlevel.current_trio_token() - get_call_later_thread().call_later_from_thread( - delay, token.run_sync_soon, event.set - ) + call_later_from_thread(delay, token.run_sync_soon, event.set) await event.wait() - + return else: sleep = sys.modules[libname].sleep await sleep(delay) diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index ca01219..7cf7516 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -18,7 +18,7 @@ get_alt_x11_display, get_alt_wayland_display, IS_WIN, - get_call_later_thread, + call_later_from_thread, ) from .base import ( WrapperRenderCanvas, @@ -28,6 +28,9 @@ ) +USE_THREADED_TIMER = IS_WIN + + BUTTON_MAP = { wx.MOUSE_BTN_LEFT: 1, wx.MOUSE_BTN_RIGHT: 2, @@ -183,10 +186,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): if delay <= 0: wx.CallAfter(callback) - elif IS_WIN: - get_call_later_thread().call_later_from_thread( - delay, wx.CallAfter, callback - ) + elif USE_THREADED_TIMER: + call_later_from_thread(delay, wx.CallAfter, callback) else: wx.CallLater(int(max(delay * 1000, 1)), callback) From 9a019534721a97be652239c0dc44e61b3b67306f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:10:13 +0100 Subject: [PATCH 12/22] add comment --- rendercanvas/_coreutils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 3871656..654f4bd 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -224,6 +224,9 @@ def call_later_from_thread(delay: float, callback: object, *args: object): The caller is responsible for the given callback to be thread-safe. There is one global thread that handles all callbacks. This thread is spawned the first time that this function is called. + + Note that this function should only be used in environments where threading is available. + E.g. on Pyodide this will raise ``RuntimeError: can't start new thread``. """ global _call_later_thread if _call_later_thread is None: From 6270abb12010ffc0eb78d47e3b648bfa3dcb6d91 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:21:33 +0100 Subject: [PATCH 13/22] simplify thread code a bit --- rendercanvas/_coreutils.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 654f4bd..b7820be 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -6,13 +6,13 @@ import re import sys import time -import heapq import queue import weakref import logging import threading import ctypes.util from contextlib import contextmanager +from collections import namedtuple # %% Constants @@ -113,6 +113,8 @@ class CallLaterThread(threading.Thread): 15.6ms ticks). """ + Item = namedtuple("Item", ["time", "index", "callback", "args"]) + class Item: def __init__(self, index, time, callback, args): self.index = index @@ -181,7 +183,8 @@ def run(self): # Put it in our priority queue if new_item is not None: - heapq.heappush(priority, new_item) + priority.append(new_item) + priority.sort(reverse=True) del new_item @@ -191,7 +194,7 @@ def run(self): while True: # Get item that is up next try: - item = heapq.heappop(priority) + item = priority.pop(-1) except IndexError: wait_until = None break @@ -199,7 +202,7 @@ def run(self): # If it's not yet time for the item, put it back, and go wait item_time_threshold = item.time - leeway if perf_counter() < item_time_threshold: - heapq.heappush(priority, item) + priority.append(item) wait_until = item_time_threshold break From c06c5c5f1dd21379e11ae3913eff02037c35783f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:31:56 +0100 Subject: [PATCH 14/22] Avoid using Future.set_result, which we are not supposed to be calling --- rendercanvas/_coreutils.py | 1 - rendercanvas/utils/asyncs.py | 11 ++++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index b7820be..389f785 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -144,7 +144,6 @@ def call_later_from_thread(self, delay, callback, *args): self._count, time.perf_counter() + float(delay), callback, args ) self._queue.put(item) - # TODO: could return a futures.Promise? def run(self): perf_counter = time.perf_counter diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 42ce8a3..6945dfa 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -12,7 +12,6 @@ """ import sys -from concurrent.futures import Future as _Future import sniffio @@ -30,18 +29,16 @@ async def sleep(delay): libname = sniffio.current_async_library() if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: asyncio = sys.modules[libname] - f = _Future() - call_later_from_thread(delay, f.set_result, None) - await asyncio.wrap_future(f) - return + loop = asyncio.get_running_loop() + event = asyncio.Event() + call_later_from_thread(delay, loop.call_soon_threadsafe, event.set) + await event.wait() elif libname == "trio" and delay > 0 and USE_THREADED_TIMER: trio = sys.modules[libname] - f = _Future() event = trio.Event() token = trio.lowlevel.current_trio_token() call_later_from_thread(delay, token.run_sync_soon, event.set) await event.wait() - return else: sleep = sys.modules[libname].sleep await sleep(delay) From 5326bab5857b97254d5a375ec8a511f92025405d Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:36:02 +0100 Subject: [PATCH 15/22] clean --- rendercanvas/_coreutils.py | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 389f785..ce4c653 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -106,29 +106,15 @@ def proxy(*args, **kwargs): class CallLaterThread(threading.Thread): - """A thread object that can be used to do "call later" from a dedicated thread. + """An object that can be used to do "call later" from a dedicated thread. Care is taken to realize precise timing, so it can be used to implement - precise sleeping and call_later on Windows (to overcome Windows notorious + precise sleeping and call_later on Windows (to overcome Windows' notorious 15.6ms ticks). """ Item = namedtuple("Item", ["time", "index", "callback", "args"]) - class Item: - def __init__(self, index, time, callback, args): - self.index = index - self.time = time # measured in time.perf_counter - self.callback = callback - self.args = args - - def __lt__(self, other): - return (self.time, self.index) < (other.time, other.index) - - def cancel(self): - self.callback = None - self.args = None - def __init__(self): super().__init__() self._queue = queue.SimpleQueue() @@ -141,7 +127,7 @@ def call_later_from_thread(self, delay, callback, *args): """In delay seconds, call the callback from the scheduling thread.""" self._count += 1 item = CallLaterThread.Item( - self._count, time.perf_counter() + float(delay), callback, args + time.perf_counter() + float(delay), self._count, callback, args ) self._queue.put(item) @@ -211,9 +197,6 @@ def run(self): except Exception as err: logger.error(f"Error in callback: {err}") - # Clear - item.cancel() - del item From eeabd0174481d89413160a468e144543b87cc769 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 11:40:18 +0100 Subject: [PATCH 16/22] comment --- rendercanvas/utils/asyncs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 6945dfa..8511e7c 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -24,7 +24,7 @@ async def sleep(delay): """Generic async sleep. Works with trio, asyncio and rendercanvas-native. - For asyncio on Windows, this uses a special sleep routine that is more accurate than ``asyncio.sleep()``. + On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the standard ``sleep()``. """ libname = sniffio.current_async_library() if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: From 8f6bbdafba9f55020a255d499151062b68aa261b Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 12:08:41 +0100 Subject: [PATCH 17/22] Using the thread, the raw loop can become dead simple --- rendercanvas/_coreutils.py | 2 +- rendercanvas/raw.py | 73 +++++--------------------------------- 2 files changed, 10 insertions(+), 65 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index ce4c653..a6132e6 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -195,7 +195,7 @@ def run(self): try: item.callback(*item.args) except Exception as err: - logger.error(f"Error in callback: {err}") + logger.error(f"Error in CallLaterThread callback: {err}") del item diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 48d52f2..92c0184 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -8,80 +8,29 @@ __all__ = ["RawLoop", "loop"] -import time -import heapq -import logging -import threading -from itertools import count +import queue from .base import BaseLoop - - -logger = logging.getLogger("rendercanvas") -counter = count() - - -class CallAtWrapper: - def __init__(self, time, callback): - self.index = next(counter) - self.time = time - self.callback = callback - - def __lt__(self, other): - return (self.time, self.index) < (other.time, other.index) - - def cancel(self): - self.callback = None +from ._coreutils import logger, call_later_from_thread class RawLoop(BaseLoop): def __init__(self): super().__init__() - self._queue = [] # prioriry queue + self._queue = queue.SimpleQueue() self._should_stop = False - self._event = threading.Event() def _rc_init(self): # This gets called when the first canvas is created (possibly after having run and stopped before). pass def _rc_run(self): - perf_counter = time.perf_counter - event = self._event - while not self._should_stop: - event.clear() - - # Get wrapper for callback that is first to be called + callback = self._queue.get(True, None) try: - wrapper = heapq.heappop(self._queue) - except IndexError: - wrapper = None - - if wrapper is None: - # Empty queue, exit - break - else: - # Wait until its time for it to be called - # Note that on Windows, the accuracy of the timeout is 15.6 ms - sleep_time = wrapper.time - perf_counter() - sleep_time = max(0, sleep_time - 0.0156) - event.wait(sleep_time) - - # Wait some more - sleep_time = wrapper.time - perf_counter() - while not event.is_set() and sleep_time > 0: - time.sleep(min(sleep_time, 0.001)) # sleep hard for at most 1ms - sleep_time = wrapper.time - perf_counter() - - # Put it back or call it? - if perf_counter() < wrapper.time: - heapq.heappush(self._queue, wrapper) - elif wrapper.callback is not None: - try: - wrapper.callback() - except Exception as err: - logger.error(f"Error in callback: {err}") + callback() + except Exception as err: + logger.error(f"Error in RawLoop callback: {err}") async def _rc_run_async(self): raise NotImplementedError() @@ -89,18 +38,14 @@ async def _rc_run_async(self): def _rc_stop(self): # Note: is only called when we're inside _rc_run self._should_stop = True - self._event.set() + self._queue.put(lambda: None) # trigger an iter def _rc_add_task(self, async_func, name): # we use the async adapter with call_later return super()._rc_add_task(async_func, name) def _rc_call_later(self, delay, callback): - now = time.perf_counter() - time_at = now + max(0, delay) - wrapper = CallAtWrapper(time_at, callback) - heapq.heappush(self._queue, wrapper) - self._event.set() + call_later_from_thread(delay, self._queue.put, callback) loop = RawLoop() From 886e6d4e9b59e73bb4e302d09de8d40e01607c11 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Wed, 19 Nov 2025 12:12:33 +0100 Subject: [PATCH 18/22] cleanup --- rendercanvas/_coreutils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index a6132e6..989889c 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -119,7 +119,6 @@ def __init__(self): super().__init__() self._queue = queue.SimpleQueue() self._count = 0 - self._shutdown = False self.daemon = True # don't let this thread prevent shutdown self.start() From 63350b200d7fcac782cce677b6f921d89791b2a4 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 20 Nov 2025 12:34:26 +0100 Subject: [PATCH 19/22] Add loop.call_soon_threadsafe() --- rendercanvas/_loop.py | 56 +++++++++++++++++++++++++++++------- rendercanvas/asyncio.py | 6 +++- rendercanvas/offscreen.py | 3 ++ rendercanvas/qt.py | 4 +++ rendercanvas/raw.py | 5 +++- rendercanvas/stub.py | 3 ++ rendercanvas/trio.py | 7 +++++ rendercanvas/utils/asyncs.py | 2 +- rendercanvas/wx.py | 3 ++ 9 files changed, 76 insertions(+), 13 deletions(-) diff --git a/rendercanvas/_loop.py b/rendercanvas/_loop.py index 96e90a0..cba3023 100644 --- a/rendercanvas/_loop.py +++ b/rendercanvas/_loop.py @@ -8,7 +8,7 @@ from inspect import iscoroutinefunction from typing import TYPE_CHECKING -from ._coreutils import logger, log_exception +from ._coreutils import logger, log_exception, call_later_from_thread from .utils.asyncs import sleep from .utils import asyncadapter @@ -29,7 +29,7 @@ class BaseLoop: """The base class for an event-loop object. Canvas backends can implement their own loop subclass (like qt and wx do), but a - canvas backend can also rely on one of muliple loop implementations (like glfw + canvas backend can also rely on one of multiple loop implementations (like glfw running on asyncio or trio). The lifecycle states of a loop are: @@ -46,7 +46,7 @@ class BaseLoop: * 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 syncio or Qt loop, the + * 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. """ @@ -176,8 +176,11 @@ async def wrapper(): def call_soon(self, callback: CallbackFunction, *args: Any) -> None: """Arrange for a callback to be called as soon as possible. - The callback will be called in the next iteration of the event-loop, - but other pending events/callbacks may be handled first. Returns None. + The callback will be called in the next iteration of the event-loop, but + other pending events/callbacks may be handled first. Returns None. + + Not thread-safe; use ``call_soon_threadsafe()`` for scheduling callbacks + from another thread. """ if not callable(callback): raise TypeError("call_soon() expects a callable.") @@ -190,6 +193,22 @@ async def wrapper(): self._rc_add_task(wrapper, "call_soon") + def call_soon_threadsafe(self, callback: CallbackFunction, *args: Any) -> None: + """A thread-safe variant of ``call_soon()``.""" + + if not callable(callback): + raise TypeError("call_soon_threadsafe() expects a callable.") + elif iscoroutinefunction(callback): + raise TypeError( + "call_soon_threadsafe() expects a normal callable, not an async one." + ) + + def wrapper(): + with log_exception("Callback error:"): + callback(*args) + + self._rc_call_soon_threadsafe(wrapper) + def call_later(self, delay: float, callback: CallbackFunction, *args: Any) -> None: """Arrange for a callback to be called after the given delay (in seconds).""" if delay <= 0: @@ -214,7 +233,7 @@ def run(self) -> None: its fine to start the loop in the normal way. This call usually blocks, but it can also return immediately, e.g. when there are no - canvases, or when the loop is already active (e.g. interactve via IDE). + canvases, or when the loop is already active (e.g. interactive via IDE). """ # Can we enter the loop? @@ -360,8 +379,13 @@ def _rc_stop(self): def _rc_add_task(self, async_func, name): """Add an async task to the running loop. - This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``. + True async loop-backends (like asyncio and trio) should implement this. + When they do, ``_rc_call_later`` is not used. + + Other loop-backends can use the default implementation, which uses the + ``asyncadapter`` which runs coroutines using ``_rc_call_later``. + * If you implement this, make ``_rc_call_later()`` raise an exception. * Schedule running the task defined by the given co-routine function. * The name is for debugging purposes only. * The subclass is responsible for cancelling remaining tasks in _rc_stop. @@ -374,11 +398,23 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): """Method to call a callback in delay number of seconds. - This method is optional. A subclass must either implement ``_rc_add_task`` or ``_rc_call_later``. + Backends that implement ``_rc_add_task`` should not implement this. + Other backends can use the default implementation, which uses a + scheduler thread and ``_rc_call_soon_threadsafe``. But they can also + implement this using the loop-backend's own mechanics. - * If you implememt this, make ``_rc_add_task()`` call ``super()._rc_add_task()``. + * If you implement this, make ``_rc_add_task()`` call ``super()._rc_add_task()``. + * Take into account that on Windows, timers are usually inaccurate. * If delay is zero, this should behave like "call_soon". - * No need to catch errors from the callback; that's dealt with internally. + * No need to catch errors from the callback; that's dealt with + internally. * Return None. """ + call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback) + + def _rc_call_soon_threadsafe(self, callback): + """Method to schedule a callback in the loop's thread. + + Must be thread-safe; this may be called from a different thread. + """ raise NotImplementedError() diff --git a/rendercanvas/asyncio.py b/rendercanvas/asyncio.py index 1b0774a..b8206f3 100644 --- a/rendercanvas/asyncio.py +++ b/rendercanvas/asyncio.py @@ -88,8 +88,12 @@ def _rc_add_task(self, func, name): self.__tasks.add(task) task.add_done_callback(self.__tasks.discard) - def _rc_call_later(self, *args): + def _rc_call_later(self, delay, callback): raise NotImplementedError() # we implement _rc_add_task instead + def _rc_call_soon_threadsafe(self, callback): + loop = self._interactive_loop or self._run_loop + loop.call_soon_threadsafe(callback) + loop = AsyncioLoop() diff --git a/rendercanvas/offscreen.py b/rendercanvas/offscreen.py index 29de76f..f5c8745 100644 --- a/rendercanvas/offscreen.py +++ b/rendercanvas/offscreen.py @@ -172,5 +172,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): self._callbacks.append((time.perf_counter() + delay, callback)) + def _rc_call_soon_threadsafe(self, callback): + self._callbacks.append((0, callback)) + loop = StubLoop() diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 50ec151..43e5fc6 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -289,6 +289,10 @@ def _rc_call_later(self, delay, callback): # Normal timer. Already precise for MacOS/Linux. QtCore.QTimer.singleShot(int(max(delay * 1000, 1)), callback) + def _rc_call_soon_threadsafe(self, callback): + # Because this goes through a signal/slot, it's thread-safe + self._caller.call.emit(callback) + loop = QtLoop() diff --git a/rendercanvas/raw.py b/rendercanvas/raw.py index 92c0184..599f47a 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -45,7 +45,10 @@ 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._queue.put, callback) + call_later_from_thread(delay, self._rc_call_soon_threadsafe, callback) + + def _rc_call_soon_threadsafe(self, callback): + self._queue.put(callback) loop = RawLoop() diff --git a/rendercanvas/stub.py b/rendercanvas/stub.py index 2986fb8..3ccf678 100644 --- a/rendercanvas/stub.py +++ b/rendercanvas/stub.py @@ -36,6 +36,9 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): raise NotImplementedError() + def _rc_call_soon_threadsafe(self, callback): + raise NotImplementedError() + loop = StubLoop() diff --git a/rendercanvas/trio.py b/rendercanvas/trio.py index 0f75043..308f447 100644 --- a/rendercanvas/trio.py +++ b/rendercanvas/trio.py @@ -17,6 +17,7 @@ def _rc_init(self): self._cancel_scope = None self._send_channel, self._receive_channel = trio.open_memory_channel(99) + self._token = None def _rc_run(self): trio.run(self._rc_run_async, restrict_keyboard_interrupt_to_checkpoints=False) @@ -27,6 +28,8 @@ async def _rc_run_async(self): if libname != "trio": raise TypeError(f"Attempt to run TrioLoop with {libname}.") + self._token = trio.lowlevel.current_trio_token() + with trio.CancelScope() as self._cancel_scope: async with trio.open_nursery() as nursery: while True: @@ -38,6 +41,7 @@ def _rc_stop(self): # Cancel the main task and all its child tasks. if self._cancel_scope is not None: self._cancel_scope.cancel() + self._token = None def _rc_add_task(self, async_func, name): self._send_channel.send_nowait((async_func, name)) @@ -46,5 +50,8 @@ def _rc_add_task(self, async_func, name): def _rc_call_later(self, delay, callback): raise NotImplementedError() # we implement _rc_add_task() instead + def _rc_call_soon_threadsafe(self, callback): + self._token.run_sync_soon(callback) + loop = TrioLoop() diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 8511e7c..549d1e3 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -18,7 +18,7 @@ from .._coreutils import IS_WIN, call_later_from_thread -USE_THREADED_TIMER = IS_WIN +USE_THREADED_TIMER = True async def sleep(delay): diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 7cf7516..400f13b 100644 --- a/rendercanvas/wx.py +++ b/rendercanvas/wx.py @@ -191,6 +191,9 @@ def _rc_call_later(self, delay, callback): else: wx.CallLater(int(max(delay * 1000, 1)), callback) + def _rc_call_soon_threadsafe(self, callback): + wx.CallAfter(callback) + def process_wx_events(self): old_loop = wx.GUIEventLoop.GetActive() event_loop = wx.GUIEventLoop() From e25d1f363d6c7d2d09725ae6b4be8c4e1fbcfbd3 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 20 Nov 2025 12:46:49 +0100 Subject: [PATCH 20/22] Add comment --- rendercanvas/utils/asyncs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index 549d1e3..f26645f 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -26,6 +26,16 @@ async def sleep(delay): On Windows, with asyncio or trio, this uses a special sleep routine that is more accurate than the standard ``sleep()``. """ + + # The commented code below would be quite elegant, but we don't have get_running_rendercanvas_loop(), + # so instead we replicate the call_soon_threadsafe logic for asyncio and trio here. + # + # if delay > 0 and USE_THREADED_TIMER: + # event = Event() + # rc_loop = get_running_rendercanvas_loop() + # call_later_from_thread(delay, rc_loop.call_soon_threadsafe, event.set) + # await event.wait() + libname = sniffio.current_async_library() if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: asyncio = sys.modules[libname] From 39ad9cbc9476a7af46d8d8320d303b094aa41665 Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 20 Nov 2025 13:03:33 +0100 Subject: [PATCH 21/22] reset changed flag while testing --- rendercanvas/utils/asyncs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rendercanvas/utils/asyncs.py b/rendercanvas/utils/asyncs.py index f26645f..38cf9a7 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -18,7 +18,7 @@ from .._coreutils import IS_WIN, call_later_from_thread -USE_THREADED_TIMER = True +USE_THREADED_TIMER = IS_WIN async def sleep(delay): From 7cfc6f6968252c9a45ba11fed447d8734e15709f Mon Sep 17 00:00:00 2001 From: Almar Klein Date: Thu, 20 Nov 2025 13:51:34 +0100 Subject: [PATCH 22/22] docstring, and little extra offset --- rendercanvas/_coreutils.py | 18 ++++++++++++++---- rendercanvas/qt.py | 4 ++-- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 989889c..5522fec 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -108,9 +108,18 @@ def proxy(*args, **kwargs): class CallLaterThread(threading.Thread): """An object that can be used to do "call later" from a dedicated thread. - Care is taken to realize precise timing, so it can be used to implement - precise sleeping and call_later on Windows (to overcome Windows' notorious - 15.6ms ticks). + This is helpful to implement a call-later mechanism on some backends, and + serves as an alternative timeout mechanism in Windows (to overcome its + notorious 15.6ms ticks). + + Windows historically uses ticks that go at 64 ticks per second, i.e. 15.625 + ms each. Other platforms are "tickless" and (in theory) have microsecond + resolution. + + Care is taken to realize precise timing, in the order of 1 ms. Nevertheless, + on OS's other than Windows, the native timers are more accurate than this + threaded approach. I suspect that this is related to the GIL; two threads + cannot run at the same time. """ Item = namedtuple("Item", ["time", "index", "callback", "args"]) @@ -140,6 +149,7 @@ def run(self): wait_until = None timestep = 0.001 # for doing small sleeps leeway = timestep / 2 # a little offset so waiting exactly right on average + leeway += 0.0005 # extra offset to account for GIL etc. (0.5ms seems ok) while True: # == Wait for input @@ -152,7 +162,7 @@ def run(self): # we wait shorter, and then go in a loop with some hard sleeps. # Windows has 15.6 ms resolution ticks. But also on other OSes, # it benefits precision to do the last bit with hard sleeps. - offset = 0.016 if is_win else 0.004 + offset = 0.016 if is_win else timestep try: new_item = q.get(True, max(0, wait_until - perf_counter() - offset)) except Empty: diff --git a/rendercanvas/qt.py b/rendercanvas/qt.py index 43e5fc6..5df5494 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -195,7 +195,7 @@ def enable_hidpi(): class CallbackWrapperHelper(QtCore.QObject): - """Little helper for the high-precision-timer call-laters.""" + """Little helper for _rc_call_later with PreciseTimer""" def __init__(self, pool, cb): super().__init__() @@ -211,7 +211,7 @@ def callback(self): class CallerHelper(QtCore.QObject): - """Little helper class for the threaded call-laters.""" + """Little helper for _rc_call_soon_threadsafe""" call = Signal(object)