fix(display): order snapshot writes, retry a failed one, stop the writer on cleanup; measure refresh from the low end

From review (CodeRabbit):

- A static frame's inline save now waits for a queued write in flight and
  drops a stale queued frame, under one write lock taken by both paths, so
  the last scrolling frame can no longer land on top of the first static one.
- A background write that fails clears the recorded digest, so an unchanged
  frame is written again instead of mtime-touching a stale file into looking
  healthy.
- cleanup() stops and joins the writer thread, dropping a frame it hasn't
  started, instead of leaving it (and its DisplayManager) alive.
- Vegas measures the panel's refresh from the 10th percentile of swap gaps,
  not the median: a late swap only lengthens a gap, so with most frames late
  in the window the median tracked the loop and locked in a rate too low
  (then the scroll ran fast until restart). Samples are dropped whenever
  the pacing is re-solved, so gaps from an old frame hold aren't divided by
  a new one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-24 18:28:42 -04:00
co-authored by Claude Opus 5.5
parent c43c10bd78
commit 00e14ea55f
4 changed files with 151 additions and 14 deletions
+26
View File
@@ -9,6 +9,7 @@ import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -122,6 +123,31 @@ def test_a_panel_that_keeps_up_with_its_cap_is_left_alone():
assert p._crisp == crisp
def test_a_window_of_mostly_late_frames_still_measures_the_panel():
# A late swap only lengthens its gap, by whole refreshes. A window where
# most frames missed a vsync (startup, a prefetch) must not read as a
# slower panel: with 7 frames in 10 a refresh late, the median would say
# 76Hz here and the speed would be solved for a panel that isn't there.
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
period, hold = 1 / 95.0, p._frame_hold
gaps = [(hold + 1) * period if i % 10 < 7 else hold * period for i in range(400)]
clock = [1000.0]
with patch.object(rp_module.time, 'monotonic', lambda: clock[0]):
for gap in gaps:
p.render_frame()
clock[0] += gap
if p._measured_hz is not None:
break
assert p._measured_hz == pytest.approx(95.0)
def test_re_solving_the_pacing_drops_samples_timed_under_the_old_hold():
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
p._swap_times.extend([1.0, 1.01, 1.02])
p._configure_scroll_helper()
assert len(p._swap_times) == 0
def test_no_measurement_without_hardware():
# Nothing blocks in the emulator, so swap gaps say nothing about a panel.
p = _pipeline(FakeDM(refresh_hz=120.0, hardware=False), scroll_speed=90)