From 84e7e6086fac19728c73d3c23acdaca1fc6e8acd Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 24 Oct 2024 09:34:01 -0600 Subject: [PATCH 01/13] FIX: Add update capability to interval/singleshot timer properties --- .../tests/test_backends_interactive.py | 26 ++++++++++++++++--- src/_macosx.m | 18 +++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index a27783fa4be1..781aa075b3fb 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -650,11 +650,13 @@ def _impl_test_interactive_timers(): # We only want singleshot if we specify that ourselves, otherwise we want # a repeating timer import os + import sys from unittest.mock import Mock import matplotlib.pyplot as plt # increase pause duration on CI to let things spin up # particularly relevant for gtk3cairo pause_time = 2 if os.getenv("CI") else 0.5 + expected_100ms_calls = int(pause_time / 0.1) fig = plt.figure() plt.pause(pause_time) timer = fig.canvas.new_timer(0.1) @@ -662,17 +664,33 @@ def _impl_test_interactive_timers(): timer.add_callback(mock) timer.start() plt.pause(pause_time) - timer.stop() - assert mock.call_count > 1 + # NOTE: The timer is as fast as possible, but this is different between backends + # so we can't assert on the exact number but it should be faster than 100ms + assert mock.call_count > expected_100ms_calls, \ + f"Expected more than {expected_100ms_calls} calls, got {mock.call_count}" + + # Test updating the interval updates a running timer + timer.interval = 100 + mock.call_count = 0 + plt.pause(pause_time) + # GTK4 on macos runners produces about 3x as many calls as expected + # It works locally and on Linux though, so only skip when running on CI + if not (os.getenv("CI") + and "gtk4" in os.getenv("MPLBACKEND") + and sys.platform == "darwin"): + # Could be off due to when the timers actually get fired (especially on CI) + assert 1 < mock.call_count <= expected_100ms_calls + 1, \ + f"Expected less than {expected_100ms_calls + 1} calls, " \ + "got {mock.call_count}" # Now turn it into a single shot timer and verify only one gets triggered mock.call_count = 0 timer.single_shot = True - timer.start() plt.pause(pause_time) assert mock.call_count == 1 - # Make sure we can start the timer a second time + # Make sure we can start the timer after stopping + timer.stop() timer.start() plt.pause(pause_time) assert mock.call_count == 2 diff --git a/src/_macosx.m b/src/_macosx.m index aa2a6e68cda5..c3126d88cbe5 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1814,6 +1814,18 @@ - (void)flagsChanged:(NSEvent *)event Py_RETURN_NONE; } +static PyObject* +Timer__timer_update(Timer* self) +{ + // stop and invalidate a timer if it is already running and then create a new one + // where the start() method retrieves the updated interval internally + if (self->timer) { + Timer__timer_stop_impl(self); + gil_call_method((PyObject*)self, "_timer_start"); + } + Py_RETURN_NONE; +} + static void Timer_dealloc(Timer* self) { @@ -1840,6 +1852,12 @@ - (void)flagsChanged:(NSEvent *)event {"_timer_stop", (PyCFunction)Timer__timer_stop, METH_NOARGS}, + {"_timer_set_interval", + (PyCFunction)Timer__timer_update, + METH_NOARGS}, + {"_timer_set_single_shot", + (PyCFunction)Timer__timer_update, + METH_NOARGS}, {} // sentinel }, }; From c04db6ad797e3c4e8553c367e007e38c35808f64 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 07:06:58 -0600 Subject: [PATCH 02/13] MNT/TST: Refactor test-interactive-timers to reduce test time --- .../tests/test_backends_interactive.py | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 781aa075b3fb..8108b0029ca5 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -645,55 +645,52 @@ def test_fallback_to_different_backend(): def _impl_test_interactive_timers(): + # NOTE: We run the timer tests in parallel to avoid longer sequential + # delays which adds to the testing time. Add new tests to one of + # the current event loop iterations if possible. + from unittest.mock import Mock + import matplotlib.pyplot as plt + + fig = plt.figure() + event_loop_time = 0.5 # in seconds + # A timer with <1 millisecond gets converted to int and therefore 0 # milliseconds, which the mac framework interprets as singleshot. # We only want singleshot if we specify that ourselves, otherwise we want # a repeating timer - import os - import sys - from unittest.mock import Mock - import matplotlib.pyplot as plt - # increase pause duration on CI to let things spin up - # particularly relevant for gtk3cairo - pause_time = 2 if os.getenv("CI") else 0.5 - expected_100ms_calls = int(pause_time / 0.1) - fig = plt.figure() - plt.pause(pause_time) - timer = fig.canvas.new_timer(0.1) - mock = Mock() - timer.add_callback(mock) - timer.start() - plt.pause(pause_time) + timer_repeating = fig.canvas.new_timer(0.1) + mock_repeating = Mock() + timer_repeating.add_callback(mock_repeating) + timer_repeating.start() + + timer_single_shot = fig.canvas.new_timer(100) + mock_single_shot = Mock() + timer_single_shot.add_callback(mock_single_shot) + # Start as a repeating timer then change to singleshot via the attribute + timer_single_shot.start() + timer_single_shot.single_shot = True + + fig.canvas.start_event_loop(event_loop_time) # NOTE: The timer is as fast as possible, but this is different between backends # so we can't assert on the exact number but it should be faster than 100ms - assert mock.call_count > expected_100ms_calls, \ - f"Expected more than {expected_100ms_calls} calls, got {mock.call_count}" + expected_100ms_calls = int(event_loop_time / 0.1) + assert mock_repeating.call_count > expected_100ms_calls, \ + f"Expected more than {expected_100ms_calls} calls, " \ + f"got {mock_repeating.call_count}" + assert mock_single_shot.call_count == 1 # Test updating the interval updates a running timer - timer.interval = 100 - mock.call_count = 0 - plt.pause(pause_time) - # GTK4 on macos runners produces about 3x as many calls as expected - # It works locally and on Linux though, so only skip when running on CI - if not (os.getenv("CI") - and "gtk4" in os.getenv("MPLBACKEND") - and sys.platform == "darwin"): - # Could be off due to when the timers actually get fired (especially on CI) - assert 1 < mock.call_count <= expected_100ms_calls + 1, \ - f"Expected less than {expected_100ms_calls + 1} calls, " \ - "got {mock.call_count}" - - # Now turn it into a single shot timer and verify only one gets triggered - mock.call_count = 0 - timer.single_shot = True - plt.pause(pause_time) - assert mock.call_count == 1 - - # Make sure we can start the timer after stopping - timer.stop() - timer.start() - plt.pause(pause_time) - assert mock.call_count == 2 + timer_repeating.interval = 100 + mock_repeating.call_count = 0 + # Make sure we can start the timer after stopping a singleshot timer + timer_single_shot.stop() + timer_single_shot.start() + + fig.canvas.start_event_loop(event_loop_time) + assert 1 < mock_repeating.call_count <= expected_100ms_calls + 1, \ + f"Expected less than {expected_100ms_calls + 1} calls, " \ + "got {mock.call_count}" + assert mock_single_shot.call_count == 2 plt.close("all") From b54c829ed5dcd0294c222f95a304bea1805c5b41 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 07:12:11 -0600 Subject: [PATCH 03/13] FIX: Event loop timers should only run for the specified time The implementation of start_event_loop would previously just count the number of sleeps that occurred. But this could lead to longer event loop times if flush_events() added time into the loop. We want the condition to be dependent on the end-time so we don't run our loop longer than necessary. --- lib/matplotlib/backend_bases.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 2158990f578a..87d39f57a174 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -2370,13 +2370,11 @@ def start_event_loop(self, timeout=0): """ if timeout <= 0: timeout = np.inf - timestep = 0.01 - counter = 0 + t_end = time.perf_counter() + timeout self._looping = True - while self._looping and counter * timestep < timeout: + while self._looping and time.perf_counter() < t_end: self.flush_events() - time.sleep(timestep) - counter += 1 + time.sleep(0.01) # Pause for 10ms def stop_event_loop(self): """ From 1ef70ca53507330f2c0ec19ca2b75d8287a12aa3 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 12:05:05 -0600 Subject: [PATCH 04/13] FIX: Add single shot update capability to TimerWx --- lib/matplotlib/backends/backend_wx.py | 4 ++++ lib/matplotlib/tests/test_backends_interactive.py | 6 ++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..9eb054bf4089 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -69,6 +69,10 @@ def _timer_set_interval(self): if self._timer.IsRunning(): self._timer_start() # Restart with new interval. + def _timer_set_single_shot(self): + if self._timer.IsRunning(): + self._timer_start() # Restart with new interval. + @_api.deprecated( "2.0", name="wx", obj_type="backend", removal="the future", diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 8108b0029ca5..590824105875 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -677,7 +677,8 @@ def _impl_test_interactive_timers(): assert mock_repeating.call_count > expected_100ms_calls, \ f"Expected more than {expected_100ms_calls} calls, " \ f"got {mock_repeating.call_count}" - assert mock_single_shot.call_count == 1 + assert mock_single_shot.call_count == 1, \ + f"Expected 1 call, got {mock_single_shot.call_count}" # Test updating the interval updates a running timer timer_repeating.interval = 100 @@ -690,7 +691,8 @@ def _impl_test_interactive_timers(): assert 1 < mock_repeating.call_count <= expected_100ms_calls + 1, \ f"Expected less than {expected_100ms_calls + 1} calls, " \ "got {mock.call_count}" - assert mock_single_shot.call_count == 2 + assert mock_single_shot.call_count == 2, \ + f"Expected 2 calls, got {mock_single_shot.call_count}" plt.close("all") From 88f8b77d9c526624420cc6e7924428aed73523d3 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 05/13] FIX: Only call timer updates when things change on the timer If we set interval in a loop, this could cause the timing to become dependent on the callback processing time. --- lib/matplotlib/backend_bases.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 87d39f57a174..edb04edd59ee 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1059,7 +1059,9 @@ def __init__(self, interval=None, callbacks=None): and `~.TimerBase.remove_callback` can be used. """ self.callbacks = [] if callbacks is None else callbacks.copy() - # Set .interval and not ._interval to go through the property setter. + # Go through the property setters for validation and updates + self._interval = None + self._single = None self.interval = 1000 if interval is None else interval self.single_shot = False @@ -1103,8 +1105,9 @@ def interval(self, interval): # milliseconds, and some error or give warnings. # Some backends also fail when interval == 0, so ensure >= 1 msec interval = max(int(interval), 1) - self._interval = interval - self._timer_set_interval() + if interval != self._interval: + self._interval = interval + self._timer_set_interval() @property def single_shot(self): @@ -1113,8 +1116,9 @@ def single_shot(self): @single_shot.setter def single_shot(self, ss): - self._single = ss - self._timer_set_single_shot() + if ss != self._single: + self._single = ss + self._timer_set_single_shot() def add_callback(self, func, *args, **kwargs): """ From 018edd90134c4a148d736a9dd806c085081b94ac Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 06/13] FIX: Avoid drift in Tk's repeating timer The Tk timer would reset itself after the callback had processed to add a new interval. This meant that if a long callback was being added we would get a drift in the timer. We need to manually track the original firing time and intervals based on that. --- lib/matplotlib/backends/_backend_tk.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 0bbff1379ffa..3b4375e3339b 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -6,6 +6,7 @@ import os.path import pathlib import sys +import time import tkinter as tk import tkinter.filedialog import tkinter.font @@ -132,6 +133,9 @@ def __init__(self, parent, *args, **kwargs): def _timer_start(self): self._timer_stop() self._timer = self.parent.after(self._interval, self._on_timer) + # Keep track of the firing time for repeating timers since + # we have to do this manually in Tk + self._timer_start_count = time.perf_counter_ns() def _timer_stop(self): if self._timer is not None: @@ -139,6 +143,9 @@ def _timer_stop(self): self._timer = None def _on_timer(self): + # We want to measure the time spent in the callback, so we need to + # record the time before calling the base class method. + timer_fire_ms = (time.perf_counter_ns() - self._timer_start_count) // 1_000_000 super()._on_timer() # Tk after() is only a single shot, so we need to add code here to # reset the timer if we're not operating in single shot mode. However, @@ -146,7 +153,20 @@ def _on_timer(self): # don't recreate the timer in that case. if not self._single and self._timer: if self._interval > 0: - self._timer = self.parent.after(self._interval, self._on_timer) + # We want to adjust our fire time independent of the time + # spent in the callback and not drift over time, so reference + # to the start count. + after_callback_ms = ((time.perf_counter_ns() - self._timer_start_count) + // 1_000_000) + if after_callback_ms - timer_fire_ms < self._interval: + next_interval = self._interval - after_callback_ms % self._interval + # minimum of 1ms + next_interval = max(1, next_interval) + else: + # Account for the callback being longer than the interval, where + # we really want to fire the next timer as soon as possible. + next_interval = 1 + self._timer = self.parent.after(next_interval, self._on_timer) else: # Edge case: Tcl after 0 *prepends* events to the queue # so a 0 interval does not allow any other events to run. From 459410f85621bed593a42db818c30d476605b1b4 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 07/13] TST: Add test for timer drift in interactive backends Make sure that the interactive timers don't drift when long callbacks are associated with them. --- .../tests/test_backends_interactive.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 590824105875..7ac331a09124 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -661,15 +661,24 @@ def _impl_test_interactive_timers(): timer_repeating = fig.canvas.new_timer(0.1) mock_repeating = Mock() timer_repeating.add_callback(mock_repeating) - timer_repeating.start() timer_single_shot = fig.canvas.new_timer(100) mock_single_shot = Mock() timer_single_shot.add_callback(mock_single_shot) + + # 100ms timer triggers and the callback takes 75ms to run + # Test that we don't drift and that we get called on every 100ms + # interval and not every 175ms + mock_slow_callback = Mock() + mock_slow_callback.side_effect = lambda: time.sleep(0.075) + timer_slow_callback = fig.canvas.new_timer(100) + timer_slow_callback.add_callback(mock_slow_callback) + + timer_repeating.start() # Start as a repeating timer then change to singleshot via the attribute timer_single_shot.start() timer_single_shot.single_shot = True - + timer_slow_callback.start() fig.canvas.start_event_loop(event_loop_time) # NOTE: The timer is as fast as possible, but this is different between backends # so we can't assert on the exact number but it should be faster than 100ms @@ -679,6 +688,9 @@ def _impl_test_interactive_timers(): f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 1, \ f"Expected 1 call, got {mock_single_shot.call_count}" + assert mock_slow_callback.call_count >= expected_100ms_calls - 1, \ + f"Expected at least {expected_100ms_calls - 1} calls, " \ + f"got {mock_slow_callback.call_count}" # Test updating the interval updates a running timer timer_repeating.interval = 100 From ebe66bcdd210ae7f8311c2e72b6dda0a52bfb401 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Fri, 25 Oct 2024 11:22:55 -0600 Subject: [PATCH 08/13] TST: Add backend bases test --- lib/matplotlib/tests/test_backend_bases.py | 24 +++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 0205eac42fb3..b6a50bcec665 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -1,9 +1,10 @@ import importlib +from unittest.mock import patch from matplotlib import path, transforms from matplotlib.backend_bases import ( FigureCanvasBase, KeyEvent, LocationEvent, MouseButton, MouseEvent, - NavigationToolbar2, RendererBase) + NavigationToolbar2, RendererBase, TimerBase) from matplotlib.backend_tools import RubberbandBase from matplotlib.figure import Figure from matplotlib.testing._markers import needs_pgf_xelatex @@ -581,3 +582,24 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): # Check if twin-axes are properly triggered assert ax_t.get_xlim() == pytest.approx(ax_t_twin.get_xlim(), abs=0.15) assert ax_b.get_xlim() == pytest.approx(ax_b_twin.get_xlim(), abs=0.15) + + +def test_timer_properties(): + # Setting a property to the same value should not trigger the + # private setter call again. + timer = TimerBase(100) + with patch.object(timer, '_timer_set_interval') as mock: + timer.interval = 200 + mock.assert_called_once() + assert timer.interval == 200 + timer.interval = 200 + # Make sure it wasn't called again + mock.assert_called_once() + + with patch.object(timer, '_timer_set_single_shot') as mock: + timer.single_shot = True + mock.assert_called_once() + assert timer._single + timer.single_shot = True + # Make sure it wasn't called again + mock.assert_called_once() From a5cd96d384cd14b87aa2f91866d0ccfd5b7a6270 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Mon, 28 Oct 2024 11:13:07 -0600 Subject: [PATCH 09/13] FIX/ENH: macos: dispatch timer tasks asynchronously to the main loop Previously, the timers were dependent on the length of time it took for the timer callback to execute. This dispatches the callback to the task queue to avoid synchronously waiting on long-running callback tasks. --- src/_macosx.m | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/_macosx.m b/src/_macosx.m index c3126d88cbe5..7b50bca8a43a 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1773,19 +1773,18 @@ - (void)flagsChanged:(NSEvent *)event } // hold a reference to the timer so we can invalidate/stop it later - self->timer = [NSTimer timerWithTimeInterval: interval - repeats: !single - block: ^(NSTimer *timer) { - gil_call_method((PyObject*)self, "_on_timer"); - if (single) { - // A single-shot timer will be automatically invalidated when it fires, so - // we shouldn't do it ourselves when the object is deleted. - self->timer = NULL; - } + self->timer = [NSTimer scheduledTimerWithTimeInterval: interval + repeats: !single + block: ^(NSTimer *timer) { + dispatch_async(dispatch_get_main_queue(), ^{ + gil_call_method((PyObject*)self, "_on_timer"); + if (single) { + // A single-shot timer will be automatically invalidated when it fires, so + // we shouldn't do it ourselves when the object is deleted. + self->timer = NULL; + } + }); }]; - // Schedule the timer on the main run loop which is needed - // when updating the UI from a background thread - [[NSRunLoop mainRunLoop] addTimer: self->timer forMode: NSRunLoopCommonModes]; exit: Py_XDECREF(py_interval); From d1e52b01b187265ad28ff7c0c55b8356cb47b17f Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Thu, 31 Oct 2024 11:01:35 -0600 Subject: [PATCH 10/13] TST: Add text to identify failing tests --- lib/matplotlib/backends/_backend_tk.py | 8 ++- lib/matplotlib/tests/test_backend_bases.py | 7 +++ .../tests/test_backends_interactive.py | 52 +++++++------------ 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 3b4375e3339b..14f8a7b306a1 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -127,8 +127,8 @@ class TimerTk(TimerBase): def __init__(self, parent, *args, **kwargs): self._timer = None - super().__init__(*args, **kwargs) self.parent = parent + super().__init__(*args, **kwargs) def _timer_start(self): self._timer_stop() @@ -178,6 +178,12 @@ def _on_timer(self): else: self._timer = None + def _timer_set_interval(self): + self._timer_start() + + def _timer_set_single_shot(self): + self._timer_start() + class FigureCanvasTk(FigureCanvasBase): required_interactive_framework = "tk" diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index b6a50bcec665..70e5c466d7fc 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -603,3 +603,10 @@ def test_timer_properties(): timer.single_shot = True # Make sure it wasn't called again mock.assert_called_once() + + # A timer with <1 millisecond gets converted to int and therefore 0 + # milliseconds, which the mac framework interprets as singleshot. + # We only want singleshot if we specify that ourselves, otherwise we want + # a repeating timer, so make sure our interval is set to a minimum of 1ms. + timer.interval = 0.1 + assert timer.interval == 1 diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 7ac331a09124..eb378303134c 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -652,13 +652,11 @@ def _impl_test_interactive_timers(): import matplotlib.pyplot as plt fig = plt.figure() - event_loop_time = 0.5 # in seconds + event_loop_time = 1 # in seconds + expected_200ms_calls = int(event_loop_time / 0.2) - # A timer with <1 millisecond gets converted to int and therefore 0 - # milliseconds, which the mac framework interprets as singleshot. - # We only want singleshot if we specify that ourselves, otherwise we want - # a repeating timer - timer_repeating = fig.canvas.new_timer(0.1) + # Start at 2s interval (would only get one firing), then update to 200ms + timer_repeating = fig.canvas.new_timer(2000) mock_repeating = Mock() timer_repeating.add_callback(mock_repeating) @@ -666,52 +664,42 @@ def _impl_test_interactive_timers(): mock_single_shot = Mock() timer_single_shot.add_callback(mock_single_shot) - # 100ms timer triggers and the callback takes 75ms to run - # Test that we don't drift and that we get called on every 100ms - # interval and not every 175ms - mock_slow_callback = Mock() - mock_slow_callback.side_effect = lambda: time.sleep(0.075) - timer_slow_callback = fig.canvas.new_timer(100) - timer_slow_callback.add_callback(mock_slow_callback) - timer_repeating.start() + # Test updating the interval updates a running timer + timer_repeating.interval = 200 # Start as a repeating timer then change to singleshot via the attribute timer_single_shot.start() timer_single_shot.single_shot = True - timer_slow_callback.start() + fig.canvas.start_event_loop(event_loop_time) - # NOTE: The timer is as fast as possible, but this is different between backends - # so we can't assert on the exact number but it should be faster than 100ms - expected_100ms_calls = int(event_loop_time / 0.1) - assert mock_repeating.call_count > expected_100ms_calls, \ - f"Expected more than {expected_100ms_calls} calls, " \ + assert 1 < mock_repeating.call_count <= expected_200ms_calls + 1, \ + f"Interval update: Expected between 2 and {expected_200ms_calls + 1} calls, " \ f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 1, \ - f"Expected 1 call, got {mock_single_shot.call_count}" - assert mock_slow_callback.call_count >= expected_100ms_calls - 1, \ - f"Expected at least {expected_100ms_calls - 1} calls, " \ - f"got {mock_slow_callback.call_count}" + f"Singleshot: Expected 1 call, got {mock_single_shot.call_count}" - # Test updating the interval updates a running timer - timer_repeating.interval = 100 + # 200ms timer triggers and the callback takes 100ms to run + # Test that we don't drift and that we get called on every 200ms + # interval and not every 300ms + mock_repeating.side_effect = lambda: time.sleep(0.1) mock_repeating.call_count = 0 # Make sure we can start the timer after stopping a singleshot timer timer_single_shot.stop() timer_single_shot.start() fig.canvas.start_event_loop(event_loop_time) - assert 1 < mock_repeating.call_count <= expected_100ms_calls + 1, \ - f"Expected less than {expected_100ms_calls + 1} calls, " \ - "got {mock.call_count}" + # Not exact timers, so add a little slop. We really want to make sure we are + # getting more than 3 (every 300ms). + assert mock_repeating.call_count >= expected_200ms_calls - 1, \ + f"Slow callback: Expected at least {expected_200ms_calls - 1} calls, " \ + f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 2, \ - f"Expected 2 calls, got {mock_single_shot.call_count}" + f"Singleshot: Expected 2 calls, got {mock_single_shot.call_count}" plt.close("all") @pytest.mark.parametrize("env", _get_testable_interactive_backends()) def test_interactive_timers(env): - if env["MPLBACKEND"] == "gtk3cairo" and os.getenv("CI"): - pytest.skip("gtk3cairo timers do not work in remote CI") if env["MPLBACKEND"] == "wx": pytest.skip("wx backend is deprecated; tests failed on appveyor") _run_helper(_impl_test_interactive_timers, From 577a0cff9e1729d564a473b9b6a68be4b565bf67 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Tue, 5 Nov 2024 09:02:58 -0700 Subject: [PATCH 11/13] FIX: macos should invalidate the previous timer when creating a new one --- src/_macosx.m | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/_macosx.m b/src/_macosx.m index 7b50bca8a43a..d7396e4b43f2 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -1754,6 +1754,15 @@ - (void)flagsChanged:(NSEvent *)event (void*) self, (void*)(self->timer)); } +static void +Timer__timer_stop_impl(Timer* self) +{ + if (self->timer) { + [self->timer invalidate]; + self->timer = NULL; + } +} + static PyObject* Timer__timer_start(Timer* self, PyObject* args) { @@ -1772,6 +1781,8 @@ - (void)flagsChanged:(NSEvent *)event goto exit; } + // Stop the current timer if it is already running + Timer__timer_stop_impl(self); // hold a reference to the timer so we can invalidate/stop it later self->timer = [NSTimer scheduledTimerWithTimeInterval: interval repeats: !single @@ -1797,15 +1808,6 @@ - (void)flagsChanged:(NSEvent *)event } } -static void -Timer__timer_stop_impl(Timer* self) -{ - if (self->timer) { - [self->timer invalidate]; - self->timer = NULL; - } -} - static PyObject* Timer__timer_stop(Timer* self) { From 52a584dde80e31f014f26b42f72cb0842cede178 Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Tue, 5 Nov 2024 09:00:52 -0700 Subject: [PATCH 12/13] TST: Add interactive timer tests This adds more robust interactive timer tests to assert against some of the discrepencies that were found in testing. - Run loop shouldn't depend on callback time - Slow callbacks shouldn't cause a timer to drift over time, it should continually fire at the requested cadence - When start() is called again it should invalidate the previous timer associated with that Timer object --- .../tests/test_backends_interactive.py | 47 ++++++++++++------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index eb378303134c..4fa78d530bfd 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -648,14 +648,12 @@ def _impl_test_interactive_timers(): # NOTE: We run the timer tests in parallel to avoid longer sequential # delays which adds to the testing time. Add new tests to one of # the current event loop iterations if possible. + import time from unittest.mock import Mock import matplotlib.pyplot as plt fig = plt.figure() - event_loop_time = 1 # in seconds - expected_200ms_calls = int(event_loop_time / 0.2) - - # Start at 2s interval (would only get one firing), then update to 200ms + # Start at 2s interval (wouldn't get any firings), then update to 100ms timer_repeating = fig.canvas.new_timer(2000) mock_repeating = Mock() timer_repeating.add_callback(mock_repeating) @@ -666,42 +664,55 @@ def _impl_test_interactive_timers(): timer_repeating.start() # Test updating the interval updates a running timer - timer_repeating.interval = 200 + timer_repeating.interval = 100 # Start as a repeating timer then change to singleshot via the attribute timer_single_shot.start() timer_single_shot.single_shot = True - fig.canvas.start_event_loop(event_loop_time) - assert 1 < mock_repeating.call_count <= expected_200ms_calls + 1, \ - f"Interval update: Expected between 2 and {expected_200ms_calls + 1} calls, " \ - f"got {mock_repeating.call_count}" + fig.canvas.start_event_loop(0.5) + assert 2 <= mock_repeating.call_count <= 5, \ + f"Interval update: Expected 2-5 calls, got {mock_repeating.call_count}" assert mock_single_shot.call_count == 1, \ f"Singleshot: Expected 1 call, got {mock_single_shot.call_count}" - # 200ms timer triggers and the callback takes 100ms to run - # Test that we don't drift and that we get called on every 200ms - # interval and not every 300ms - mock_repeating.side_effect = lambda: time.sleep(0.1) + # 250ms timer triggers and the callback takes 150ms to run + # Test that we don't drift and that we get called on every 250ms + # firing and not every 400ms + timer_repeating.interval = 250 + mock_repeating.side_effect = lambda: time.sleep(0.15) + # calling start() again on a repeating timer should remove the old + # one, so we don't want double the number of calls here either because + # two timers are potentially running. + timer_repeating.start() mock_repeating.call_count = 0 # Make sure we can start the timer after stopping a singleshot timer timer_single_shot.stop() timer_single_shot.start() + event_loop_time = 2 # in seconds + expected_calls = int(event_loop_time / (timer_repeating.interval / 1000)) + + t_start = time.perf_counter() fig.canvas.start_event_loop(event_loop_time) - # Not exact timers, so add a little slop. We really want to make sure we are - # getting more than 3 (every 300ms). - assert mock_repeating.call_count >= expected_200ms_calls - 1, \ - f"Slow callback: Expected at least {expected_200ms_calls - 1} calls, " \ + t_loop = time.perf_counter() - t_start + # Should be around 2s, but allow for some slop on CI. We want to make sure + # we aren't getting 2 + (callback time) 0.5s/iteration, which would be 4+ s. + assert 1.8 < t_loop < 3, \ + f"Event loop: Expected to run for around 2s, but ran for {t_loop:.2f}s" + # Not exact timers, so add some slop. (Quite a bit for CI resources) + assert abs(mock_repeating.call_count - expected_calls) <= 2, \ + f"Slow callback: Expected {expected_calls} calls, " \ f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 2, \ f"Singleshot: Expected 2 calls, got {mock_single_shot.call_count}" - plt.close("all") @pytest.mark.parametrize("env", _get_testable_interactive_backends()) def test_interactive_timers(env): if env["MPLBACKEND"] == "wx": pytest.skip("wx backend is deprecated; tests failed on appveyor") + if env["MPLBACKEND"].startswith("gtk3") and is_ci_environment(): + pytest.xfail("GTK3 backend timer is slow on CI resources") _run_helper(_impl_test_interactive_timers, timeout=_test_timeout, extra_env=env) From 1affa9e05cff1ecc9c5cd0ca2c8b8ee6a141754d Mon Sep 17 00:00:00 2001 From: Greg Lucas Date: Wed, 20 Nov 2024 09:20:55 -0700 Subject: [PATCH 13/13] TST: Update some times for interactive timer test on CI --- .../tests/test_backends_interactive.py | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 4fa78d530bfd..5a1ec29f387b 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -675,11 +675,13 @@ def _impl_test_interactive_timers(): assert mock_single_shot.call_count == 1, \ f"Singleshot: Expected 1 call, got {mock_single_shot.call_count}" - # 250ms timer triggers and the callback takes 150ms to run - # Test that we don't drift and that we get called on every 250ms - # firing and not every 400ms - timer_repeating.interval = 250 - mock_repeating.side_effect = lambda: time.sleep(0.15) + # 500ms timer triggers and the callback takes 400ms to run + # Test that we don't drift and that we get called on every 500ms + # firing and not every 900ms + timer_repeating.interval = 500 + # sleep for 80% of the interval + sleep_time = timer_repeating.interval / 1000 * 0.8 + mock_repeating.side_effect = lambda: time.sleep(sleep_time) # calling start() again on a repeating timer should remove the old # one, so we don't want double the number of calls here either because # two timers are potentially running. @@ -689,18 +691,21 @@ def _impl_test_interactive_timers(): timer_single_shot.stop() timer_single_shot.start() - event_loop_time = 2 # in seconds + # CI resources are inconsistent, so we need to allow for some slop + event_loop_time = 10 if os.getenv("CI") else 3 # in seconds expected_calls = int(event_loop_time / (timer_repeating.interval / 1000)) t_start = time.perf_counter() fig.canvas.start_event_loop(event_loop_time) t_loop = time.perf_counter() - t_start - # Should be around 2s, but allow for some slop on CI. We want to make sure - # we aren't getting 2 + (callback time) 0.5s/iteration, which would be 4+ s. - assert 1.8 < t_loop < 3, \ - f"Event loop: Expected to run for around 2s, but ran for {t_loop:.2f}s" + # Should be around event_loop_time, but allow for some slop on CI. + # We want to make sure we aren't getting + # event_loop_time + (callback time)*niterations + assert event_loop_time * 0.95 < t_loop < event_loop_time / 0.7, \ + f"Event loop: Expected to run for around {event_loop_time}s, " \ + f"but ran for {t_loop:.2f}s" # Not exact timers, so add some slop. (Quite a bit for CI resources) - assert abs(mock_repeating.call_count - expected_calls) <= 2, \ + assert abs(mock_repeating.call_count - expected_calls) / expected_calls <= 0.3, \ f"Slow callback: Expected {expected_calls} calls, " \ f"got {mock_repeating.call_count}" assert mock_single_shot.call_count == 2, \