Files
LEDMatrix/test/test_plugin_update_reservation.py
T
ChuckandClaude Opus 5.5 84afa9d64f refactor: delete dead Python code in the core (and stop storing Wi-Fi passwords) (#608)
* refactor(plugins): remove the no-op PluginHealthMonitor

Its monitor loop did nothing (`if callbacks: pass`), register_health_check
had no callers and api_v3.health_monitor was never read by any route. The
live health data comes from PluginHealthTracker, which is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(store): drop the never-set uninstall tombstones

Nothing in production called mark_recently_uninstalled, so the
reconciler's was_recently_uninstalled check was always False. The
persistent uninstall registry is what actually stops resurrection; the
reconciler test now exercises that gate instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(common): delete unused config/display/game helpers, utils and error_handler

Nothing in core, the web UI, scripts or the plugin monorepo imports
config_helper, display_helper, game_helper, utils or error_handler; only
their own tests did. The error_handler re-exports leave src.common's
__all__; APIHelper, TextHelper, ScrollHelper, LogoHelper and the adaptive
layout exports are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(config): drop ConfigService's unused versioning and save API

ConfigVersion, get_version/get_version_history/get_version_config,
rollback, save_config, reload, get_plugin_config and the backward-compat
load_config/get_config_path/get_secrets_path had no callers. The display
controller only uses get_config, subscribe, unsubscribe and shutdown,
plus the file watcher. Change detection now compares against the
current checksum instead of the last history entry.

The subscriber tests asserted `callback.called or True`; they now
reload the way the watcher does and assert the notification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): drop unread plugin state history and callbacks

plugin_state.PluginStateManager kept a bounded per-plugin transition
history that only get_state_history (tests only) read; get_state_info
reports a separate lifetime count, which stays. set_error_info and
record_display had no callers, and set_state_with_error's `error`
argument only fed the history.

The web-side state_manager.PluginStateManager loses
subscribe_to_state_changes, _notify_callbacks, set_plugin_error and
get_state_version, none of which had callers; with no subscribers the
old-state copy in update_plugin_state went with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): remove unused PluginManager methods and attribute guards

update_all_plugins was only called by a test (the display loop uses
run_scheduled_updates); get_plugin_health_metrics,
get_plugin_resource_metrics and get_plugin_state had no callers; and
plugin_modules was written but never read. plugin_directories is now
initialised in __init__, so the hasattr() guards around it go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(plugins): remove unused executor, loader, store and package helpers

- PluginExecutor.execute_safe: no callers.
- PluginLoader._parse_semver: only its own tests; compatibility.parse_semver
  is the live copy and test_compatibility.py already covers it.
- PluginStoreManager.get_installed_plugin_info: no callers.
- PluginResourceMonitor._local: never read.
- src.plugin_system.get_store_manager and __api_version__: no importers in
  core, scripts or the plugin monorepo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(wifi): stop storing Wi-Fi passwords in wifi_config.json

WiFiManager appended every joined network's SSID and password, in
plaintext, to saved_networks in config/wifi_config.json, and nothing
(web UI, backup restore, scripts) ever read them back: NetworkManager
keeps its own credentials. The writes are gone, and loading the config
now drops any saved_networks key and rewrites the file, so passwords
already on disk are scrubbed.

Also removes _check_dnsmasq_conflict (never called) and _detect_trixie,
whose result only reached one log line, along with the
NM_CONNECTIONS_PATHS constant only it used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(display): remove unreachable and unused DisplayController code

- _follower_rebuild_scroll_image: never called.
- mode_duration (never read) and last_mode_change (write-only).
- The `chosen_cap <= 0` branch: chosen_cap is either the minimum of
  caps already filtered to > 0 or DEFAULT_DYNAMIC_DURATION_CAP (180).
- The `max_duration < min_duration` branch directly after
  `max_duration = max(min_duration, max_duration)`.
- The circuit-breaker branch's `display_result = False` and
  `manager_to_display = None`: the first is overwritten a few lines
  later, the second is already None there.
- The bool-to-bool conversion of execute_display's result, which is
  always a bool.
