diff --git a/CHANGELOG.md b/CHANGELOG.md index e523b2cf..f8a04d6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ accepts both, but the store flags the old spelling as deprecated ## 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: - `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. diff --git a/src/display_controller.py b/src/display_controller.py index bd2a5fa8..6c91b170 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -28,7 +28,7 @@ import json import threading import types 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 concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module import pytz @@ -101,6 +101,16 @@ class DisplayController: 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): start_time = time.time() logger.info("Starting DisplayController initialization") @@ -1088,6 +1098,38 @@ class DisplayController: or self.on_demand_active != on_demand): 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): """Seconds to show a mode: the Rotation & Durations page's value for it (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 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: logger.info("No content for on-demand mode %s, skipping to next mode", active_mode) if not self.on_demand_modes: @@ -2395,6 +2447,7 @@ class DisplayController: # If no exception (just no content), fall through to normal rotation logic # This allows trying other modes (recent, upcoming) from the same plugin else: + self._empty_pass_streak = 0 # Get base duration for current mode base_duration = self._get_display_duration(active_mode) dynamic_enabled = self._plugin_supports_dynamic(manager_to_display) diff --git a/test/test_display_controller_empty_rotation.py b/test/test_display_controller_empty_rotation.py new file mode 100644 index 00000000..1ef7b6b6 --- /dev/null +++ b/test/test_display_controller_empty_rotation.py @@ -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"