mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
fix(display): Vegas resumes after live priority, and six smaller runtime fixes (#644)
- Vegas: a live-priority pause was only lifted from inside run_frame(), which returns before that check while paused, so the ticker never came back until a restart. run_iteration() now resumes it (the controller only calls it when nothing preempts Vegas); start()/stop() clear the pause state. Iteration length is timed with the monotonic clock. - Dim schedule: a per-day disabled day now updates the minute-gate cache, so brightness no longer flips back to dim within each minute. - On-demand: a second request no longer overwrites the rotation resume index with the first request's mode. - Render pipeline: reset() drops the prepared group and deferred queue, and a prefetch in flight across a reset discards its result. - Sync: stop() removes the status file (and the controller's cleanup now calls it), standalone removes a stale one at startup, and writes use a unique mkstemp temp file. - render_gate.swap_releases_gil() delegates to frame_timing. - Stale docstrings/comments corrected. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,31 @@ def test_cleanup_without_vegas_does_not_raise():
|
||||
dc.display_manager.cleanup.assert_called_once_with()
|
||||
|
||||
|
||||
def test_cleanup_stops_display_sync_after_vegas():
|
||||
# Stopping sync withdraws its status file; left behind, the web UI kept
|
||||
# reporting a connected peer after the display service had stopped.
|
||||
order = []
|
||||
vegas = MagicMock()
|
||||
vegas.cleanup.side_effect = lambda: order.append('vegas')
|
||||
dc = _controller(vegas)
|
||||
dc.sync_manager = MagicMock()
|
||||
dc.sync_manager.stop.side_effect = lambda: order.append('sync')
|
||||
|
||||
dc.cleanup()
|
||||
|
||||
assert order == ['vegas', 'sync']
|
||||
|
||||
|
||||
def test_a_failing_sync_stop_does_not_stop_the_rest():
|
||||
dc = _controller(None)
|
||||
dc.sync_manager = MagicMock()
|
||||
dc.sync_manager.stop.side_effect = OSError('boom')
|
||||
|
||||
dc.cleanup()
|
||||
|
||||
dc.display_manager.cleanup.assert_called_once_with()
|
||||
|
||||
|
||||
def test_a_failing_vegas_teardown_does_not_stop_the_rest():
|
||||
vegas = MagicMock()
|
||||
vegas.cleanup.side_effect = RuntimeError('boom')
|
||||
|
||||
@@ -254,6 +254,27 @@ class TestDimSchedule:
|
||||
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
|
||||
assert dc.is_dimmed is False
|
||||
|
||||
def test_disabled_day_stays_normal_within_the_minute(self):
|
||||
# Dim late Monday, then Tuesday (dimming disabled) begins. The second
|
||||
# call in the same minute is served from the minute-gate cache, which
|
||||
# the disabled-day branch used to leave holding Monday's dim value --
|
||||
# so brightness flipped back to dim for the rest of every minute.
|
||||
dc = make_controller(self._config(mode="per-day", days={
|
||||
"monday": {"enabled": True, "start_time": "22:00",
|
||||
"end_time": "06:00"},
|
||||
"tuesday": {"enabled": False},
|
||||
}))
|
||||
assert dim_at(dc, "23:59", day="monday") == 25
|
||||
dc._dim_checked_minute = None
|
||||
p = at("00:00", day="tuesday")
|
||||
try:
|
||||
assert dc._check_dim_schedule() == 90
|
||||
assert dc._check_dim_schedule() == 90 # cached, same minute
|
||||
finally:
|
||||
p.stop()
|
||||
assert dc.is_dimmed is False
|
||||
assert dc._was_dimmed is False
|
||||
|
||||
def test_no_legacy_inference_for_dim(self):
|
||||
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a
|
||||
# days config exists — no legacy inference.
|
||||
|
||||
@@ -99,6 +99,31 @@ class TestPinSurvivesARestart:
|
||||
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'}
|
||||
|
||||
|
||||
class TestASecondRequestKeepsTheResumePoint:
|
||||
"""Clearing returns to where the normal rotation was, not the last request.
|
||||
|
||||
A second start while on-demand was showing overwrote the saved resume
|
||||
index with the first request's mode, so stopping resumed rotation there.
|
||||
"""
|
||||
|
||||
def test_clear_resumes_the_original_rotation(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
_plugin_with_modes(c, 'clock', ['clock'])
|
||||
_plugin_with_modes(c, 'weather', ['weather'])
|
||||
_plugin_with_modes(c, 'stocks', ['stocks'])
|
||||
c.available_modes = ['clock', 'weather', 'stocks']
|
||||
c.current_mode_index = 0
|
||||
c.current_display_mode = 'clock'
|
||||
|
||||
c._activate_on_demand({'plugin_id': 'weather', 'mode': 'weather'})
|
||||
c._activate_on_demand({'plugin_id': 'stocks', 'mode': 'stocks'})
|
||||
assert c.current_display_mode == 'stocks'
|
||||
|
||||
c._clear_on_demand(reason='requested-stop')
|
||||
assert c.current_mode_index == 0
|
||||
assert c.current_display_mode == 'clock'
|
||||
|
||||
|
||||
class TestRestartDoesNotStarveTheOtherPlugins:
|
||||
"""Restarting mid-on-demand used to load only the on-demand plugin.
|
||||
|
||||
|
||||
@@ -430,3 +430,25 @@ class TestVegasWiring:
|
||||
pipeline._prefetch_thread.join(5)
|
||||
assert seen == [(True, True)]
|
||||
assert pipeline._prepared_group == ["segment"]
|
||||
|
||||
|
||||
class TestBindingCheck:
|
||||
"""swap_releases_gil() is frame_timing's check, not a second copy of it."""
|
||||
|
||||
@pytest.mark.parametrize("contents, expected", [
|
||||
(b"...PyEval_SaveThread...", True),
|
||||
(b"stock binding", False),
|
||||
])
|
||||
def test_agrees_with_frame_timing(self, monkeypatch, tmp_path, contents, expected):
|
||||
from types import SimpleNamespace
|
||||
from src.common import frame_timing
|
||||
binding = tmp_path / "core.so"
|
||||
binding.write_bytes(contents)
|
||||
monkeypatch.setitem(sys.modules, "rgbmatrix.core",
|
||||
SimpleNamespace(__file__=str(binding)))
|
||||
assert render_gate.swap_releases_gil() is expected
|
||||
assert frame_timing.binding_releases_gil() is expected
|
||||
|
||||
def test_none_without_a_binding(self, monkeypatch):
|
||||
monkeypatch.delitem(sys.modules, "rgbmatrix.core", raising=False)
|
||||
assert render_gate.swap_releases_gil() is None
|
||||
|
||||
@@ -875,9 +875,50 @@ class TestWriteStatusFile:
|
||||
|
||||
def test_write_failure_is_swallowed(self, monkeypatch):
|
||||
mgr = make_manager(role=SyncRole.STANDALONE)
|
||||
monkeypatch.setattr("builtins.open", MagicMock(side_effect=OSError("disk full")))
|
||||
monkeypatch.setattr(sync_manager.json, "dump",
|
||||
MagicMock(side_effect=OSError("disk full")))
|
||||
mgr.write_status_file() # must not raise
|
||||
assert mgr.logger.debug.called
|
||||
# ...and the half-written temp file is not left behind.
|
||||
assert list(Path(sync_manager.STATUS_FILE).parent.iterdir()) == []
|
||||
|
||||
def test_concurrent_writers_each_use_their_own_temp_file(self):
|
||||
"""The receive loop, watchdog and hello handler all write. With one
|
||||
fixed ".tmp" name, one writer's os.replace() could move the other's
|
||||
half-written file into place, or find it already gone."""
|
||||
mgr = make_manager(role=SyncRole.LEADER)
|
||||
errors = []
|
||||
mgr.logger.debug.side_effect = lambda *a: errors.append(a)
|
||||
|
||||
def hammer():
|
||||
for _ in range(50):
|
||||
mgr.write_status_file()
|
||||
threads = [threading.Thread(target=hammer) for _ in range(4)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert errors == []
|
||||
assert [p.name for p in Path(sync_manager.STATUS_FILE).parent.iterdir()] == [
|
||||
Path(sync_manager.STATUS_FILE).name]
|
||||
assert json.loads(Path(sync_manager.STATUS_FILE).read_text())["role"] == "leader"
|
||||
|
||||
def test_stop_withdraws_the_status_file(self):
|
||||
"""Left behind, the web UI kept reporting a connected peer after the
|
||||
display service had stopped."""
|
||||
mgr = make_manager(role=SyncRole.LEADER)
|
||||
mgr.write_status_file()
|
||||
assert Path(sync_manager.STATUS_FILE).exists()
|
||||
mgr.stop()
|
||||
assert not Path(sync_manager.STATUS_FILE).exists()
|
||||
mgr.write_status_file() # a late write from a thread still winding down
|
||||
assert not Path(sync_manager.STATUS_FILE).exists()
|
||||
|
||||
def test_standalone_removes_a_stale_status_file(self):
|
||||
Path(sync_manager.STATUS_FILE).write_text('{"role": "leader", "state": "connected"}')
|
||||
DisplaySyncManager("standalone", {}, {}, MagicMock())
|
||||
assert not Path(sync_manager.STATUS_FILE).exists()
|
||||
|
||||
def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
|
||||
"""GET /sync/status reads STATUS_FILE, which lives under
|
||||
|
||||
@@ -55,3 +55,81 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
|
||||
|
||||
static.get_vegas_display_mode.assert_not_called()
|
||||
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records)
|
||||
|
||||
|
||||
def _live_coordinator(live):
|
||||
"""A coordinator running the real run_frame(), with a switchable live check."""
|
||||
coord = _coordinator({})
|
||||
del coord.run_frame # the real one: it is what refuses frames while paused
|
||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}})
|
||||
coord.render_pipeline.has_deferred.return_value = False
|
||||
coord.render_pipeline.needs_extension.return_value = False
|
||||
coord.render_pipeline.render_frame.return_value = True
|
||||
coord._pending_config_update = False
|
||||
coord._last_live_check = float('-inf')
|
||||
coord._live_priority_check = lambda: live[0]
|
||||
return coord
|
||||
|
||||
|
||||
def test_vegas_resumes_after_a_live_priority_pause():
|
||||
"""The pause for live content was only ever lifted from inside run_frame(),
|
||||
which returns before reaching that check while paused -- so once live
|
||||
content had interrupted the ticker, it never came back until a restart.
|
||||
The display controller only calls run_iteration() when nothing preempts
|
||||
Vegas, so an iteration starting while paused for live content resumes."""
|
||||
live = ['nfl_live']
|
||||
coord = _live_coordinator(live)
|
||||
|
||||
assert coord.run_iteration() is False
|
||||
assert coord._is_paused and coord._live_priority_active
|
||||
coord.render_pipeline.render_frame.assert_not_called()
|
||||
|
||||
live[0] = None
|
||||
coord._last_live_check = float('-inf')
|
||||
assert coord.run_iteration() is True
|
||||
assert not coord._is_paused and not coord._live_priority_active
|
||||
coord.render_pipeline.render_frame.assert_called()
|
||||
|
||||
|
||||
def test_stop_clears_a_live_priority_pause():
|
||||
coord = _live_coordinator(['nfl_live'])
|
||||
coord._start_time = None
|
||||
coord._restore_switch_interval = lambda: None
|
||||
coord._remove_render_gate = lambda: None
|
||||
coord.run_iteration()
|
||||
assert coord._is_paused
|
||||
|
||||
coord.stop()
|
||||
assert not coord._is_paused and not coord._live_priority_active
|
||||
|
||||
|
||||
def test_iteration_length_ignores_wall_clock_steps(monkeypatch):
|
||||
"""No RTC: the wall clock jumps when NTP first syncs. A forward step used
|
||||
to end the iteration on its first frame."""
|
||||
from src.vegas_mode import coordinator as coordinator_module
|
||||
coord = _coordinator({})
|
||||
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||
'enabled': True, 'max_cycle_duration': 10}}})
|
||||
frames = [0]
|
||||
|
||||
def frame():
|
||||
frames[0] += 1
|
||||
return True
|
||||
coord.run_frame = frame
|
||||
clock = [1000.0]
|
||||
wall = [1000.0]
|
||||
|
||||
def monotonic():
|
||||
clock[0] += 0.5
|
||||
return clock[0]
|
||||
|
||||
def wall_time():
|
||||
wall[0] += 3600.0 # NTP stepping the wall clock forward
|
||||
return wall[0]
|
||||
monkeypatch.setattr(coordinator_module.time, 'monotonic', monotonic)
|
||||
monkeypatch.setattr(coordinator_module.time, 'time', wall_time)
|
||||
monkeypatch.setattr(coordinator_module.time, 'sleep', lambda s: None)
|
||||
|
||||
assert coord.run_iteration() is True
|
||||
assert frames[0] > 1
|
||||
|
||||
@@ -1385,6 +1385,41 @@ class TestContinuousExtension:
|
||||
widths.append(p.scroll_helper.total_scroll_width)
|
||||
assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}"
|
||||
|
||||
def test_reset_drops_the_previous_runs_prepared_content(self):
|
||||
# Vegas off then on: the group lined up for the old run, and any
|
||||
# canvas-bound plugins still queued, must not reach the new one.
|
||||
groups = [[('a', [self._block(600)])], [('old', [self._block(600)])]]
|
||||
p, _ = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
p.start_prefetch()
|
||||
if p._prefetch_thread:
|
||||
p._prefetch_thread.join(timeout=5)
|
||||
p._deferred_queue.append('old-canvas')
|
||||
assert p._prepared_group is not None
|
||||
|
||||
p.reset()
|
||||
assert p._prepared_group is None
|
||||
assert not p.has_deferred()
|
||||
|
||||
def test_a_prefetch_in_flight_across_reset_is_discarded(self):
|
||||
import threading
|
||||
release = threading.Event()
|
||||
groups = [[('a', [self._block(600)])], [('old', [self._block(600)])]]
|
||||
p, stream = self._pipeline(groups, continuous_scroll=True)
|
||||
p.compose_scroll_content()
|
||||
take = stream.take_next_group
|
||||
|
||||
def slow_take(count=None, offscreen_only=False):
|
||||
release.wait(timeout=5)
|
||||
return take(count, offscreen_only)
|
||||
stream.take_next_group = slow_take
|
||||
|
||||
p.start_prefetch()
|
||||
p.reset() # Vegas stops while the fetch is still running
|
||||
release.set()
|
||||
p._prefetch_thread.join(timeout=5)
|
||||
assert p._prepared_group is None
|
||||
|
||||
|
||||
class TestDeferredDraining:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user