- The `loaded_plugins` lookup in _update_modules: PluginManager has no
  such attribute, so it always fell through to `plugins`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(vegas): remove unused config update, boundary finder and refresh

VegasModeConfig.update had no callers outside its own tests (the
coordinator rebuilds the config with from_config on a change);
geometry.find_item_boundary and StreamManager._refresh_plugin_content
had no callers at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(run): drop the debug block that pretended to import the plugin system

In debug mode run.py put src/plugin_system itself on sys.path and printed
"Plugin system import successful" without importing anything. Nothing
imports plugin_system modules by bare name, so the path entry did
nothing either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: delete tests that test nothing

- test/plugins/test_{basketball_scoreboard,calendar,clock_simple,
  odds_ticker,soccer_scoreboard,text_display}.py skip everywhere the named
  plugins are not installed, including CI (LEDMATRIX_PLUGINS_DIR holds only
  the fixture plugin); test_plugin_matrix.py already covers every
  discovered plugin. Their PluginTestBase and the fixtures only it used
  (plugins_dir, mock_display_manager, mock_cache_manager,
  mock_plugin_manager, base_plugin_config in test/plugins/conftest.py) go
  with them.
- test_plugin_system.py: test_discover_plugins (body was `pass`) and
  test_dependency_check (a comment), plus the test_plugin_manager fixture
  only the former requested.
- test_display_manager.py: test_draw_image asserted that an image it had
  just assigned was not None.
- test_display_controller.py: the rotation and schedule-override tests
  re-implemented the run-loop arithmetic inline and asserted on their own
  result without calling the controller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: expect one plugin_last_update success stamp after update_all_plugins

EveryStampRecordsACompletion required at least two success-path stamps;
the second was update_all_plugins, removed as test-only. The worker and
synchronous paths share the remaining stamp in _execute_update_now, and
the check that every stamp calls _note_update_completed is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 12:36:26 -04:00

325 lines
12 KiB
Python

