mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* 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>
325 lines
12 KiB
Python
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"
|