fix(display): end the scroll state at scroller-to-static handovers (#716)

A static plugin screen that follows a scroller no longer starts with the ticker's lagging rows on scan-compensated panels, and the 1 Hz loop's second frame is no longer recorded as a ~1 s mid-scroll freeze / Render stall. The display controller calls DisplayManager.end_scroll_for_static_screen() before a static screen's first display() (clears the scan history; _scan_segments passes its frames through in one swap) and set_scrolling_state(False) after it; the scroller's hold stays until then, so late-frame counts are unchanged. A screen's first frame is tagged 'handover': gaps of 250 ms or more before it go to the additive handover_freezes (frame_soak prints 'Handover gaps'), not freezes. The display thread is named display-<plugin id>. The WiFi notice and the schedule-off blank are not covered yet (docs list them as a follow-up).

ledpi A B B A soak (20 min each, --preview): main 0.118% / 0.113% late with 6 / 3 freezes; with this and #717 0.107% / 0.104% late, 0 freezes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 20:13:57 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 41db488c73
commit 34be83d595
12 changed files with 1041 additions and 38 deletions
+100 -13
View File
@@ -42,6 +42,7 @@ from src.cache_manager import CacheManager
from src.font_manager import FontManager
from src.logging_config import get_logger
from src.exceptions import PluginError
from src.common.frame_timing import HANDOVER_OP
from src.common.sync_manager import DisplaySyncManager, SyncRole
from src.ipc.server import ControlServer, start_control_server
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
@@ -2571,6 +2572,70 @@ class DisplayController:
logger.debug(f"Found plugin manager for mode {mode}: {type(plugin_instance).__name__}")
return plugin_instance
def _start_screen_handover(self, plugin, active_mode: str) -> bool:
"""Before a screen's first dispatch: if the screen is static, keep
the last scroll's leftovers off its first frame.
Nothing else ends a scroll when the rotation moves on: the state
expires 2 s after the scroller's last frame. Left to that, a static
screen's first frame -- up for a whole second -- went out, on a panel
with scan-order compensation, with rows taken from the scroller's
last frame (after a held scroll, for its first refresh); and its
second frame, 1 s later, was still "mid-scroll", so the frame-timing
soak counted a 1-2 s freeze and the stall watchdog logged a "Render
stall" at every scroller-to-static handover. See
DisplayManager.end_scroll_for_static_screen.
Returns whether the screen is static, for _finish_screen_handover.
False when that cannot be told, which leaves the scroll state as it
was before this existed.
"""
try:
static_screen = not self._needs_high_fps(plugin, active_mode, log=False)
except Exception: # pylint: disable=broad-except
# A plugin property raising. The FPS check after the dispatch is
# where that is reported; here it only means "leave it alone".
logger.debug("Could not tell whether %s is static before its first frame",
active_mode, exc_info=True)
return False
if static_screen:
end_scroll = getattr(self.display_manager, 'end_scroll_for_static_screen', None)
if end_scroll is not None:
end_scroll()
return static_screen
def _note_screen_handover(self) -> None:
"""Tag the frame the first dispatch is about to present.
The gap from the last screen's final frame to it is the next screen
drawing, not a scroll freezing: frame_timing counts it apart from
the freezes, and the stall watchdog labels it a handover gap.
"""
recorder = getattr(self.display_manager, 'frame_timing', None)
note = getattr(recorder, 'note_op', None)
if note is not None:
note(HANDOVER_OP)
def _finish_screen_handover(self, static_screen: bool) -> None:
"""After a screen's first dispatch, whatever it returned.
Drops the handover tag if no frame took it (a screen with nothing to
show), so it cannot land on an unrelated frame later. For a static
screen, also ends the previous scroll now, whether or not it showed
anything: its first frame has gone out, and with the state left set
its next one -- a second later in the 1 Hz loop -- would be timed as
a frame of the old scroll.
"""
dm = self.display_manager
recorder = getattr(dm, 'frame_timing', None)
drop = getattr(recorder, 'drop_op', None)
if drop is not None:
drop(HANDOVER_OP)
if static_screen:
set_scrolling_state = getattr(dm, 'set_scrolling_state', None)
if set_scrolling_state is not None:
set_scrolling_state(False)
def _dispatch_first_frame(self, plugin, active_mode: str) -> Tuple[bool, bool, bool]:
"""Draw the first frame of a screen through the PluginExecutor.
@@ -2592,6 +2657,10 @@ class DisplayController:
display_failed_due_to_exception = False
_accepts_display_mode = False
plugin_id = getattr(plugin, 'plugin_id', active_mode)
# Decided before the first frame rather than at run()'s FPS check
# after it, by when that frame has gone out with the last scroll's
# rows. See _start_screen_handover.
static_screen = self._start_screen_handover(plugin, active_mode)
try:
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
if plugin_id not in self._plugin_accepts_display_mode:
@@ -2606,6 +2675,10 @@ class DisplayController:
display_hung = False
# Set when display() raised inside the executor.
display_error: Optional[Exception] = None
if can_display:
# Only when display() will run: a busy plugin
# presents nothing for the tag to land on.
self._note_screen_handover()
if display_lock is None:
# Only when plugin loading failed part-way.
@@ -2729,6 +2802,10 @@ class DisplayController:
self.force_change = True
display_result = False
display_failed_due_to_exception = True
# Whatever the dispatch did -- drew, had nothing to show, raised
# inside the executor or out here -- and after the health record,
# before the 1 Hz loop or the next mode.
self._finish_screen_handover(static_screen)
return display_result, display_failed_due_to_exception, _accepts_display_mode
def _skip_failed_plugin_modes(self, active_mode: str) -> bool:
@@ -2878,7 +2955,7 @@ class DisplayController:
return None
return min_duration, max_duration
def _needs_high_fps(self, plugin, active_mode: str) -> bool:
def _needs_high_fps(self, plugin, active_mode: str, log: bool = True) -> bool:
"""Whether a screen runs the high-FPS (8 ms) loop or the 1 s one.
In precedence order:
@@ -2889,29 +2966,36 @@ class DisplayController:
the attribute keep the historical forced high-FPS
(GIF support).
3. Otherwise scrolling plugins get high FPS.
``log=False`` for the look taken before a screen's first dispatch
(see _start_screen_handover): the FPS check after it logs the
decision, and once per screen is enough.
"""
plugin_id = getattr(plugin, 'plugin_id', None)
declared = getattr(plugin, 'needs_high_fps', None)
if declared is not None:
needs_high_fps = bool(declared)
logger.debug(
"[DisplayController] FPS check for %s (plugin=%s) - "
"plugin declares needs_high_fps=%s",
active_mode, plugin_id, needs_high_fps)
if log:
logger.debug(
"[DisplayController] FPS check for %s (plugin=%s) - "
"plugin declares needs_high_fps=%s",
active_mode, plugin_id, needs_high_fps)
elif plugin_id == 'static-image':
needs_high_fps = True
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
if log:
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
else:
has_enable_scrolling = hasattr(plugin, 'enable_scrolling')
enable_scrolling_value = getattr(plugin, 'enable_scrolling', False)
needs_high_fps = has_enable_scrolling and enable_scrolling_value
logger.info(
"FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s",
active_mode,
has_enable_scrolling,
enable_scrolling_value,
needs_high_fps,
)
if log:
logger.info(
"FPS check for %s - has_enable_scrolling: %s, enable_scrolling_value: %s, needs_high_fps: %s",
active_mode,
has_enable_scrolling,
enable_scrolling_value,
needs_high_fps,
)
return needs_high_fps
def _advance_after_screen(self, active_mode: Optional[str]) -> None:
@@ -3194,6 +3278,9 @@ class DisplayController:
continue
min_duration, max_duration = bounds
# High-FPS decision; see _needs_high_fps for the order.
# Read again here, after the first dispatch, as it always
# was: a plugin may settle it in that display() call.
needs_high_fps = self._needs_high_fps(manager_to_display, active_mode)
target_duration = max_duration