diff --git a/CHANGELOG.md b/CHANGELOG.md index b9efc067..6efc241c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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: - `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. diff --git a/src/common/render_gate.py b/src/common/render_gate.py index 3c827718..5b7d5997 100644 --- a/src/common/render_gate.py +++ b/src/common/render_gate.py @@ -48,6 +48,8 @@ import time from collections import deque 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, #: so a short C call already under way has finished by then. MARGIN_SECONDS = 0.002 @@ -90,19 +92,11 @@ def _unsafe(frame: Any, base: Any) -> bool: def swap_releases_gil() -> Optional[bool]: """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. - The same test as src.common.frame_timing.binding_releases_gil (#629); one - of the two goes once both have landed. + A thin delegate to src.common.frame_timing.binding_releases_gil (#629), + which this used to duplicate line for line. The name stays because the + coordinator calls it here and tests replace it here. """ - module = sys.modules.get("rgbmatrix.core") - 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 + return binding_releases_gil() def _held(lock: Any) -> bool: diff --git a/src/common/sync_manager.py b/src/common/sync_manager.py index bec3d7f0..3bdbaf05 100644 --- a/src/common/sync_manager.py +++ b/src/common/sync_manager.py @@ -53,6 +53,17 @@ HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone 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): @@ -83,6 +94,10 @@ class DisplaySyncManager: 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__( self, role_str: str, @@ -138,6 +153,14 @@ class DisplaySyncManager: self._send_sock: Optional[socket.socket] = None 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 if self.role == SyncRole.LEADER: @@ -692,18 +715,38 @@ class DisplaySyncManager: def write_status_file(self) -> None: """Write current sync status to STATUS_FILE for the web UI to read.""" + tmp = None try: status = self.get_status() status["ts"] = time.time() - tmp = STATUS_FILE + ".tmp" - with open(tmp, "w") as f: - json.dump(status, f) - os.replace(tmp, STATUS_FILE) + with _STATUS_LOCK: + 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) + # 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) + tmp = None except Exception as 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: - """Shut down threads and close sockets.""" + """Shut down threads, close sockets and withdraw the status file.""" self._running = False for sock in (self._recv_sock, self._send_sock, self._img_server_sock): if sock: @@ -711,3 +754,11 @@ class DisplaySyncManager: sock.close() except Exception as 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) diff --git a/src/display_controller.py b/src/display_controller.py index 847332be..bcb4634a 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -284,7 +284,10 @@ class DisplayController: if os.path.isabs(plugins_dir_name): plugins_dir = plugins_dir_name 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() plugins_dir = os.path.join(project_root, plugins_dir_name) @@ -801,7 +804,15 @@ class DisplayController: if use_per_day: day_config = days_config[current_day] 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._was_dimmed = False + self._cached_target_brightness = normal_brightness # persist for minute-gate return normal_brightness start_time_str = day_config.get('start_time', '20:00') end_time_str = day_config.get('end_time', '07:00') @@ -1710,10 +1721,16 @@ class DisplayController: pinned = bool(request.get('pinned', False)) now = time.time() - if self.available_modes: - self.rotation_resume_index = self.current_mode_index - else: - self.rotation_resume_index = None + # 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: + self.rotation_resume_index = self.current_mode_index + else: + self.rotation_resume_index = None if resolved_mode in self.available_modes: self.current_mode_index = self.available_modes.index(resolved_mode) @@ -3310,6 +3327,13 @@ class DisplayController: self.vegas_coordinator.cleanup() except Exception as 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 if hasattr(self, 'config_service'): try: diff --git a/src/display_manager.py b/src/display_manager.py index eb58d206..873a9859 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -20,7 +20,10 @@ Key responsibilities Singleton: only one ``DisplayManager`` instance exists per process. The 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 diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 3de38bc8..f6df144a 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -273,6 +273,10 @@ class VegasModeCoordinator: self._is_active = True 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() # A fresh run starts with a clean health slate: no stale # "was degraded" from the previous run, and a heartbeat that is @@ -298,6 +302,8 @@ class VegasModeCoordinator: self._should_stop = True self._is_active = False + self._is_paused = False + self._live_priority_active = False if self._start_time: self.stats['total_runtime_seconds'] += time.time() - self._start_time @@ -473,6 +479,20 @@ class VegasModeCoordinator: if not self.start(): 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: # The strip is continuously extended and trimmed, so its width says # 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) else: 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 fps_log_interval = 5.0 # Sample FPS every 5 seconds # 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 # that crossed an iteration boundary was never reported at all -- # was_degraded had already gone back to False. - # Monotonic, and deliberately not start_time: start_time is wall - # clock and is used below to report the iteration's duration. Mixing - # the two here would make every delta hugely negative and silence the - # frame-rate reporting altogether. + # Monotonic. Never mix it with a wall-clock value: every delta would + # be hugely negative and silence the frame-rate reporting altogether. last_fps_log_time = time.monotonic() fps_frame_count = 0 # A mean hides stutter completely. At 120fps a five-second window is @@ -633,7 +655,7 @@ class VegasModeCoordinator: ).start() # Check elapsed time - elapsed = time.time() - start_time + elapsed = time.monotonic() - start_time if elapsed >= duration: break @@ -652,7 +674,7 @@ class VegasModeCoordinator: # cycle content multiple times within one iteration — acceptable for # 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 def _check_live_priority(self) -> bool: diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 2c1ebe00..72300af3 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -52,6 +52,12 @@ class RenderPipeline: # A panel measured within this fraction of its cap is keeping up with it. 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__( self, config: VegasModeConfig, @@ -373,6 +379,7 @@ class RenderPipeline: return if self._prepared_group is not None: return # already have one waiting + generation = self._prefetch_generation def _work(): # Deprioritise against the render loop. Linux applies nice @@ -394,6 +401,8 @@ class RenderPipeline: logger.exception("Background prefetch failed") group = [] with self._prefetch_lock: + if generation != self._prefetch_generation: + return # Vegas was reset while this was fetching self._prepared_group = group self._prefetch_thread = threading.Thread( @@ -602,7 +611,8 @@ class RenderPipeline: """ 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: True if frame was rendered, False if no content @@ -917,6 +927,15 @@ class RenderPipeline: self._segments_in_scroll = [] 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) logger.info("RenderPipeline reset") diff --git a/test/test_display_controller_cleanup.py b/test/test_display_controller_cleanup.py index 5511e0d8..16ab489a 100644 --- a/test/test_display_controller_cleanup.py +++ b/test/test_display_controller_cleanup.py @@ -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') diff --git a/test/test_display_controller_schedule.py b/test/test_display_controller_schedule.py index a7237d4b..22a72c63 100644 --- a/test/test_display_controller_schedule.py +++ b/test/test_display_controller_schedule.py @@ -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. diff --git a/test/test_on_demand_pinning_and_restart.py b/test/test_on_demand_pinning_and_restart.py index 7d32a320..eb33f434 100644 --- a/test/test_on_demand_pinning_and_restart.py +++ b/test/test_on_demand_pinning_and_restart.py @@ -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. diff --git a/test/test_render_gate.py b/test/test_render_gate.py index bf531520..58f0b5e1 100644 --- a/test/test_render_gate.py +++ b/test/test_render_gate.py @@ -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 diff --git a/test/test_sync_manager.py b/test/test_sync_manager.py index da9b52d2..f9c692e2 100644 --- a/test/test_sync_manager.py +++ b/test/test_sync_manager.py @@ -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 diff --git a/test/test_vegas_coordinator_iteration.py b/test/test_vegas_coordinator_iteration.py index 954c33b7..f56d227b 100644 --- a/test/test_vegas_coordinator_iteration.py +++ b/test/test_vegas_coordinator_iteration.py @@ -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 diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 34efcfb9..2118cfd5 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -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: """