mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
fix(plugins): one hung plugin no longer stops every plugin from updating
The single update worker took each plugin's lock with a blocking acquire(), and the render thread holds that lock while it runs the plugin's display(). A display() that never returned -- or a first frame still running on the executor's lingering thread after its 30s timeout -- parked the worker for good, and no plugin updated again. - The worker waits at most PLUGIN_LOCK_TIMEOUT (5s, the bound unload_plugin already uses), skips the busy plugin and records it through the normal update-failure path as a hang (PluginBusyError), so repeats open its circuit breaker. Log lines about it are rate-limited per plugin. - display() is timed on every frame (two monotonic reads). Calls of 2s or more are logged once a minute and counted in plugin health (slow_call_count, last_slow_call); calls past the executor timeout, and a first frame still running at it, are recorded as hangs (hang_count, last_hang) and no longer as successes. An update() still running after its timeout is recorded as a hang too. - on_config_change() runs under the plugin's lock via PluginManager.apply_config_change(); if the lock stays busy the latest change is deferred to the update worker, applied as soon as the lock frees and before the plugin's next update() at the latest. The plugin API is unchanged. Which thread runs each hook is documented in PluginManager.__init__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,19 @@ def plugin_manager(plugin_dir, tmp_path):
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def delivering_manager(tmp_path):
|
||||
"""A real PluginManager whose apply_config_change() the mocked one uses.
|
||||
|
||||
The hot-reload callback hands on_config_change to the plugin manager, so
|
||||
it runs under the plugin's lock; delegate to the real method so these
|
||||
tests still see the plugin called.
|
||||
"""
|
||||
manager = PluginManager(plugins_dir=str(tmp_path / "plugin-repos"))
|
||||
yield manager
|
||||
manager.stop_update_worker()
|
||||
|
||||
|
||||
class TestPluginManagerPreparation:
|
||||
def test_legacy_boolean_and_defaults(self, plugin_manager):
|
||||
prepared = plugin_manager.prepare_plugin_config(
|
||||
@@ -107,11 +120,13 @@ class TestPluginManagerPreparation:
|
||||
|
||||
|
||||
class TestHotReload:
|
||||
def test_on_config_change_gets_the_prepared_config(self, test_display_controller, plugin_manager):
|
||||
def test_on_config_change_gets_the_prepared_config(self, test_display_controller, plugin_manager,
|
||||
delivering_manager):
|
||||
controller = test_display_controller
|
||||
plugin = MagicMock()
|
||||
plugin.modes = ["demo"]
|
||||
pm = controller.plugin_manager
|
||||
pm.apply_config_change.side_effect = delivering_manager.apply_config_change
|
||||
pm.discover_plugins.return_value = ["demo"]
|
||||
pm.load_plugin.return_value = True
|
||||
pm.plugin_manifests = {}
|
||||
@@ -128,11 +143,13 @@ class TestHotReload:
|
||||
"enabled": True, "max_duration_seconds": 300}
|
||||
assert new_config["nhl"]["show_records"] is True
|
||||
|
||||
def test_raw_section_still_delivered_without_a_preparer(self, test_display_controller):
|
||||
def test_raw_section_still_delivered_without_a_preparer(self, test_display_controller,
|
||||
delivering_manager):
|
||||
controller = test_display_controller
|
||||
plugin = MagicMock()
|
||||
plugin.modes = ["demo"]
|
||||
pm = controller.plugin_manager
|
||||
pm.apply_config_change.side_effect = delivering_manager.apply_config_change
|
||||
pm.discover_plugins.return_value = ["demo"]
|
||||
pm.load_plugin.return_value = True
|
||||
pm.plugin_manifests = {}
|
||||
|
||||
Reference in New Issue
Block a user