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
+90
View File
@@ -11,6 +11,7 @@ import sys
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -141,6 +142,95 @@ def test_aggregate_still_takes_frames_without_ops(tmp_path):
assert r.totals["op_frames"] == {}
# -- screen handovers ---------------------------------------------------------
# The display controller tags a screen's first frame "handover". A freeze that
# frame ends is the next plugin drawing, not a scroll that stalled.
def test_a_handover_freeze_is_not_a_freeze(tmp_path):
r = _recorder(tmp_path)
_frame(r, 100.0)
r.note_op("handover")
_frame(r, 101.4) # 1.4s to draw the next screen
r.note_op("extend", 10)
_frame(r, 101.8) # a real one, for contrast
totals = _totals(r)
assert totals["handover_freezes"] == 1
assert totals["freezes"] == 1
assert totals["freeze_by"] == {"<0.5s": 1, "0.5-1s": 0, "1-2s": 0, "2s+": 0}
assert totals["freeze_seconds"] == pytest.approx(0.4)
# Still on the "after work" table, with its freezes column.
assert totals["op_freezes"] == {"handover": 1, "extend": 1}
def test_a_quick_handover_is_an_ordinary_timed_frame(tmp_path):
r = _recorder(tmp_path)
_frame(r, 100.0)
r.note_op("handover")
_frame(r, 100.0 + 4 * PERIOD)
totals = _totals(r)
assert totals["handover_freezes"] == totals["freezes"] == 0
assert totals["op_frames"] == {"handover": 1}
assert totals["late_op_frames"] == {"handover": 1}
def test_a_dropped_note_tags_nothing(tmp_path):
# The first display() drew nothing: the tag must not wait for whatever
# frame comes next, here a stall a minute later.
r = _recorder(tmp_path)
_frame(r, 100.0)
r.note_op("handover")
r.drop_op("handover")
_frame(r, 101.0)
totals = _totals(r)
assert totals["handover_freezes"] == 0
assert totals["freezes"] == 1
assert totals["op_freezes"] == {}
def test_dropping_a_note_keeps_other_kinds_and_is_harmless_when_none(tmp_path):
r = _recorder(tmp_path)
r.drop_op("handover") # nothing noted: nothing to do
_frame(r, 100.0)
r.note_op("handover")
r.note_op("patch", 5)
r.drop_op("handover")
_frame(r, 100.0 + PERIOD)
r.drop_op("handover") # already carried by a frame
totals = _totals(r)
assert totals["op_frames"] == {"patch": 1}
def test_the_soak_report_prints_handover_gaps(tmp_path, capsys):
r = _recorder(tmp_path)
before = json.loads(json.dumps(r.snapshot()))
_frame(r, 100.0)
r.note_op("handover")
_frame(r, 100.5)
report = _report(r, before)
assert report["handover_freezes"] == 1
assert report["freezes"] == 0
frame_soak.print_report(report, 0.1)
assert "Handover gaps 1" in capsys.readouterr().out
def test_a_report_from_an_older_recorder_has_no_handover_line(tmp_path, capsys):
# Stats written before the count existed: diffed and printed without it.
r = _recorder(tmp_path)
before = json.loads(json.dumps(r.snapshot()))
_frame(r, 100.0)
_frame(r, 100.0 + PERIOD)
r.drain()
after = json.loads(json.dumps(r.snapshot()))
after["updated"] = before["updated"] + 10.0
for stats in (before, after):
del stats["totals"]["handover_freezes"]
report = frame_soak.build_report(before, after, preview=False)
assert report["handover_freezes"] is None
frame_soak.print_report(report, 0.1)
assert "Handover gaps" not in capsys.readouterr().out
# -- the soak report ----------------------------------------------------------