Files
LEDMatrix/test/test_plugin_update_tick_throttle.py
T
ChuckandClaude Opus 5.5 0d179fdf12 perf(display): throttle the per-frame update tick; check strips without building them (#731)
The frame loops and the dwell sleep run PluginManager.run_scheduled_updates() at most every 0.25 s instead of after every frame (the top of each loop pass still ticks unthrottled), and SportsScrollDisplay and the sync follower ask ScrollHelper.has_strip() instead of building cached_image just to see whether a strip exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 14:01:23 -04:00

135 lines
5.3 KiB
Python

"""The render loop's scheduled-update pass, throttled per frame.
The frame loops called _tick_plugin_updates() after every frame, about 125
times a second on a scroller, and each call ran
PluginManager.run_scheduled_updates(): a copy of the plugin dict and several
locks per plugin, to find that nothing was due (no interval is shorter than
5 s). The frame loops and the dwell sleep now call
_tick_plugin_updates_if_due(), which runs it at most once per
PLUGIN_UPDATE_TICK_INTERVAL. The top of each loop pass still calls
_tick_plugin_updates() unthrottled, because a plugin loaded, reloaded or
enabled for on-demand there is due at once.
"""
import os
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
os.environ.setdefault("EMULATOR", "true")
from src import display_controller as dc_mod # noqa: E402
from src.display_controller import DisplayController # noqa: E402
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
@pytest.fixture
def clock(monkeypatch):
now = {"t": 1000.0}
fake = SimpleNamespace(monotonic=lambda: now["t"], time=lambda: now["t"])
monkeypatch.setattr(dc_mod, "time", fake)
return now
@pytest.fixture
def dc():
controller = object.__new__(DisplayController)
controller.plugin_manager = MagicMock()
return controller
def _calls(dc):
return dc.plugin_manager.run_scheduled_updates.call_count
class TestThrottle:
def test_the_first_call_runs(self, dc, clock):
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 1
def test_calls_inside_the_window_are_skipped(self, dc, clock):
dc._tick_plugin_updates_if_due()
for _ in range(30): # a scroller's frames, 8 ms apart
clock["t"] += 0.008
dc._tick_plugin_updates_if_due()
assert clock["t"] - 1000.0 < dc.PLUGIN_UPDATE_TICK_INTERVAL
assert _calls(dc) == 1
def test_it_runs_again_once_the_window_has_passed(self, dc, clock):
dc._tick_plugin_updates_if_due()
clock["t"] += dc.PLUGIN_UPDATE_TICK_INTERVAL
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 2
def test_the_unthrottled_tick_always_runs_and_restarts_the_window(self, dc, clock):
# The top of the loop pass: a plugin just (re)loaded is due now.
dc._tick_plugin_updates_if_due()
clock["t"] += 0.01
dc._tick_plugin_updates()
assert _calls(dc) == 2
# ...and the frame right after it does not repeat the pass.
clock["t"] += 0.01
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 2
def test_no_plugin_manager(self, clock):
controller = object.__new__(DisplayController)
controller.plugin_manager = None
controller._tick_plugin_updates_if_due() # must not raise
controller._tick_plugin_updates()
def test_a_failing_pass_is_contained_and_still_throttled(self, dc, clock):
dc.plugin_manager.run_scheduled_updates.side_effect = RuntimeError("boom")
dc._tick_plugin_updates_if_due()
dc._tick_plugin_updates_if_due()
assert _calls(dc) == 1
def test_the_vegas_tick_is_not_throttled(self, dc, clock):
# Vegas's own update thread calls the plugin manager directly.
dc.vegas_coordinator = None
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = []
dc._tick_plugin_updates()
dc._tick_plugin_updates_for_vegas()
dc._tick_plugin_updates_for_vegas()
assert dc.plugin_manager.run_scheduled_updates_with_changes.call_count == 2
class TestInTheRunLoop:
"""The real run() on the golden-trace harness's fake clock."""
@pytest.fixture
def run(self, tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
h.add_plugin(FakePlugin("ticker", ["ticker"], duration=10, enable_scrolling=True))
h.add_plugin(FakePlugin("clock", ["clock"], duration=10))
ticks = []
h.pm.run_scheduled_updates = lambda: ticks.append(round(h.clock.rel(), 3))
h.run()
passes = sorted({t for t, kind, _s, _d in h.events if kind == "pass"})
frames = [t for t, kind, s, _d in h.events if kind in ("first", "frame") and s == "ticker"]
return SimpleNamespace(ticks=ticks, passes=passes, frames=frames,
interval=h.controller.PLUGIN_UPDATE_TICK_INTERVAL)
def test_a_scroller_ticks_a_few_times_a_second_not_every_frame(self, run):
ticker_frames = len(run.frames)
assert ticker_frames > 500 # 10 s screens at 125 Hz, twice
# 40 s at four a second, plus one per pass.
assert len(run.ticks) <= 40 / run.interval + len(run.passes) + 1
def test_ticks_are_never_closer_than_the_floor_except_at_a_pass(self, run):
passes = set(run.passes)
for prev, cur in zip(run.ticks, run.ticks[1:]):
if cur - prev < run.interval - 1e-6:
assert cur in passes, (prev, cur)
def test_every_pass_ticks_at_once(self, run):
# Unthrottled: the frame loop ticked just before the screen ended, and
# the pass ticks again anyway, so a plugin it just loaded is not kept
# waiting.
ticks = set(run.ticks)
for t in run.passes:
assert t in ticks, t
assert any(cur - prev < run.interval
for prev, cur in zip(run.ticks, run.ticks[1:]))