diff --git a/rendercanvas/_coreutils.py b/rendercanvas/_coreutils.py index 9114462..5522fec 100644 --- a/rendercanvas/_coreutils.py +++ b/rendercanvas/_coreutils.py @@ -6,10 +6,19 @@ import re import sys import time +import queue import weakref import logging +import threading import ctypes.util from contextlib import contextmanager +from collections import namedtuple + + +# %% Constants + + +IS_WIN = sys.platform.startswith("win") # Note that IS_WIN is false on Pyodide # %% Logging @@ -93,6 +102,132 @@ def proxy(*args, **kwargs): return proxy +# %% Helper for scheduling call-laters + + +class CallLaterThread(threading.Thread): + """An object that can be used to do "call later" from a dedicated thread. + + 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"]) + + def __init__(self): + super().__init__() + self._queue = queue.SimpleQueue() + self._count = 0 + 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 = CallLaterThread.Item( + time.perf_counter() + float(delay), self._count, 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 + 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 + + if wait_until is None: + # Nothing to do but wait + new_item = q.get(True, None) + else: + # 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 timestep + try: + new_item = q.get(True, max(0, wait_until - perf_counter() - offset)) + except Empty: + new_item = None + while perf_counter() < wait_until: + time.sleep(timestep) + try: + new_item = q.get_nowait() + break + except Empty: + pass + + # Put it in our priority queue + if new_item is not None: + priority.append(new_item) + priority.sort(reverse=True) + + del new_item + + # == Process items until we have to wait + + item = None + while True: + # Get item that is up next + try: + item = priority.pop(-1) + except IndexError: + wait_until = None + break + + # 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: + priority.append(item) + wait_until = item_time_threshold + break + + # Otherwise, handle the callback + try: + item.callback(*item.args) + except Exception as err: + logger.error(f"Error in CallLaterThread callback: {err}") + + del item + + +_call_later_thread = None + + +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. + + 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: + _call_later_thread = CallLaterThread() + return _call_later_thread.call_later_from_thread(delay, callback, *args) + + # %% lib support 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/_scheduler.py b/rendercanvas/_scheduler.py index 5185318..1a196f8 100644 --- a/rendercanvas/_scheduler.py +++ b/rendercanvas/_scheduler.py @@ -2,7 +2,6 @@ The scheduler class/loop. """ -import sys import time import weakref @@ -10,9 +9,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 +117,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/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 bafbcd8..5df5494 100644 --- a/rendercanvas/qt.py +++ b/rendercanvas/qt.py @@ -17,8 +17,12 @@ 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() @@ -28,26 +32,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 - WA_PaintOnScreen = QtCore.Qt.WA_PaintOnScreen - WA_DeleteOnClose = QtCore.Qt.WA_DeleteOnClose + 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 + PreciseTimer = QtCore.Qt.PreciseTimer 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." @@ -181,6 +194,32 @@ 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""" + + call = Signal(object) + + def __init__(self): + super().__init__() + self.call.connect(lambda f: f()) + + class QtLoop(BaseLoop): _app = None _we_run_the_loop = False @@ -192,6 +231,8 @@ def _rc_init(self): self._app = QtWidgets.QApplication([]) 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 @@ -227,8 +268,30 @@ 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)) - QtCore.QTimer.singleShot(delay_ms, 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 + ) + else: + # 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 351144d..599f47a 100644 --- a/rendercanvas/raw.py +++ b/rendercanvas/raw.py @@ -8,38 +8,17 @@ __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). @@ -47,31 +26,11 @@ def _rc_init(self): def _rc_run(self): while not self._should_stop: - self._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 - wait_time = wrapper.time - time.perf_counter() - self._event.wait(max(wait_time, 0)) - - # Put it back or call it? - if time.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() @@ -79,18 +38,17 @@ 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._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 3307885..38cf9a7 100644 --- a/rendercanvas/utils/asyncs.py +++ b/rendercanvas/utils/asyncs.py @@ -12,14 +12,46 @@ """ import sys + import sniffio +from .._coreutils import IS_WIN, call_later_from_thread + + +USE_THREADED_TIMER = IS_WIN + async def sleep(delay): - """Generic async sleep. Works with trio, asyncio and rendercanvas-native.""" + """Generic async sleep. Works with trio, asyncio and rendercanvas-native. + + 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() - sleep = sys.modules[libname].sleep - await sleep(delay) + if libname == "asyncio" and delay > 0 and USE_THREADED_TIMER: + asyncio = sys.modules[libname] + 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] + event = trio.Event() + token = trio.lowlevel.current_trio_token() + call_later_from_thread(delay, token.run_sync_soon, event.set) + await event.wait() + else: + sleep = sys.modules[libname].sleep + await sleep(delay) class Event: diff --git a/rendercanvas/wx.py b/rendercanvas/wx.py index 09b4cd0..400f13b 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, + call_later_from_thread, ) from .base import ( WrapperRenderCanvas, @@ -26,6 +28,9 @@ ) +USE_THREADED_TIMER = IS_WIN + + BUTTON_MAP = { wx.MOUSE_BTN_LEFT: 1, wx.MOUSE_BTN_RIGHT: 2, @@ -179,7 +184,15 @@ 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 USE_THREADED_TIMER: + call_later_from_thread(delay, wx.CallAfter, 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() diff --git a/tests/test_utils.py b/tests/test_utils.py index ebe43b2..382d8aa 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.CallLaterThread() + + 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())