Files
LEDMatrix/test/test_display_controller_vegas_tick.py
1c7a0cef66 fix(vegas): lock plugin_last_update snapshot/diff against concurrent mutation (#398)
* fix(vegas): restore live plugin-update refresh dropped by sync refactor

Investigating a user report that Vegas scroll mode doesn't update scores
or game status. Root cause: PR #299 (Mar 28) added a mechanism so a live
score change reached the ticker within a few seconds instead of waiting
for a full scroll cycle -- _tick_plugin_updates_for_vegas() diffed
plugin_last_update timestamps to detect which plugins got fresh data and
called coordinator.mark_plugin_updated() for each, and should_recompose()
checked has_pending_updates_for_visible_segments() to trigger an immediate
hot-swap.

PR #330 (May 14, multi-display wireless sync) refactored both call sites
while adding sync support and silently deleted this entire mechanism --
not just gated it behind the new sync-mode deferral it legitimately
needed, but removed it outright. The result: VegasModeCoordinator.
mark_plugin_updated() and StreamManager.has_pending_updates_for_visible_
segments() have been fully implemented but never called from anywhere
since. Vegas mode's only remaining freshness sources are a 5s content
cache TTL (fine) and full recompose at cycle boundaries, which depending
on min/max_cycle_duration can be minutes away -- so live scores/status
can sit stale far longer than a user would expect from a "live" ticker.

Fix:
- Restored _tick_plugin_updates_for_vegas() in display_controller.py,
  wired as the Vegas coordinator's update callback in place of the plain
  _tick_plugin_updates(). Diffs plugin_last_update before/after the tick
  and calls vegas_coordinator.mark_plugin_updated(plugin_id) for each
  plugin that actually got new data (rather than returning the list, since
  the callback interface no longer consumes a return value).
- Restored the has_pending_updates_for_visible_segments() check in
  render_pipeline.should_recompose(), positioned after (not instead of)
  the sync-mode early return PR #330 added, so standalone installations
  regain immediate refresh while synced leader/follower pairs correctly
  keep deferring hot-swaps to cycle boundaries as PR #330 intended.

Test plan:
- Added test_display_controller_vegas_tick.py and
  test_vegas_render_pipeline_recompose.py -- neither area had any prior
  test coverage, which is very likely why this regression went unnoticed
  for ~2.5 months.
- Verified both new test files fail against the pre-fix code (swapped in
  the current main versions of both files) with exactly the expected
  errors -- AttributeError for the deleted method, and the recompose
  assertion returning False instead of True -- then pass against the fix.
- Confirmed the sync-mode deferral this restoration must not break still
  holds: test_sync_active_defers_pending_updates_to_cycle_boundary.
- Full related suite (test_vegas_plugin_adapter, test_vegas_config,
  test_display_controller_plugin_toggle, test_display_controller_
  optimizations, test_plugin_system): 108 passed, 1 pre-existing failure
  unrelated to this change (test_circuit_breaker, stale mock signature).
- Full CI plugin-safety suite (test_harness, test_visual_rendering,
  test_plugin_matrix): 52 passed, 2 pre-existing skips.

* fix(vegas): lock plugin_last_update snapshot/diff against concurrent mutation

_tick_plugin_updates_for_vegas() snapshotted and later re-iterated
plugin_manager.plugin_last_update from the Vegas background update-tick
thread while the main render loop (or other callers) could mutate the
same dict concurrently — a real race (unprotected dict iteration/mutation
across threads), not just a style nit.

Move the snapshot/update/diff into a new locked
PluginManager.run_scheduled_updates_with_changes() so all reads and
mutations of plugin_last_update happen under one lock, and update
DisplayController to use it. The lock is only held around the dict
accesses, not the update pass itself, so slow plugin update() calls don't
serialize against other callers.

Also add a regression test covering that the Vegas coordinator is wired
to the Vegas-aware tick callback rather than the plain one.

Skipped as not worth the change:
- Narrowing the broad `except Exception` around
  vc.mark_plugin_updated(plugin_id) to specific types: it's a deliberate
  per-plugin isolation boundary (matches the same pattern used elsewhere
  in this file for plugin/coordinator calls) and there's no documented,
  stable set of exceptions that call can raise to narrow to.
- Adding an inactive-DisplaySyncManager test to
  test_vegas_render_pipeline_recompose.py: verified
  VegasModeCoordinator.set_sync_manager() already normalizes a
  SyncRole.STANDALONE manager to None before handing it to the render
  pipeline (src/vegas_mode/coordinator.py:152-156), so should_recompose()'s
  `is not None` check is correct in practice; the suggested case is
  already covered by that normalization.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 10:52:18 -04:00

89 lines
3.6 KiB
Python

"""
Regression tests for DisplayController._tick_plugin_updates_for_vegas().
PR #299 added logic to detect which plugins actually got fresh data on a
scheduled-update tick and notify Vegas mode via
vegas_coordinator.mark_plugin_updated(), so a live score change reaches the
scroll within seconds instead of waiting for a full cycle. PR #330's
multi-display sync refactor deleted this method (folding the callback back
to the plain _tick_plugin_updates(), which reports nothing), silently
orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero
callers since.
"""
from typing import Dict, List, Optional
from unittest.mock import MagicMock
from src.display_controller import DisplayController
def _make_controller(updated: Optional[List[str]] = None, vegas_coordinator: Optional[MagicMock] = None) -> DisplayController:
dc = object.__new__(DisplayController)
dc.plugin_manager = MagicMock()
dc.plugin_manager.run_scheduled_updates_with_changes.return_value = list(updated or [])
dc.vegas_coordinator = vegas_coordinator
return dc
class TestTickPluginUpdatesForVegas:
def test_marks_only_plugins_whose_timestamp_advanced(self):
vc = MagicMock()
dc = _make_controller(updated=["stock-news"], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas()
vc.mark_plugin_updated.assert_called_once_with("stock-news")
def test_no_advance_marks_nothing(self):
vc = MagicMock()
dc = _make_controller(updated=[], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas()
vc.mark_plugin_updated.assert_not_called()
def test_no_vegas_coordinator_does_not_raise(self):
dc = _make_controller(updated=["stock-news"], vegas_coordinator=None)
dc._tick_plugin_updates_for_vegas() # must not raise
def test_mark_plugin_updated_exception_does_not_propagate(self):
"""One plugin's mark_plugin_updated failing must not stop the tick
or crash the update loop it runs in."""
vc = MagicMock()
vc.mark_plugin_updated.side_effect = [RuntimeError("boom"), None]
dc = _make_controller(updated=["a", "b"], vegas_coordinator=vc)
dc._tick_plugin_updates_for_vegas() # must not raise
assert vc.mark_plugin_updated.call_count == 2
class TestVegasCoordinatorCallbackWiring:
def test_initialize_wires_vegas_aware_tick_as_update_callback(self):
"""The Vegas coordinator must be given the Vegas-aware
_tick_plugin_updates_for_vegas as its update callback, not the plain
_tick_plugin_updates() -- that's the exact wiring PR #330 dropped."""
dc = object.__new__(DisplayController)
dc.config = {"display": {"vegas_scroll": {"enabled": True}}, "sync": {}}
dc.display_manager = MagicMock()
dc.plugin_manager = MagicMock()
dc.sync_manager = MagicMock()
dc._check_live_priority = MagicMock()
dc._check_vegas_interrupt = MagicMock(return_value=False)
fake_coordinator = MagicMock()
import src.display_controller as dc_module
original_imported = dc_module._vegas_mode_imported
original_class = dc_module.VegasModeCoordinator
try:
dc_module._vegas_mode_imported = True
dc_module.VegasModeCoordinator = MagicMock(return_value=fake_coordinator)
dc._initialize_vegas_mode()
finally:
dc_module._vegas_mode_imported = original_imported
dc_module.VegasModeCoordinator = original_class
fake_coordinator.set_update_callback.assert_called_once_with(dc._tick_plugin_updates_for_vegas)