Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Avoid timer drift during long callbacks #29023

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions 14 lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,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

Expand Down Expand Up @@ -1085,8 +1087,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):
Expand All @@ -1095,8 +1098,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):
"""
Expand Down
22 changes: 21 additions & 1 deletion 22 lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import os.path
import pathlib
import sys
import time
import tkinter as tk
import tkinter.filedialog
import tkinter.font
Expand Down Expand Up @@ -132,21 +133,40 @@ 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:
self.parent.after_cancel(self._timer)
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,
# if _timer is None, this means that _timer_stop has been called; so
# 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.
Expand Down
24 changes: 23 additions & 1 deletion 24 lib/matplotlib/tests/test_backend_bases.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
19 changes: 19 additions & 0 deletions 19 lib/matplotlib/tests/test_backends_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,8 +626,10 @@ def _impl_test_interactive_timers():
# We only want singleshot if we specify that ourselves, otherwise we want
# a repeating timer
import os
import time
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
Expand All @@ -637,9 +639,26 @@ def _impl_test_interactive_timers():
mock = Mock()
timer.add_callback(mock)
timer.start()

mock_slow_callback = Mock()
def slow_callback():
time.sleep(0.075) # 75ms
mock_slow_callback()
# 100ms timer triggers and the callback takes 75ms to run
# Make sure we don't drift and get called on every 100ms
# interval and not every 175ms
timer_slow_callback = fig.canvas.new_timer(100)
timer_slow_callback.add_callback(slow_callback)
timer_slow_callback.start()

plt.pause(pause_time)
timer.stop()
timer_slow_callback.stop()
assert mock.call_count > 1
expected_slow_calls = pause_time * 10 - 1
assert expected_slow_calls <= mock_slow_callback.call_count, \
f"Expected at least {expected_slow_calls} calls, " \
f"got {mock_slow_callback.call_count}"

# Now turn it into a single shot timer and verify only one gets triggered
mock.call_count = 0
Expand Down
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.