mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-22 19:08:17 +00:00
Addresses the review finding on the cross-thread reads. _enabled_plugin_not_running runs on the config-watcher thread and read two mappings the render thread mutates. Catching RuntimeError was not a fix: it turned a torn read into a coin flip between an unnecessary discovery scan and a missed retry, which is the bug this PR exists to remove. Both reads are now snapshots taken under the lock that guards their writes: - plugin_manifests via a new PluginManager.discovered_plugin_ids(), which copies the ids while holding the existing _discovery_lock. Discovery rebuilds that mapping entry by entry, so an unsynchronised reader can see it half-populated. - plugin_display_modes under a new controller lock, taken at the only two sites that mutate it (_register_loaded_plugin / _unregister_plugin). The locks are never nested -- each snapshot is taken and released before the next -- so this cannot deadlock against discovery, which holds _discovery_lock while it rebuilds. No cost on the per-frame path. Both mutation sites run during reconcile, which is rare, and every hot-path read of plugin_display_modes is on the render thread itself, same thread as the writes, so those stay lock-free. Tests: the accessor returns a snapshot rather than a live view, and actually takes the discovery lock (proved from a second thread, since an RLock is reentrant on the owning one) so a later refactor cannot quietly drop it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW
64 lines
2.0 KiB
Python
64 lines
2.0 KiB
Python
"""Tests for PluginManager.discovered_plugin_ids().
|
|
|
|
The config-watcher thread needs the set of discovered plugin ids while the
|
|
render thread may be rebuilding plugin_manifests. Iterating that dict directly
|
|
can observe a half-populated mapping or raise "dictionary changed size during
|
|
iteration", so the accessor snapshots it under the discovery lock.
|
|
"""
|
|
|
|
import tempfile
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
|
|
|
|
@pytest.fixture
|
|
def pm():
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
yield PluginManager(plugins_dir=str(Path(tmp) / "plugins"))
|
|
|
|
|
|
def test_returns_the_discovered_ids(pm):
|
|
pm.plugin_manifests = {"clock-simple": {}, "hockey-scoreboard": {}}
|
|
assert pm.discovered_plugin_ids() == {"clock-simple", "hockey-scoreboard"}
|
|
|
|
|
|
def test_empty_when_nothing_discovered(pm):
|
|
pm.plugin_manifests = {}
|
|
assert pm.discovered_plugin_ids() == set()
|
|
|
|
|
|
def test_is_a_snapshot_not_a_live_view(pm):
|
|
"""The caller iterates the result on another thread; it must not alias
|
|
the mapping discovery is still writing to."""
|
|
pm.plugin_manifests = {"clock-simple": {}}
|
|
snapshot = pm.discovered_plugin_ids()
|
|
pm.plugin_manifests["hockey-scoreboard"] = {}
|
|
assert snapshot == {"clock-simple"}
|
|
|
|
|
|
def test_takes_the_discovery_lock(pm):
|
|
"""Guards against the lock being dropped in a later refactor: with the
|
|
lock held by another thread the call must block rather than read."""
|
|
pm.plugin_manifests = {"clock-simple": {}}
|
|
finished = threading.Event()
|
|
|
|
def call():
|
|
pm.discovered_plugin_ids()
|
|
finished.set()
|
|
|
|
pm._discovery_lock.acquire()
|
|
try:
|
|
# RLock is reentrant per-thread, so use a *different* thread to prove
|
|
# the accessor actually waits on it.
|
|
t = threading.Thread(target=call, daemon=True)
|
|
t.start()
|
|
assert not finished.wait(timeout=0.3), "accessor did not take the discovery lock"
|
|
finally:
|
|
pm._discovery_lock.release()
|
|
t.join(timeout=2)
|
|
assert finished.is_set()
|