Visitar URL original
TST/CI: try to fix some CI timing issues · matplotlib/matplotlib@cace96c · GitHub
Skip to content

Commit cace96c

Browse files
committed
TST/CI: try to fix some CI timing issues
Test against N firings and measure the gaps rather than against a specific amount of time and measuring the firings. Use a baseline reference run to get an initial estimate of the system's CPU slowness for CI systems that can be unreliable with timings. Adjust QT timer intervals ourselves to avoid drift
1 parent 8537e32 commit cace96c

7 files changed

Lines changed: 122 additions & 44 deletions

File tree

‎lib/matplotlib/backend_bases.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1242,6 +1242,20 @@ def _timer_set_interval(self):
12421242
def _timer_set_single_shot(self):
12431243
"""Used to set single shot on underlying timer object."""
12441244

1245+
@staticmethod
1246+
def _next_delay(next_fire, interval, now):
1247+
"""
1248+
Compute the next absolute fire time and delay for a repeating timer
1249+
that reschedules itself, skipping any intervals a slow callback
1250+
caused it to miss instead of drifting by the callback's duration.
1251+
1252+
*next_fire*, *interval*, and *now* must be in the same units.
1253+
"""
1254+
next_fire += interval
1255+
if next_fire <= now:
1256+
next_fire += interval * ((now - next_fire) // interval + 1)
1257+
return next_fire, next_fire - now
1258+
12451259
def _on_timer(self):
12461260
"""
12471261
Runs all function that have been registered as callbacks. Functions

‎lib/matplotlib/backends/_backend_gtk.py‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ def _timer_start(self):
8989
# Need to stop it, otherwise we potentially leak a timer id that will
9090
# never be stopped.
9191
self._timer_stop()
92+
self._next_fire = GLib.get_monotonic_time() / 1e6 + self._interval / 1000
9293
self._timer = GLib.timeout_add(self._interval, self._on_timer)
9394

9495
def _timer_stop(self):
@@ -99,7 +100,6 @@ def _timer_stop(self):
99100
def _timer_set_interval(self):
100101
# Only stop and restart it if the timer has already been started.
101102
if self._timer is not None:
102-
self._timer_stop()
103103
self._timer_start()
104104

105105
def _on_timer(self):
@@ -108,9 +108,14 @@ def _on_timer(self):
108108
if self._timer is not timer:
109109
# A callback stopped or restarted us; leave its timer alone.
110110
return GLib.SOURCE_REMOVE
111-
if self.callbacks and not self._single:
112-
return GLib.SOURCE_CONTINUE
113-
self._timer = None
111+
if not self.callbacks or self._single:
112+
self._timer = None
113+
return GLib.SOURCE_REMOVE
114+
# Reschedule ourselves instead of using SOURCE_CONTINUE, so a slow
115+
# callback skips to the next interval instead of drifting.
116+
self._next_fire, delay = self._next_delay(
117+
self._next_fire, self._interval / 1000, GLib.get_monotonic_time() / 1e6)
118+
self._timer = GLib.timeout_add(max(1, round(delay * 1000)), self._on_timer)
114119
return GLib.SOURCE_REMOVE
115120

116121

‎lib/matplotlib/backends/_backend_tk.py‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,9 @@ def _on_timer(self):
178178
if self._single:
179179
self._timer = None
180180
return
181-
now = time.monotonic()
182-
interval = self._interval / 1000
183-
self._next_fire += interval
184-
if self._next_fire <= now:
185-
# Drop the firings missed while the callback overran.
186-
self._next_fire += interval * ((now - self._next_fire) // interval + 1)
187-
self._timer = self.parent.after(
188-
max(1, round((self._next_fire - now) * 1000)), self._on_timer)
181+
self._next_fire, delay = self._next_delay(
182+
self._next_fire, self._interval / 1000, time.monotonic())
183+
self._timer = self.parent.after(max(1, round(delay * 1000)), self._on_timer)
189184

190185
def _timer_set_interval(self):
191186
if self._timer is not None:

‎lib/matplotlib/backends/backend_qt.py‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
import os
33
import sys
4+
import time
45
import traceback
56

67
import matplotlib as mpl
@@ -193,12 +194,7 @@ class TimerQT(TimerBase):
193194
"""Subclass of `.TimerBase` using QTimer events."""
194195

195196
def __init__(self, *args, **kwargs):
196-
# Create a new timer and connect the timeout() signal to the
197-
# _on_timer method.
198-
self._timer = QtCore.QTimer()
199-
# The default coarse timer is allowed to drift by up to 5%.
200-
self._timer.setTimerType(QtCore.Qt.TimerType.PreciseTimer)
201-
self._timer.timeout.connect(self._on_timer)
197+
self._timer = None
202198
super().__init__(*args, **kwargs)
203199

204200
def __del__(self):
@@ -210,17 +206,45 @@ def __del__(self):
210206
if str(e) != ignore_msg:
211207
raise
212208

209+
def _new_qtimer(self, msec):
210+
# QTimer's own repeat mode can drift on some platforms, so drive it
211+
# as a single shot and reschedule ourselves from a fixed schedule.
212+
timer = QtCore.QTimer()
213+
timer.setTimerType(QtCore.Qt.TimerType.PreciseTimer)
214+
timer.setSingleShot(True)
215+
timer.timeout.connect(self._on_timer)
216+
timer.start(msec)
217+
return timer
218+
213219
def _timer_set_single_shot(self):
214-
self._timer.setSingleShot(self._single)
220+
pass
215221

216222
def _timer_set_interval(self):
217-
self._timer.setInterval(self._interval)
223+
if self._timer is not None:
224+
self._timer_start()
218225

219226
def _timer_start(self):
220-
self._timer.start()
227+
self._timer_stop()
228+
self._next_fire = time.monotonic() + self._interval / 1000
229+
self._timer = self._new_qtimer(self._interval)
221230

222231
def _timer_stop(self):
223-
self._timer.stop()
232+
if self._timer is not None:
233+
self._timer.stop()
234+
self._timer = None
235+
236+
def _on_timer(self):
237+
timer = self._timer
238+
super()._on_timer()
239+
if self._timer is not timer:
240+
# A callback stopped or restarted us; leave its timer alone.
241+
return
242+
if self._single:
243+
self._timer = None
244+
return
245+
self._next_fire, delay = self._next_delay(
246+
self._next_fire, self._interval / 1000, time.monotonic())
247+
self._timer = self._new_qtimer(max(1, round(delay * 1000)))
224248

225249

226250
class FigureCanvasQT(FigureCanvasBase, QtWidgets.QWidget):

‎lib/matplotlib/backends/backend_webagg_core.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,11 +133,7 @@ async def _timer_task(self, interval):
133133

134134
if self._single:
135135
break
136-
# Drop the firings missed while the callback overran.
137-
now = loop.time()
138-
next_fire += interval
139-
if next_fire <= now:
140-
next_fire += interval * ((now - next_fire) // interval + 1)
136+
next_fire, _ = self._next_delay(next_fire, interval, loop.time())
141137
except asyncio.CancelledError:
142138
break
143139

‎lib/matplotlib/tests/test_backend_webagg.py‎

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import asyncio
22
import os
3-
import statistics
43
import sys
54
import time
65
from unittest.mock import MagicMock
@@ -102,9 +101,40 @@ def test_asyncio_timer_single_shot():
102101
assert len(fires) == 1
103102

104103

104+
async def _time_n_fires(interval, callback_s, n, max_wait_s):
105+
# Collect n fire timestamps, bounded by max_wait_s in case a timer stalls.
106+
timer = matplotlib.backends.backend_webagg_core.TimerAsyncio(interval * 1000)
107+
fires = []
108+
done = asyncio.Event()
109+
110+
def callback():
111+
fires.append(asyncio.get_running_loop().time())
112+
if callback_s:
113+
time.sleep(callback_s)
114+
if len(fires) >= n:
115+
done.set()
116+
117+
timer.add_callback(callback)
118+
timer.start()
119+
try:
120+
await asyncio.wait_for(done.wait(), timeout=max_wait_s)
121+
except asyncio.TimeoutError:
122+
pass
123+
timer.stop()
124+
return fires
125+
126+
105127
def test_asyncio_timer_no_drift():
106-
# A slow callback must not push back the firings that follow it.
107-
interval, fires = asyncio.run(_run_asyncio_timer(False, 0.04, 0.5))
108-
assert len(fires) >= 4
109-
spacing = statistics.median(b - a for a, b in zip(fires, fires[1:]))
110-
assert spacing < interval * 1.4
128+
# A slow callback should skip to the next interval, not drift by
129+
# its own duration. Each gap should land on a multiple of interval.
130+
interval = 1
131+
callback_s = interval / 2
132+
n = 4
133+
fires = asyncio.run(_time_n_fires(interval, callback_s, n, max_wait_s=20))
134+
assert len(fires) >= n, f"Only fired {len(fires)} times"
135+
for a, b in zip(fires, fires[1:]):
136+
gap = b - a
137+
offset = abs(gap - round(gap / interval) * interval)
138+
assert offset < interval * 0.3, (
139+
f"Gap {gap * 1000:.0f}ms is {offset * 1000:.0f}ms from the "
140+
f"nearest multiple of {interval * 1000:.0f}ms")

‎lib/matplotlib/tests/test_backends_interactive.py‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,6 @@ def test_fallback_to_different_backend():
634634

635635

636636
def _impl_test_interactive_timers():
637-
import statistics
638637
import time
639638
from unittest.mock import Mock
640639
import matplotlib.pyplot as plt
@@ -689,26 +688,41 @@ def _impl_test_interactive_timers():
689688
f"Restarted single shot fired {single_mock.call_count - 1} times"
690689
assert rearm_mock.call_count == rearm_stopped_at, "stop() did not cancel the timer"
691690

692-
# A slow callback must not push back the firings that follow it. Measuring
693-
# the firings against each other survives a runner that is short on CPU.
694-
interval = 0.1
691+
# A slow callback should skip to the next interval, not drift by
692+
# its own duration. Each gap should land on a multiple of interval.
693+
interval = 1
694+
callback_s = interval / 2
695+
n = 4
695696
fires = []
697+
698+
def callback():
699+
fires.append(time.perf_counter())
700+
time.sleep(callback_s)
701+
if len(fires) >= n:
702+
fig.canvas.stop_event_loop()
703+
696704
slow = fig.canvas.new_timer(interval * 1000)
697-
slow.add_callback(lambda: (fires.append(time.perf_counter()),
698-
time.sleep(interval * 0.8)))
705+
slow.add_callback(callback)
699706
slow.start()
700-
fig.canvas.start_event_loop(0.7)
707+
fig.canvas.start_event_loop(20)
701708
slow.stop()
702-
assert len(fires) >= 4, f"Slow callback only fired {len(fires)} times"
703-
spacing = statistics.median(b - a for a, b in zip(fires, fires[1:]))
704-
assert spacing < interval * 1.15, \
705-
f"Slow callback drifted to {spacing * 1000:.0f}ms spacing"
709+
710+
assert len(fires) >= n, f"Only fired {len(fires)} times"
711+
for a, b in zip(fires, fires[1:]):
712+
gap = b - a
713+
offset = abs(gap - round(gap / interval) * interval)
714+
assert offset < interval * 0.3, (
715+
f"Gap {gap * 1000:.0f}ms is {offset * 1000:.0f}ms from the "
716+
f"nearest multiple of {interval * 1000:.0f}ms")
706717

707718

708719
@pytest.mark.parametrize("env", _get_testable_interactive_backends())
720+
@pytest.mark.flaky(reruns=_retry_count)
709721
def test_interactive_timers(env):
710722
if env["MPLBACKEND"] == "wx":
711723
pytest.skip("wx backend is deprecated; tests failed on appveyor")
724+
if env["MPLBACKEND"] == "gtk3cairo" and os.getenv("CI"):
725+
pytest.skip("gtk3cairo timers do not work in remote CI")
712726
_run_helper(_impl_test_interactive_timers,
713727
timeout=_test_timeout, extra_env=env)
714728

0 commit comments

Comments
 (0)