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:
Chuck
2026-09-28 08:25:26 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bcef1957a9
commit 6bc13a8934
14 changed files with 401 additions and 32 deletions
+9
View File
@@ -37,6 +37,15 @@ accepts both, but the store flags the old spelling as deprecated
- `check_system_compatibility.sh` no longer reports installed packages as missing. - `check_system_compatibility.sh` no longer reports installed packages as missing.
- A network failure fetching GitHub repo info logs a warning, not an error. - A network failure fetching GitHub repo info logs a warning, not an error.
- Display runtime:
- Vegas comes back after live content interrupts it. It stayed paused, and the display fell back to normal rotation until a restart.
- A day with dimming turned off in a per-day dim schedule stays at normal brightness. Before, brightness went back to dim for most of each minute.
- Stopping on-demand after a second request resumes rotation where it was first interrupted, not at the first request's screen.
- Turning Vegas off and on no longer shows content prepared for the previous run, including plugins disabled in between.
- How long a Vegas iteration runs is timed with the monotonic clock, so an NTP clock step on a Pi without an RTC doesn't cut it short or stretch it.
- The sync status file is removed when the display service stops, and at startup in standalone mode, so the web UI no longer reports a peer from an earlier run. Concurrent writes each use their own temp file.
- `render_gate.swap_releases_gil()` delegates to `frame_timing.binding_releases_gil()` instead of duplicating it.
- Scripts and installer: - Scripts and installer:
- `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640. - `fix_web_permissions.sh` makes `safe_plugin_rm.sh` and `safe_pip_install.sh` root-owned again after resetting ownership. A web-user-owned copy of either is a root shell, since sudo lets the web user run them as root. It also restores `config_secrets.json` to mode 640.
- `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs. - `configure_wifi_permissions.sh` checks its rules with `visudo -c` before installing them, and grants the NetworkManager captive-portal `cp` and `rm` commands `wifi_manager` runs.
+6 -12
View File
@@ -48,6 +48,8 @@ import time
from collections import deque from collections import deque
from typing import Any, Callable, Deque, List, Optional from typing import Any, Callable, Deque, List, Optional
from src.common.frame_timing import binding_releases_gil
#: Park background threads this long before the refresh a swap will return on, #: Park background threads this long before the refresh a swap will return on,
#: so a short C call already under way has finished by then. #: so a short C call already under way has finished by then.
MARGIN_SECONDS = 0.002 MARGIN_SECONDS = 0.002
@@ -90,19 +92,11 @@ def _unsafe(frame: Any, base: Any) -> bool:
def swap_releases_gil() -> Optional[bool]: def swap_releases_gil() -> Optional[bool]:
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded. """Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
The rebuilt binding links PyEval_SaveThread and the stock one never does. A thin delegate to src.common.frame_timing.binding_releases_gil (#629),
The same test as src.common.frame_timing.binding_releases_gil (#629); one which this used to duplicate line for line. The name stays because the
of the two goes once both have landed. coordinator calls it here and tests replace it here.
""" """
module = sys.modules.get("rgbmatrix.core") return binding_releases_gil()
path = getattr(module, "__file__", None)
if not path:
return None
try:
with open(path, "rb") as handle:
return b"PyEval_SaveThread" in handle.read()
except OSError:
return None
def _held(lock: Any) -> bool: def _held(lock: Any) -> bool:
+54 -3
View File
@@ -53,6 +53,17 @@ HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json") STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
# Serialises writes to STATUS_FILE (several threads report status) against
# its removal in stop(), so a write already under way cannot put the file back
# after the display process has shut down.
_STATUS_LOCK = threading.Lock()
def _remove_status_file() -> None:
try:
os.remove(STATUS_FILE)
except FileNotFoundError:
pass
class SyncRole(Enum): class SyncRole(Enum):
@@ -83,6 +94,10 @@ class DisplaySyncManager:
back to its own plugins when the leader stops sending. back to its own plugins when the leader stops sending.
""" """
# Set by stop(); status writes after that are dropped. Class-level so
# instances built without __init__ (tests) have it too.
_status_closed = False
def __init__( def __init__(
self, self,
role_str: str, role_str: str,
@@ -138,6 +153,14 @@ class DisplaySyncManager:
self._send_sock: Optional[socket.socket] = None self._send_sock: Optional[socket.socket] = None
if self.role == SyncRole.STANDALONE: if self.role == SyncRole.STANDALONE:
# Standalone never writes a status file, so one still here is
# from an earlier run as leader or follower. The web UI would
# keep reporting that run's peer as if it were live.
try:
with _STATUS_LOCK:
_remove_status_file()
except OSError as exc:
logger.debug("Sync: could not remove stale status file: %s", exc)
return return
if self.role == SyncRole.LEADER: if self.role == SyncRole.LEADER:
@@ -692,18 +715,38 @@ class DisplaySyncManager:
def write_status_file(self) -> None: def write_status_file(self) -> None:
"""Write current sync status to STATUS_FILE for the web UI to read.""" """Write current sync status to STATUS_FILE for the web UI to read."""
tmp = None
try: try:
status = self.get_status() status = self.get_status()
status["ts"] = time.time() status["ts"] = time.time()
tmp = STATUS_FILE + ".tmp" with _STATUS_LOCK:
with open(tmp, "w") as f: if self._status_closed:
return
# A unique temp name per write, like frame_timing's stats
# file: the receive loop, watchdog and hello handler all
# write, and with one fixed ".tmp" name one thread's
# os.replace() could move the other's half-written file.
fd, tmp = tempfile.mkstemp(
dir=os.path.dirname(STATUS_FILE) or ".",
prefix=".led_matrix_sync_status.", suffix=".tmp")
with os.fdopen(fd, "w") as f:
json.dump(status, f) json.dump(status, f)
# mkstemp makes it owner-only; the web UI may run as a
# different user from the display service.
os.chmod(tmp, 0o644)
os.replace(tmp, STATUS_FILE) os.replace(tmp, STATUS_FILE)
tmp = None
except Exception as exc: except Exception as exc:
self.logger.debug("Sync: status file write error: %s", exc) self.logger.debug("Sync: status file write error: %s", exc)
finally:
if tmp is not None:
try:
os.unlink(tmp)
except OSError:
pass
def stop(self) -> None: def stop(self) -> None:
"""Shut down threads and close sockets.""" """Shut down threads, close sockets and withdraw the status file."""
self._running = False self._running = False
for sock in (self._recv_sock, self._send_sock, self._img_server_sock): for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
if sock: if sock:
@@ -711,3 +754,11 @@ class DisplaySyncManager:
sock.close() sock.close()
except Exception as exc: except Exception as exc:
self.logger.debug("Sync: error closing socket: %s", exc) self.logger.debug("Sync: error closing socket: %s", exc)
# The web UI reads this file as live status. Left behind, it went on
# reporting a connected peer after the display service had stopped.
try:
with _STATUS_LOCK:
self._status_closed = True
_remove_status_file()
except OSError as exc:
self.logger.debug("Sync: could not remove status file: %s", exc)
+25 -1
View File
@@ -284,7 +284,10 @@ class DisplayController:
if os.path.isabs(plugins_dir_name): if os.path.isabs(plugins_dir_name):
plugins_dir = plugins_dir_name plugins_dir = plugins_dir_name
else: else:
# If relative, resolve relative to the project root (LEDMatrix directory) # If relative, resolve against the current working directory.
# That is the project root only because ledmatrix.service
# sets WorkingDirectory to it; run from anywhere else, a
# relative path resolves against wherever that is.
project_root = os.getcwd() project_root = os.getcwd()
plugins_dir = os.path.join(project_root, plugins_dir_name) plugins_dir = os.path.join(project_root, plugins_dir_name)
@@ -801,7 +804,15 @@ class DisplayController:
if use_per_day: if use_per_day:
day_config = days_config[current_day] day_config = days_config[current_day]
if not day_config.get('enabled', True): if not day_config.get('enabled', True):
# Past the minute gate, so the cache must say the same thing:
# returning here without it left the previous minute's dim
# value to be served for the rest of this one, and the
# brightness flipped between dim and normal every minute.
if self._was_dimmed:
logger.info(f"Dim schedule deactivated: brightness restored to {normal_brightness}%")
self.is_dimmed = False self.is_dimmed = False
self._was_dimmed = False
self._cached_target_brightness = normal_brightness # persist for minute-gate
return normal_brightness return normal_brightness
start_time_str = day_config.get('start_time', '20:00') start_time_str = day_config.get('start_time', '20:00')
end_time_str = day_config.get('end_time', '07:00') end_time_str = day_config.get('end_time', '07:00')
@@ -1710,6 +1721,12 @@ class DisplayController:
pinned = bool(request.get('pinned', False)) pinned = bool(request.get('pinned', False))
now = time.time() now = time.time()
# Only a request that starts a session records where rotation was.
# A request made while on-demand is already showing would otherwise
# save the previous request's mode (current_mode_index points at it
# by now), and clearing would resume there instead of where the
# normal rotation was interrupted.
if not self.on_demand_active:
if self.available_modes: if self.available_modes:
self.rotation_resume_index = self.current_mode_index self.rotation_resume_index = self.current_mode_index
else: else:
@@ -3310,6 +3327,13 @@ class DisplayController:
self.vegas_coordinator.cleanup() self.vegas_coordinator.cleanup()
except Exception as e: except Exception as e:
logger.warning("Error cleaning up Vegas mode: %s", e) logger.warning("Error cleaning up Vegas mode: %s", e)
# After Vegas, which sends through it. Stopping also withdraws the
# sync status file, which the web UI otherwise kept showing as live.
if getattr(self, 'sync_manager', None) is not None:
try:
self.sync_manager.stop()
except Exception as e:
logger.warning("Error stopping display sync: %s", e)
# Shutdown config service if it exists # Shutdown config service if it exists
if hasattr(self, 'config_service'): if hasattr(self, 'config_service'):
try: try:
+4 -1
View File
@@ -20,7 +20,10 @@ Key responsibilities
Singleton: only one ``DisplayManager`` instance exists per process. The Singleton: only one ``DisplayManager`` instance exists per process. The
first call to ``DisplayManager(config)`` creates it; subsequent calls return first call to ``DisplayManager(config)`` creates it; subsequent calls return
the same object. the same object, but ``__init__`` runs again on it each time, so it is
re-initialised (matrix included) with the new arguments rather than handed
back as it was. Construct it once and pass that instance around;
:meth:`DisplayManager.cleanup` clears the singleton.
""" """
import json import json
+29 -7
View File
@@ -273,6 +273,10 @@ class VegasModeCoordinator:
self._is_active = True self._is_active = True
self._should_stop = False self._should_stop = False
# A pause belongs to the run it happened in; carrying it into a
# new run would have run_frame() refuse every frame.
self._is_paused = False
self._live_priority_active = False
self._start_time = time.time() self._start_time = time.time()
# A fresh run starts with a clean health slate: no stale # A fresh run starts with a clean health slate: no stale
# "was degraded" from the previous run, and a heartbeat that is # "was degraded" from the previous run, and a heartbeat that is
@@ -298,6 +302,8 @@ class VegasModeCoordinator:
self._should_stop = True self._should_stop = True
self._is_active = False self._is_active = False
self._is_paused = False
self._live_priority_active = False
if self._start_time: if self._start_time:
self.stats['total_runtime_seconds'] += time.time() - self._start_time self.stats['total_runtime_seconds'] += time.time() - self._start_time
@@ -473,6 +479,20 @@ class VegasModeCoordinator:
if not self.start(): if not self.start():
return False return False
# A live-priority pause is only ever lifted by _check_live_priority(),
# and run_frame() returns before reaching it while paused -- so once
# paused, every later iteration returned False at its first frame and
# the ticker never came back until a restart. The display controller
# only calls run_iteration() when nothing preempts Vegas (no live mode,
# or live content is kept in the ticker), so being called at all means
# the live content that paused us has ended.
with self._state_lock:
paused_for_live = self._is_paused and self._live_priority_active
if paused_for_live:
self._live_priority_active = False
self.resume()
logger.info("Live priority ended - resuming Vegas")
if self.vegas_config.continuous_scroll: if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says # The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control # nothing about how long to run. This is only how often control
@@ -481,7 +501,11 @@ class VegasModeCoordinator:
duration = float(self.vegas_config.max_cycle_duration) duration = float(self.vegas_config.max_cycle_duration)
else: else:
duration = self.render_pipeline.get_dynamic_duration() duration = self.render_pipeline.get_dynamic_duration()
start_time = time.time() # Monotonic for the same reason as the per-frame clock below: this
# bounds how long the iteration runs, and an NTP step on an RTC-less
# Pi would otherwise end it at once (forward) or stretch it by the
# size of the correction (backward).
start_time = time.monotonic()
frame_count = 0 frame_count = 0
fps_log_interval = 5.0 # Sample FPS every 5 seconds fps_log_interval = 5.0 # Sample FPS every 5 seconds
# Health state lives on the coordinator, not here: run_iteration() is # Health state lives on the coordinator, not here: run_iteration() is
@@ -490,10 +514,8 @@ class VegasModeCoordinator:
# of every iteration rather than once per interval, and a recovery # of every iteration rather than once per interval, and a recovery
# that crossed an iteration boundary was never reported at all -- # that crossed an iteration boundary was never reported at all --
# was_degraded had already gone back to False. # was_degraded had already gone back to False.
# Monotonic, and deliberately not start_time: start_time is wall # Monotonic. Never mix it with a wall-clock value: every delta would
# clock and is used below to report the iteration's duration. Mixing # be hugely negative and silence the frame-rate reporting altogether.
# the two here would make every delta hugely negative and silence the
# frame-rate reporting altogether.
last_fps_log_time = time.monotonic() last_fps_log_time = time.monotonic()
fps_frame_count = 0 fps_frame_count = 0
# A mean hides stutter completely. At 120fps a five-second window is # A mean hides stutter completely. At 120fps a five-second window is
@@ -633,7 +655,7 @@ class VegasModeCoordinator:
).start() ).start()
# Check elapsed time # Check elapsed time
elapsed = time.time() - start_time elapsed = time.monotonic() - start_time
if elapsed >= duration: if elapsed >= duration:
break break
@@ -652,7 +674,7 @@ class VegasModeCoordinator:
# cycle content multiple times within one iteration — acceptable for # cycle content multiple times within one iteration — acceptable for
# a continuous ticker. # a continuous ticker.
logger.info("Vegas iteration completed after %.1fs", time.time() - start_time) logger.info("Vegas iteration completed after %.1fs", time.monotonic() - start_time)
return True return True
def _check_live_priority(self) -> bool: def _check_live_priority(self) -> bool:
+20 -1
View File
@@ -52,6 +52,12 @@ class RenderPipeline:
# A panel measured within this fraction of its cap is keeping up with it. # A panel measured within this fraction of its cap is keeping up with it.
REFRESH_TOLERANCE = 0.03 REFRESH_TOLERANCE = 0.03
# Bumped by reset(). A prefetch thread records the value it started under
# and drops its group if a reset happened meanwhile, so a fetch still in
# flight when Vegas stops cannot land in the next run. Class-level so
# pipelines built without __init__ (tests) still have it.
_prefetch_generation = 0
def __init__( def __init__(
self, self,
config: VegasModeConfig, config: VegasModeConfig,
@@ -373,6 +379,7 @@ class RenderPipeline:
return return
if self._prepared_group is not None: if self._prepared_group is not None:
return # already have one waiting return # already have one waiting
generation = self._prefetch_generation
def _work(): def _work():
# Deprioritise against the render loop. Linux applies nice # Deprioritise against the render loop. Linux applies nice
@@ -394,6 +401,8 @@ class RenderPipeline:
logger.exception("Background prefetch failed") logger.exception("Background prefetch failed")
group = [] group = []
with self._prefetch_lock: with self._prefetch_lock:
if generation != self._prefetch_generation:
return # Vegas was reset while this was fetching
self._prepared_group = group self._prepared_group = group
self._prefetch_thread = threading.Thread( self._prefetch_thread = threading.Thread(
@@ -602,7 +611,8 @@ class RenderPipeline:
""" """
Render a single frame to the display. Render a single frame to the display.
Should be called at ~125 FPS (8ms intervals). Called once per frame by the coordinator, which paces the calls by
frame_interval (see that property) rather than a fixed rate.
Returns: Returns:
True if frame was rendered, False if no content True if frame was rendered, False if no content
@@ -917,6 +927,15 @@ class RenderPipeline:
self._segments_in_scroll = [] self._segments_in_scroll = []
self._frame_times = deque(maxlen=100) self._frame_times = deque(maxlen=100)
# Content lined up for the old run belongs to it. Left in place, the
# first extension after Vegas is switched back on appended that stale
# group -- including plugins disabled in the meantime -- and the
# deferred queue went on fetching the old run's plugins.
with self._prefetch_lock:
self._prefetch_generation += 1
self._prepared_group = None
self._deferred_queue = []
self.display_manager.set_scrolling_state(False) self.display_manager.set_scrolling_state(False)
logger.info("RenderPipeline reset") logger.info("RenderPipeline reset")
+25
View File
@@ -49,6 +49,31 @@ def test_cleanup_without_vegas_does_not_raise():
dc.display_manager.cleanup.assert_called_once_with() 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(): def test_a_failing_vegas_teardown_does_not_stop_the_rest():
vegas = MagicMock() vegas = MagicMock()
vegas.cleanup.side_effect = RuntimeError('boom') vegas.cleanup.side_effect = RuntimeError('boom')
+21
View File
@@ -254,6 +254,27 @@ class TestDimSchedule:
assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled assert dim_at(dc, "23:00", day="tuesday") == 90 # day disabled
assert dc.is_dimmed is False 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): def test_no_legacy_inference_for_dim(self):
# Unlike _check_schedule, dim mode defaults to GLOBAL even when a # Unlike _check_schedule, dim mode defaults to GLOBAL even when a
# days config exists — no legacy inference. # days config exists — no legacy inference.
@@ -99,6 +99,31 @@ class TestPinSurvivesARestart:
assert set(c.on_demand_modes) == {'app_a', 'app_b', 'app_c'} 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: class TestRestartDoesNotStarveTheOtherPlugins:
"""Restarting mid-on-demand used to load only the on-demand plugin. """Restarting mid-on-demand used to load only the on-demand plugin.
+22
View File
@@ -430,3 +430,25 @@ class TestVegasWiring:
pipeline._prefetch_thread.join(5) pipeline._prefetch_thread.join(5)
assert seen == [(True, True)] assert seen == [(True, True)]
assert pipeline._prepared_group == ["segment"] 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
+42 -1
View File
@@ -875,9 +875,50 @@ class TestWriteStatusFile:
def test_write_failure_is_swallowed(self, monkeypatch): def test_write_failure_is_swallowed(self, monkeypatch):
mgr = make_manager(role=SyncRole.STANDALONE) 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 mgr.write_status_file() # must not raise
assert mgr.logger.debug.called 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): def test_web_status_endpoint_reads_the_file_that_was_written(self, api_v3_client):
"""GET /sync/status reads STATUS_FILE, which lives under """GET /sync/status reads STATUS_FILE, which lives under
+78
View File
@@ -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() static.get_vegas_display_mode.assert_not_called()
assert not any('Static mode plugins' in r.getMessage() for r in caplog.records) 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
+35
View File
@@ -1385,6 +1385,41 @@ class TestContinuousExtension:
widths.append(p.scroll_helper.total_scroll_width) widths.append(p.scroll_helper.total_scroll_width)
assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}" 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: class TestDeferredDraining:
""" """