fix(display): stop the run loop spinning when no mode has anything to show (#649)

* fix(display): stop the run loop spinning when no mode has anything to show

A mode whose display() reports nothing rotates to the next at once, with no
dwell. With every enabled mode empty (only a sports plugin in its
off-season, say) the loop went round with no sleep: on ledpi, 169% CPU and
~1,800 "No content" log lines every 10 seconds. After one full rotation of
empty passes it now pauses EMPTY_ROTATION_PAUSE (1s) per pass, servicing
plugin updates and returning early on on-demand or schedule changes; live
priority is still checked at the top of every pass, and the streak resets
as soon as any mode shows something.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): restart the loop if the empty-rotation pause starts on-demand; per-rotation streak

- an on-demand request serviced during the pause returned early into the
  on-demand branch, which advanced past the mode just requested; the loop
  now restarts when the pause changed the mode, on-demand state or schedule
- the streak is reset when the rotation changes (on-demand start/stop, a
  plugin enabled or disabled), so a streak from one rotation can't make
  another pause before its own modes are tried
- docstring: live content is picked up within the pause, not "at once"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-28 09:07:08 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent aeaeaa4e94
commit 224847cebc
3 changed files with 216 additions and 1 deletions
+2
View File
@@ -19,6 +19,8 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
- The display loop no longer spins at 100% CPU when no enabled mode has anything to show (for example, only a sports plugin enabled in its off-season). After one full rotation of empty modes it checks one mode per second until something shows; live content still takes over at once.
- Contributor tooling and docs: - Contributor tooling and docs:
- `mypy.ini` parses again. A multi-line `exclude` and trailing comments on values made mypy refuse the whole file, so none of its settings applied and the pre-commit hook failed with "Missing target". The mypy hook is now manual (`pre-commit run mypy --hook-stage manual`) while the ~500 existing type errors in `src/` are paid down. - `mypy.ini` parses again. A multi-line `exclude` and trailing comments on values made mypy refuse the whole file, so none of its settings applied and the pre-commit hook failed with "Missing target". The mypy hook is now manual (`pre-commit run mypy --hook-stage manual`) while the ~500 existing type errors in `src/` are paid down.
- `.gitignore` ignores everything in `config/` except the templates; `ytm_auth.json`, `saved_repositories.json`, `wifi_status.json` and `font_overrides.json` weren't ignored. - `.gitignore` ignores everything in `config/` except the templates; `ytm_auth.json`, `saved_repositories.json`, `wifi_status.json` and `font_overrides.json` weren't ignored.
+54 -1
View File
@@ -28,7 +28,7 @@ import json
import threading import threading
import types import types
from contextlib import contextmanager from contextlib import contextmanager
from typing import Dict, Any, List, Optional, Callable from typing import Dict, Any, List, Optional, Callable, Tuple
from datetime import datetime from datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
import pytz import pytz
@@ -101,6 +101,16 @@ class DisplayController:
it and start the run loop. it and start the run loop.
""" """
#: How long the run loop pauses per pass once a whole rotation has had
#: nothing to show. See _note_empty_pass.
EMPTY_ROTATION_PAUSE = 1.0
#: Consecutive passes whose mode had nothing to show, and the rotation
#: (on-demand or not, and its modes) they were counted in. Class-level so
#: controllers built without __init__ (tests) have them too.
_empty_pass_streak = 0
_empty_pass_rotation: Optional[Tuple[bool, Tuple[str, ...]]] = None
def __init__(self): def __init__(self):
start_time = time.time() start_time = time.time()
logger.info("Starting DisplayController initialization") logger.info("Starting DisplayController initialization")
@@ -1088,6 +1098,38 @@ class DisplayController:
or self.on_demand_active != on_demand): or self.on_demand_active != on_demand):
break break
def _note_empty_pass(self) -> None:
"""Record a pass whose mode had nothing to show; pause once a whole
rotation has been empty.
A mode with no content rotates to the next one at once, with no dwell.
When every mode is empty -- say, only a sports plugin enabled in its
off-season -- the loop went round with no sleep at all: 100% of a core,
a plugin-executor thread per pass and several log lines each time,
indefinitely. After one full rotation of empty passes, each further
one pauses EMPTY_ROTATION_PAUSE seconds. Live content is still picked
up within that second (the live-priority check runs at the top of
every pass), the pause services plugin updates, and it returns early
on an on-demand request or a schedule change. The streak resets as
soon as any mode shows something, and when the rotation itself
changes (on-demand starting or stopping, a plugin enabled or
disabled): a streak counted in one rotation says nothing about the
modes of another, which haven't been tried yet.
"""
modes = self.on_demand_modes if self.on_demand_active else self.available_modes
rotation_key = (bool(self.on_demand_active), tuple(modes))
if rotation_key != self._empty_pass_rotation:
self._empty_pass_rotation = rotation_key
self._empty_pass_streak = 0
self._empty_pass_streak += 1
rotation = max(1, len(modes))
if self._empty_pass_streak < rotation:
return
if self._empty_pass_streak == rotation:
logger.info("No mode has anything to show; checking one mode every %.0fs "
"until one does", self.EMPTY_ROTATION_PAUSE)
self._sleep_with_plugin_updates(self.EMPTY_ROTATION_PAUSE)
def _get_display_duration(self, mode_key): def _get_display_duration(self, mode_key):
"""Seconds to show a mode: the Rotation & Durations page's value for it """Seconds to show a mode: the Rotation & Durations page's value for it
(display.display_durations), else the plugin's own duration. (display.display_durations), else the plugin's own duration.
@@ -2347,6 +2389,16 @@ class DisplayController:
# If display() returned False, skip to next mode immediately # If display() returned False, skip to next mode immediately
if not display_result: if not display_result:
was_on_demand = self.on_demand_active
self._note_empty_pass()
# The pause returns early when an on-demand request, its
# end, or the schedule decides what comes next. Rotating
# past this empty mode now would skip that: an on-demand
# start would advance past the mode just requested.
if (self.current_display_mode != active_mode
or self.on_demand_active != was_on_demand
or not self.is_display_active):
continue
if self.on_demand_active: if self.on_demand_active:
logger.info("No content for on-demand mode %s, skipping to next mode", active_mode) logger.info("No content for on-demand mode %s, skipping to next mode", active_mode)
if not self.on_demand_modes: if not self.on_demand_modes:
@@ -2395,6 +2447,7 @@ class DisplayController:
# If no exception (just no content), fall through to normal rotation logic # If no exception (just no content), fall through to normal rotation logic
# This allows trying other modes (recent, upcoming) from the same plugin # This allows trying other modes (recent, upcoming) from the same plugin
else: else:
self._empty_pass_streak = 0
# Get base duration for current mode # Get base duration for current mode
base_duration = self._get_display_duration(active_mode) base_duration = self._get_display_duration(active_mode)
dynamic_enabled = self._plugin_supports_dynamic(manager_to_display) dynamic_enabled = self._plugin_supports_dynamic(manager_to_display)
@@ -0,0 +1,160 @@
"""The run loop must not spin when no mode has anything to show.
A mode with no content rotates to the next one at once. When every enabled
mode is empty (only a sports plugin enabled in its off-season, say), the loop
used to go round with no sleep at all: a full core, a plugin-executor thread
per pass and several log lines each time, forever. After one empty rotation it
now pauses between passes, and it stops pausing as soon as something shows.
"""
import os
os.environ.setdefault("EMULATOR", "true")
from src.display_controller import DisplayController # noqa: E402
def _bare_controller(modes, on_demand_modes=None):
dc = object.__new__(DisplayController)
dc.available_modes = list(modes)
dc.on_demand_active = on_demand_modes is not None
dc.on_demand_modes = list(on_demand_modes or [])
dc.sleeps = []
dc._sleep_with_plugin_updates = lambda duration, tick_interval=1.0: dc.sleeps.append(duration)
return dc
class TestNoteEmptyPass:
def test_a_partly_empty_rotation_never_pauses(self):
dc = _bare_controller(["a", "b", "c"])
dc._note_empty_pass()
dc._note_empty_pass()
assert dc.sleeps == []
def test_a_fully_empty_rotation_pauses_every_further_pass(self):
dc = _bare_controller(["a", "b", "c"])
for _ in range(5):
dc._note_empty_pass()
# Passes 3, 4 and 5 are at or past one full rotation.
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE] * 3
def test_content_resets_the_streak(self):
dc = _bare_controller(["a", "b"])
dc._note_empty_pass()
dc._note_empty_pass()
assert len(dc.sleeps) == 1
dc._empty_pass_streak = 0 # what the run loop does when a mode shows
dc._note_empty_pass()
assert len(dc.sleeps) == 1
def test_on_demand_counts_its_own_modes(self):
dc = _bare_controller(["a", "b", "c", "d"], on_demand_modes=["x"])
dc._note_empty_pass()
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE]
def test_a_single_mode_pauses_straight_away(self):
dc = _bare_controller(["only"])
dc._note_empty_pass()
assert dc.sleeps == [DisplayController.EMPTY_ROTATION_PAUSE]
class TestRunLoopWithNothingToShow:
def test_run_pauses_after_one_empty_rotation(self, test_display_controller):
controller = test_display_controller
# Modes with no plugin behind them take the "nothing to display" path.
controller.available_modes = ["m1", "m2", "m3"]
controller.current_mode_index = 0
controller.plugin_modes = {}
# Count passes at the top of the loop. The cap stops a spinning loop
# (the old behaviour) instead of hanging the test run.
passes = []
original_poll = controller._poll_on_demand_requests
def counting_poll():
passes.append(1)
if len(passes) > 50:
raise RuntimeError("stop-test-loop: spinning")
original_poll()
sleeps = []
def fake_sleep(duration, tick_interval=1.0):
sleeps.append((len(passes), duration))
if len(sleeps) >= 2:
raise RuntimeError("stop-test-loop")
controller._poll_on_demand_requests = counting_poll
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# Three empty passes (one full rotation) before the first pause, then
# one pause per further pass -- not thousands of passes a second.
assert [n for n, _ in sleeps] == [3, 4]
assert all(d == DisplayController.EMPTY_ROTATION_PAUSE for _, d in sleeps)
class TestRotationChanges:
def test_a_new_rotation_starts_a_new_streak(self):
# A long empty streak in a one-mode on-demand session must not make
# the normal rotation pause before its own modes have been tried.
dc = _bare_controller(["a", "b", "c"], on_demand_modes=["x"])
for _ in range(4):
dc._note_empty_pass()
assert len(dc.sleeps) == 4
dc.on_demand_active = False
dc._note_empty_pass()
dc._note_empty_pass()
assert len(dc.sleeps) == 4
dc._note_empty_pass()
assert len(dc.sleeps) == 5
def test_a_changed_mode_list_starts_a_new_streak(self):
dc = _bare_controller(["a"])
dc._note_empty_pass()
assert len(dc.sleeps) == 1
dc.available_modes = ["a", "b"] # a plugin was enabled
dc._note_empty_pass()
assert len(dc.sleeps) == 1
class TestOnDemandDuringThePause:
def test_the_requested_mode_is_shown_first(self, test_display_controller):
controller = test_display_controller
controller.available_modes = ["m1", "m2", "m3"]
controller.current_mode_index = 0
controller.plugin_modes = {}
tried = []
original_note = controller._note_empty_pass
def recording_note():
tried.append(controller.current_display_mode)
if len(tried) > 50:
raise RuntimeError("stop-test-loop: spinning")
original_note()
sleeps = []
def fake_sleep(duration, tick_interval=1.0):
sleeps.append(duration)
if len(sleeps) == 1:
# What the real pause does when an on-demand request lands:
# activate it and return early.
controller.on_demand_active = True
controller.on_demand_modes = ["x", "y"]
controller.on_demand_mode_index = 0
controller.current_display_mode = "x"
else:
raise RuntimeError("stop-test-loop")
controller._note_empty_pass = recording_note
controller._sleep_with_plugin_updates = fake_sleep
controller.run()
# m1, m2, m3 empty -> pause -> on-demand starts; "x" must come next,
# not be skipped by rotating the empty pass that was interrupted.
assert tried[:3] == ["m1", "m2", "m3"]
assert tried[3] == "x"