"""Plugin update scheduling must be atomic (issue #401).
`run_scheduled_updates()` decided whether to update a plugin with a
check-then-act sequence: `can_execute()` and `set_state(RUNNING)` were separate
calls with nothing between them, so two scheduler threads could both observe
ENABLED and both go on to call the same plugin's `update()`.
Two schedulers really do run at once. The render loop calls
`_tick_plugin_updates()`, and Vegas mode fires its own `vegas-plugin-tick`
daemon thread every few seconds; that thread is never joined when
`VegasModeCoordinator.play()` returns, so a slow `update()` still in flight can
overlap the next tick from the main loop.
A plugin whose `update()` runs twice at once is unsafe unless it happens to be
reentrant — shared mutable state, a non-thread-safe HTTP session or cache all
break. These tests pin the reservation that closes it, and the state
bookkeeping that has to survive it: a plugin reserved but never dispatched must
not be stranded in RUNNING, because `can_execute()` would then refuse it
forever.
"""
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_state import PluginState # noqa: E402
class OverlapDetectingPlugin:
"""Records the high-water mark of concurrent update() calls."""
def __init__(self, update_seconds=0.25):
self.enabled = True
self.update_seconds = update_seconds
self.update_calls = 0
self.max_concurrent = 0
self._active = 0
self._guard = threading.Lock()
def update(self):
with self._guard:
self._active += 1
self.update_calls += 1
self.max_concurrent = max(self.max_concurrent, self._active)
try:
time.sleep(self.update_seconds)
finally:
with self._guard:
self._active -= 1
return True
def display(self, force_clear=False):
return True
@pytest.fixture
def pm(tmp_path):
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=None, cache_manager=None)
yield manager
manager.stop_update_worker()
def _install(pm, plugin, plugin_id="racy-plugin", interval=0.01):
pm.plugins[plugin_id] = plugin
pm._update_interval_cache[plugin_id] = interval
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
return plugin_id
def _widen_check_then_act_window(pm, delay=0.02):
"""Hold every scheduler thread inside the eligibility check at once.
The real gap between can_execute() and the RUNNING transition is a couple
of bytecodes wide, so a plain thread race under the GIL almost never lands
in it — an unfixed scheduler looks correct in a test that just hammers it.
Delaying the check reproduces the interleaving that Vegas's tick thread and
the render loop actually produce when a slow update() overlaps the next
tick, and it is what makes these tests fail without the reservation.
Once the check and the transition are under one lock, the delay only
serializes the schedulers: the losers observe RUNNING and back off.
"""
real_can_execute = pm.state_manager.can_execute
def slow_can_execute(plugin_id):
result = real_can_execute(plugin_id)
time.sleep(delay)
return result
pm.state_manager.can_execute = slow_can_execute
def _hammer(target, threads=8, rounds=1):
"""Run `target` on N threads released simultaneously by a barrier."""
errors = []
barrier = threading.Barrier(threads)
def runner():
try:
barrier.wait(timeout=5)
for _ in range(rounds):
target()
except Exception as exc: # noqa: BLE001 - surfaced by the assertion
errors.append(exc)
workers = [threading.Thread(target=runner) for _ in range(threads)]
for worker in workers:
worker.start()
for worker in workers:
worker.join(timeout=30)
assert not errors, f"worker raised: {errors[0]!r}"
class TestReservationAtomicity:
"""The check-and-claim itself, independent of any dispatch path."""
def test_only_one_caller_wins_the_reservation(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
_widen_check_then_act_window(pm)
wins = []
lock = threading.Lock()
def claim():
if pm._reserve_for_update(plugin_id):
with lock:
wins.append(threading.current_thread().name)
_hammer(claim, threads=16)
assert len(wins) == 1, (
f"{len(wins)} threads reserved the same plugin concurrently; "
"the eligibility check and the RUNNING transition are not atomic")
assert pm.state_manager.get_state(plugin_id) == PluginState.RUNNING
def test_reservation_refused_while_running(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
assert pm._reserve_for_update(plugin_id) is True
assert pm._reserve_for_update(plugin_id) is False
def test_reservation_can_be_handed_back(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
assert pm._reserve_for_update(plugin_id) is True
pm._release_reservation(plugin_id)
assert pm.state_manager.get_state(plugin_id) == PluginState.ENABLED
assert pm._reserve_for_update(plugin_id) is True, \
"a released reservation must be claimable again"
def test_due_check_is_inside_the_reservation(self, pm):
"""Two threads that both decided 'due' must not both get a turn.
With the due check outside the lock, the loser of the race could claim
the plugin the moment the winner finished, running update() twice
inside one interval.
"""
plugin_id = _install(pm, OverlapDetectingPlugin(), interval=60.0)
now = time.time()
assert pm._reserve_for_update(plugin_id, now, 60.0) is True
pm.plugin_last_update[plugin_id] = now
pm._release_reservation(plugin_id)
assert pm._reserve_for_update(plugin_id, now, 60.0) is False, \
"plugin updated just now must not be due again"
class TestNoConcurrentUpdate:
"""The end-to-end invariant the issue is actually about."""
def test_synchronous_path_never_overlaps(self, pm):
"""The kill-switch path ran update() inline with no dedup at all."""
pm._synchronous_updates = True
plugin = OverlapDetectingPlugin(update_seconds=0.25)
_install(pm, plugin)
_widen_check_then_act_window(pm)
_hammer(pm.run_scheduled_updates, threads=8)
assert plugin.max_concurrent == 1, (
f"update() ran {plugin.max_concurrent}x concurrently on the "
"synchronous path")
def test_async_path_never_overlaps(self, pm):
plugin = OverlapDetectingPlugin(update_seconds=0.2)
plugin_id = _install(pm, plugin)
_hammer(pm.run_scheduled_updates, threads=8, rounds=3)
# Wait for an update to have both started and finished. Polling only
# `_active` races the worker: before it picks the item up nothing is
# active yet, so the loop would fall straight through and assert on a
# plugin that never ran.
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if plugin.update_calls >= 1 and plugin._active == 0:
break
time.sleep(0.05)
assert plugin.update_calls >= 1, "no update ran on the async path"
assert plugin.max_concurrent == 1, (
f"update() ran {plugin.max_concurrent}x concurrently on the "
"async path")
assert plugin_id in pm.plugins
class TestNoStrandedState:
"""A reservation that is never dispatched must not wedge the plugin."""
def test_plugin_returns_to_enabled_after_async_updates(self, pm):
plugin = OverlapDetectingPlugin(update_seconds=0.1)
plugin_id = _install(pm, plugin)
_hammer(pm.run_scheduled_updates, threads=6, rounds=2)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if (plugin.update_calls >= 1
and pm.state_manager.get_state(plugin_id) == PluginState.ENABLED
and not pm._pending_updates):
break
time.sleep(0.05)
# ENABLED is also the starting state, so without this the assertion
# below would pass on a plugin that never got scheduled at all.
assert plugin.update_calls >= 1, "no update ran; the state assertion would be vacuous"
assert pm.state_manager.get_state(plugin_id) == PluginState.ENABLED, \
"plugin stranded in RUNNING; can_execute() would refuse it forever"
assert not pm._pending_updates, "pending set not drained"
def test_pending_cleared_before_state_reset(self, pm):
"""Invariant: never-RUNNING implies never-pending.
_finish() used to clear the pending entry *after* flipping the state
back to ENABLED. In that window a scheduler could reserve the plugin
and then have its enqueue dropped by the pending-dedup.
"""
plugin = OverlapDetectingPlugin(update_seconds=0.05)
plugin_id = _install(pm, plugin)
violations = []
stop = threading.Event()
def watcher():
while not stop.is_set():
state = pm.state_manager.get_state(plugin_id)
if state != PluginState.RUNNING and plugin_id in pm._pending_updates:
violations.append(state)
time.sleep(0.001)
thread = threading.Thread(target=watcher, daemon=True)
thread.start()
try:
for _ in range(15):
pm.run_scheduled_updates()
time.sleep(0.05)
finally:
stop.set()
thread.join(timeout=5)
assert not violations, (
f"{len(violations)} sample(s) saw a non-RUNNING plugin still in "
"_pending_updates")
class TestDispatchFailure:
"""A reservation must survive the dispatch itself failing.
_enqueue_update() claims the plugin, adds it to the pending set, and only
then starts the worker and queues the item. threading.Thread.start() raises
RuntimeError when the OS refuses a new thread — not hypothetical on a Pi
under memory or thread pressure. Nothing is queued to release the plugin at
that point, so without an explicit rollback it stays RUNNING with a stale
pending entry and can_execute() refuses it for the rest of the process.
"""
def test_reservation_released_when_the_worker_cannot_start(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
def refuse_to_start():
raise RuntimeError("can't start new thread")
pm._ensure_update_worker = refuse_to_start
assert pm._reserve_for_update(plugin_id) is True
pm._enqueue_update(plugin_id, time.time())
assert pm.state_manager.get_state(plugin_id) == PluginState.ENABLED, \
"plugin left in RUNNING after a failed dispatch; can_execute() " \
"would refuse it forever"
assert plugin_id not in pm._pending_updates, \
"stale pending entry would make the next enqueue hit the dedup"
assert pm._reserve_for_update(plugin_id) is True, \
"plugin must be claimable again on the next tick"
def test_dispatch_failure_does_not_abort_the_rest_of_the_tick(self, pm):
"""One plugin failing to queue must not skip the others in that tick."""
first = OverlapDetectingPlugin()
second = OverlapDetectingPlugin()
_install(pm, first, plugin_id="plugin-a")
_install(pm, second, plugin_id="plugin-b")
calls = []
real_ensure = pm._ensure_update_worker
def fail_first_only():
calls.append(1)
if len(calls) == 1:
raise RuntimeError("can't start new thread")
return real_ensure()
pm._ensure_update_worker = fail_first_only
pm.run_scheduled_updates() # must not propagate the RuntimeError
assert len(calls) == 2, (
"run_scheduled_updates() stopped after the failing plugin; the "
"exception escaped the enqueue")
for plugin_id in ("plugin-a", "plugin-b"):
assert pm.state_manager.get_state(plugin_id) in (
PluginState.ENABLED, PluginState.RUNNING), \
f"{plugin_id} left in an unexpected state"