Skip to content

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

Merged
ChuckBuilds merged 3 commits into
mainfrom
claude/pr-395-plugin-locking-3ss48c
Jul 12, 2026
Merged

fix(vegas): lock plugin_last_update snapshot/diff against concurrent mutation#398
ChuckBuilds merged 3 commits into
mainfrom
claude/pr-395-plugin-locking-3ss48c

Conversation

@ChuckBuilds

@ChuckBuilds ChuckBuilds commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up to review feedback on #395 (fix/vegas-live-refresh). This branch is based on that PR's tip and addresses the actionable and nitpick comments left on it.

  • _tick_plugin_updates_for_vegas() snapshotted and later re-iterated plugin_manager.plugin_last_update from Vegas mode's background update-tick thread (vegas-plugin-tick) while the main render loop / other callers can mutate the same dict concurrently. That's a real unprotected-iteration race (RuntimeError: dictionary changed size during iteration or lost updates), not just style.
  • Added PluginManager.run_scheduled_updates_with_changes(): snapshots plugin_last_update, runs the scheduled update pass, and diffs it — with the before/after dict accesses each individually lock-protected via a new _plugin_last_update_lock. The lock is only held around the dict snapshot/diff, not across the update pass itself, so slow/blocking plugin update() calls don't serialize against other plugin_last_update readers.
  • All other plugin_last_update mutation sites in PluginManager (load/unload, run_scheduled_updates, update_all_plugins, failure recording) now go through the same lock.
  • DisplayController._tick_plugin_updates_for_vegas() now delegates to the new helper instead of doing its own snapshot/diff, and got the missing -> None return annotation.
  • Added a regression test asserting the Vegas coordinator is wired to _tick_plugin_updates_for_vegas (the Vegas-aware callback) rather than the plain _tick_plugin_updates — this is the exact wiring the original regression (feat(sync): multi-display wireless sync — extend scrolling across two LED matrices #330) silently dropped.
  • Updated the existing test_display_controller_vegas_tick.py tests to match the new run_scheduled_updates_with_changes()-based flow, with type annotations on the test helper.

Skipped (with reason)

  • Narrowing the broad except Exception around vc.mark_plugin_updated(plugin_id) to specific exception types: this is a deliberate per-plugin isolation boundary (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 safely.
  • Adding an inactive-DisplaySyncManager case 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, so a dedicated test adds little value.

Test plan

  • EMULATOR=true pytest test/test_display_controller_vegas_tick.py test/test_vegas_render_pipeline_recompose.py -v --no-cov: 11 passed.
  • EMULATOR=true pytest test/test_vegas_plugin_adapter.py test/test_vegas_config.py test/test_display_controller_plugin_toggle.py test/test_display_controller_optimizations.py test/test_plugin_system.py --no-cov: 108 passed, 1 pre-existing unrelated failure (test_circuit_breaker, stale mock signature — already documented in prior PRs).
  • EMULATOR=true pytest test/plugins/test_harness.py test/plugins/test_visual_rendering.py test/plugins/test_plugin_matrix.py --no-cov: 56 passed.

Generated by Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability when scheduled plugins update concurrently.
    • Vegas mode now accurately identifies plugins with fresh data and refreshes their status.
    • Prevented update-tracking issues from causing incorrect notifications or disrupting the display.
    • Improved handling when the Vegas coordinator is unavailable.
    • Added safeguards so errors while notifying Vegas do not interrupt plugin update processing.
  • Tests

    • Expanded coverage for plugin update detection, Vegas callback wiring, and notification behavior.

ChuckBuilds and others added 2 commits July 11, 2026 16:42
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.
…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.
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

PluginManager now synchronizes plugin_last_update access and reports plugins updated during scheduled runs. DisplayController uses this result for Vegas notifications, with fallback handling and expanded callback wiring tests.

Changes

Vegas plugin update tracking

Layer / File(s) Summary
Synchronized plugin update contract
src/plugin_system/plugin_manager.py
Adds locked access to plugin_last_update and introduces run_scheduled_updates_with_changes() to return plugin IDs whose timestamps advanced.
Vegas-aware update ticking
src/display_controller.py
Uses the new PluginManager method when available, falls back to the existing update path, handles a missing coordinator, and notifies Vegas for changed plugins.
Vegas update behavior tests
test/test_display_controller_vegas_tick.py
Tests changed-plugin notifications, empty results, missing coordinators, callback failures, and Vegas callback wiring.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant VegasModeCoordinator
  participant DisplayController
  participant PluginManager
  VegasModeCoordinator->>DisplayController: trigger update callback
  DisplayController->>PluginManager: run_scheduled_updates_with_changes()
  PluginManager-->>DisplayController: updated plugin IDs
  DisplayController->>VegasModeCoordinator: mark_plugin_updated(plugin ID)
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: locking plugin_last_update snapshot and diff logic against concurrent mutation in Vegas.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pr-395-plugin-locking-3ss48c

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codacy-production

codacy-production Bot commented Jul 12, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

# Conflicts:
#	src/display_controller.py
#	test/test_display_controller_vegas_tick.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/plugin_system/plugin_manager.py (1)

715-787: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make the update reservation atomic in run_scheduled_updates() can_execute() and set_state(..., RUNNING) are separate calls, so the background tick and render loop can both see ENABLED and enter update() for the same plugin at once. Reserve the plugin under one per-plugin lock before execute_update(), and roll back on failure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/plugin_system/plugin_manager.py` around lines 715 - 787, Make the
eligibility check and RUNNING transition in run_scheduled_updates atomic by
acquiring the existing per-plugin lock before can_execute(), rechecking
eligibility while holding it, and setting PluginState.RUNNING before releasing
or executing. Ensure competing scheduler/render paths use the same reservation
lock, and roll the reservation back to ENABLED (or the established failure
state) when execute_update() fails or raises, while preserving successful update
bookkeeping.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/plugin_system/plugin_manager.py`:
- Around line 715-787: Make the eligibility check and RUNNING transition in
run_scheduled_updates atomic by acquiring the existing per-plugin lock before
can_execute(), rechecking eligibility while holding it, and setting
PluginState.RUNNING before releasing or executing. Ensure competing
scheduler/render paths use the same reservation lock, and roll the reservation
back to ENABLED (or the established failure state) when execute_update() fails
or raises, while preserving successful update bookkeeping.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d470f057-6c61-43fa-b1fa-caec541130f1

📥 Commits

Reviewing files that changed from the base of the PR and between 6052a60 and 7285ba6.

📒 Files selected for processing (3)
  • src/display_controller.py
  • src/plugin_system/plugin_manager.py
  • test/test_display_controller_vegas_tick.py

@ChuckBuilds ChuckBuilds merged commit 1c7a0ce into main Jul 12, 2026
9 checks passed
@ChuckBuilds ChuckBuilds deleted the claude/pr-395-plugin-locking-3ss48c branch July 12, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants