fix(perf): count a stall even when the scroll state went missing across it

On hdpi the stall watchdog logged a 1.9s stall during the hourly sports
refresh that the soak report never had: its worst gap was 655ms. The frame
that ended the stall was recorded as static, so its interval was dropped.

"Scrolling" is DisplayManager's scroll state at the moment a frame is
presented, and it goes missing mid-scroll: it expires after 2s without
activity, and any thread can clear it. Plugins call
set_scrolling_state(False) from their own display() (news, stocks, the odds
ticker's fallback), and Vegas captures some of those on the render thread
between two of its own frames. Vegas sets the state again only after its
next frame, so that frame is recorded as static -- along with the capture
or stall it followed.

One static frame between two scrolling frames, with the scroll resuming
within RESUME_SECONDS (1s), is now a frame of the scroll and both of its
intervals count, the first at the scroll's own hold (clearing the state
drops the hold to 1 too). Two static frames in a row still end the scroll.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 15:23:18 -04:00
co-authored by Claude Opus 5.5
parent b67818d5c5
commit 14c38a3189
3 changed files with 108 additions and 7 deletions
+66
View File
@@ -121,6 +121,72 @@ def test_a_stall_between_one_and_two_seconds_is_not_lost(tmp_path):
assert _aggregate(r)["freezes"] == 1
def test_a_stall_whose_scroll_state_went_missing_still_counts(tmp_path):
# hdpi, 14:48: the watchdog logged a 1.9s stall the soak never reported.
# A plugin captured between two Vegas frames cleared the scroll state, so
# the frame that ended the stall was recorded as static and its interval
# dropped. Vegas set the state again right after, as it does every frame.
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
t += 1.911
r.record(0.002, 0.004, 1, False, t) # state missing: "static"
_feed(r, [PERIOD] * 100, start=t + PERIOD) # the scroll carries on
totals = _aggregate(r)
assert totals["freezes"] == 1
assert totals["freeze_by"]["1-2s"] == 1
assert totals["static_frames"] == 0
# 100 before, the step back into the scroll, 100 after: all but the freeze.
assert totals["scroll_frames"] == 100 + 1 + 100
def test_a_frame_with_its_scroll_state_missing_is_still_timed(tmp_path):
# No stall at all -- the state was cleared and the frame went out on
# time. Nothing is late and no interval is lost.
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
r.record(0.002, 0.004, 1, False, t + PERIOD)
_feed(r, [PERIOD] * 100, start=t + 2 * PERIOD)
totals = _aggregate(r)
assert totals["scroll_frames"] == 202
assert totals["late_frames"] == totals["freezes"] == totals["static_frames"] == 0
def test_a_scroll_that_really_ended_is_not_a_freeze(tmp_path):
# Two static frames in a row, then a new scroll: the gaps between them
# were a static screen, not a stall.
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
t = _feed(r, [0.5], scrolling=False, start=t + 0.3)
_feed(r, [PERIOD] * 100, start=t + 0.4)
totals = _aggregate(r)
assert totals["freezes"] == 0
assert totals["static_frames"] == 2
assert totals["scroll_frames"] == 200
def test_one_static_frame_then_a_scroll_much_later_is_a_new_scroll(tmp_path):
r = _recorder(tmp_path)
t = _feed(r, [PERIOD] * 100)
r.record(0.002, 0.004, 1, False, t + 0.3)
_feed(r, [PERIOD] * 100, start=t + 0.3 + frame_timing.RESUME_SECONDS + 0.1)
totals = _aggregate(r)
assert totals["freezes"] == 0
assert totals["static_frames"] == 1
assert totals["scroll_frames"] == 200
def test_a_missing_state_interval_is_due_at_the_scrolls_own_hold(tmp_path):
# Clearing the state drops the hold to 1 as well, so the "static" frame
# reports hold 1. Its interval is still due two refreshes after the last.
r = _recorder(tmp_path)
t = _feed(r, [2 * PERIOD] * 100, hold=2)
r.record(0.002, 0.004, 1, False, t + 2 * PERIOD)
_feed(r, [2 * PERIOD] * 100, hold=2, start=t + 4 * PERIOD)
totals = _aggregate(r)
assert totals["late_frames"] == totals["early_frames"] == 0
assert totals["scroll_frames"] == 202
def test_early_frames_are_counted(tmp_path):
# Hold 2 on a 100Hz panel: frames are due every 20ms. A swap that returns
# after 10ms did not wait out the hold.