mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(display): on-demand loads a disabled plugin live instead of failing (#678)
* fix(web): on-demand no longer restarts a running display service POST /display/on-demand/start treated start_service (default true, sent by "Preview on display", the on-demand dialog and the MQTT bridge) as "restart": with the service running it ran systemctl stop, slept 1.5s and started it again. Every request cold-started the display process -- every plugin reloaded, panel blank -- to deliver a request the running process already reads from the cache mailbox every ON_DEMAND_POLL_INTERVAL (0.25s), including mid-dwell, mid-screen and mid-Vegas. The restart bought nothing: startup only restores a session the display saved itself (display_on_demand_config), so the new request arrived through the same mailbox either way. start_service now means "start it if it is not running". The stop route coerces stop_service to a boolean so "false" no longer stops the service. test_api_v3_on_demand_restart.py pinned the old restart path; it now pins the replacement. Docs updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on-demand loads a disabled plugin live instead of failing The display process only loads enabled plugins, so an on-demand request for a disabled one -- "Preview on display" offers it on every config page, with a note that the plugin will be enabled for the preview -- failed with invalid-mode. Nothing enabled it short of a restart, and the on-demand route no longer restarts the service. _activate_on_demand now loads an installed-but-not-running plugin through the live-enable path (load_plugin + _register_loaded_plugin), with a new load_plugin(force_enabled=True) so the instance runs enabled while config.json keeps saying disabled. The plugin is tracked in _on_demand_loaded_plugins, and the main loop unloads it through _unregister_plugin once on-demand moves off it (stop, expiry, another request, or a failed request that ends the session) -- right after its own poll, where no display() is on the stack. A failed load publishes status error with load-failed. A plugin enabled during the session stays loaded. A session restored after a restart uses the same tracking instead of setting enabled in the config dict config_manager caches, so its plugin is unloaded when the session ends rather than staying loaded until the next restart. Ending a session no longer resumes the rotation onto a plugin that is about to be unloaded, which a restored session did. Also: a stop sent while on-demand is inactive clears a failed request's error, instead of /display/on-demand/status reporting status: error until the state aged out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"""On-demand for a plugin that is installed but disabled in config.
|
||||
|
||||
The display process only loads enabled plugins, so a request for a disabled
|
||||
one -- "Preview on display" offers it on every plugin's config page, with a
|
||||
note that the plugin will be enabled for the preview -- failed with
|
||||
"invalid-mode". Nothing loaded it short of a restart, and the on-demand
|
||||
route no longer restarts the service.
|
||||
|
||||
The display now loads such a plugin live for the session (force_enabled, so
|
||||
config.json keeps saying disabled) and the main loop unloads it once
|
||||
on-demand moves off it: a stop, an expiry, or a request for another plugin.
|
||||
|
||||
Also here: a stop sent after a failed request clears the error instead of
|
||||
leaving status 'error' published until the state ages out.
|
||||
"""
|
||||
|
||||
import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.plugin_system.plugin_state import PluginState
|
||||
|
||||
|
||||
def _make_plugin(modes):
|
||||
plugin = MagicMock()
|
||||
plugin.modes = list(modes)
|
||||
return plugin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller):
|
||||
"""An idle controller running 'clock', with 'preview-me' installed but disabled."""
|
||||
c = test_display_controller
|
||||
clock = _make_plugin(['clock'])
|
||||
preview = _make_plugin(['preview_a', 'preview_b'])
|
||||
instances = {'clock': clock}
|
||||
catalogue = {'clock': clock, 'preview-me': preview}
|
||||
|
||||
def load_plugin(plugin_id, force_enabled=False):
|
||||
instances[plugin_id] = catalogue[plugin_id]
|
||||
return True
|
||||
|
||||
def unload_plugin(plugin_id):
|
||||
return instances.pop(plugin_id, None) is not None
|
||||
|
||||
pm = c.plugin_manager
|
||||
pm.discovered_plugin_ids.return_value = set(catalogue)
|
||||
pm.discover_plugins.return_value = list(catalogue)
|
||||
pm.plugin_manifests = {}
|
||||
pm.load_plugin = MagicMock(side_effect=load_plugin)
|
||||
pm.unload_plugin = MagicMock(side_effect=unload_plugin)
|
||||
pm.get_plugin.side_effect = instances.get
|
||||
|
||||
config = {'clock': {'enabled': True}, 'preview-me': {'enabled': False}}
|
||||
c.config_service.get_config = lambda: config
|
||||
c.config_manager.save_config = MagicMock()
|
||||
c.cache_manager.set = MagicMock()
|
||||
c.cache_manager.clear_cache = MagicMock()
|
||||
|
||||
c._register_loaded_plugin('clock')
|
||||
c.current_mode_index = 0
|
||||
c.current_display_mode = 'clock'
|
||||
c.test_config = config
|
||||
c.test_instances = instances
|
||||
return c
|
||||
|
||||
|
||||
def _start(c, plugin_id='preview-me', mode=None, **extra):
|
||||
request = {'request_id': 'r-' + plugin_id, 'action': 'start',
|
||||
'plugin_id': plugin_id, 'mode': mode or plugin_id}
|
||||
request.update(extra)
|
||||
c._activate_on_demand(request)
|
||||
|
||||
|
||||
class TestLoadingForOnDemand:
|
||||
def test_a_disabled_plugin_is_loaded_and_shown(self, controller):
|
||||
_start(controller)
|
||||
|
||||
controller.plugin_manager.load_plugin.assert_called_once_with(
|
||||
'preview-me', force_enabled=True)
|
||||
assert controller.on_demand_active is True
|
||||
assert controller.on_demand_status == 'active'
|
||||
assert controller.on_demand_plugin_id == 'preview-me'
|
||||
assert controller.current_display_mode == 'preview_a'
|
||||
assert controller.plugin_display_modes['preview-me'] == ['preview_a', 'preview_b']
|
||||
|
||||
def test_config_json_is_not_written(self, controller):
|
||||
_start(controller)
|
||||
|
||||
controller.config_manager.save_config.assert_not_called()
|
||||
assert controller.test_config['preview-me'] == {'enabled': False}
|
||||
|
||||
def test_a_requested_mode_is_honoured(self, controller):
|
||||
_start(controller, mode='preview_b')
|
||||
assert controller.current_display_mode == 'preview_b'
|
||||
|
||||
def test_an_enabled_plugin_is_not_reloaded(self, controller):
|
||||
_start(controller, plugin_id='clock')
|
||||
|
||||
controller.plugin_manager.load_plugin.assert_not_called()
|
||||
assert controller.on_demand_active is True
|
||||
assert controller._on_demand_loaded_plugins == set()
|
||||
|
||||
def test_a_plugin_that_is_not_installed_is_not_loaded(self, controller):
|
||||
_start(controller, plugin_id='uninstalled')
|
||||
|
||||
controller.plugin_manager.load_plugin.assert_not_called()
|
||||
assert controller.on_demand_status == 'error'
|
||||
assert controller.on_demand_last_error == 'invalid-mode'
|
||||
|
||||
def test_a_plugin_installed_after_startup_is_found_by_rescanning(self, controller):
|
||||
controller.plugin_manager.discovered_plugin_ids.return_value = {'clock'}
|
||||
|
||||
_start(controller)
|
||||
|
||||
controller.plugin_manager.discover_plugins.assert_called()
|
||||
assert controller.on_demand_active is True
|
||||
|
||||
|
||||
class TestLoadFailures:
|
||||
def test_a_failed_load_reports_load_failed(self, controller):
|
||||
controller.plugin_manager.load_plugin = MagicMock(return_value=False)
|
||||
|
||||
_start(controller)
|
||||
|
||||
assert controller.on_demand_active is False
|
||||
assert controller.on_demand_status == 'error'
|
||||
assert controller.on_demand_last_error == 'load-failed'
|
||||
assert 'preview_a' not in controller.available_modes
|
||||
published = controller.cache_manager.set.call_args_list[-1]
|
||||
assert published.args[0] == 'display_on_demand_state'
|
||||
assert published.args[1]['status'] == 'error'
|
||||
assert published.args[1]['error'] == 'load-failed'
|
||||
|
||||
def test_a_load_that_raises_reports_load_failed(self, controller):
|
||||
controller.plugin_manager.load_plugin = MagicMock(side_effect=ImportError('no module'))
|
||||
|
||||
_start(controller)
|
||||
|
||||
assert controller.on_demand_status == 'error'
|
||||
assert controller.on_demand_last_error == 'load-failed'
|
||||
|
||||
def test_a_failed_load_leaves_the_rotation_alone(self, controller):
|
||||
controller.plugin_manager.load_plugin = MagicMock(return_value=False)
|
||||
|
||||
_start(controller)
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
assert controller.available_modes == ['clock']
|
||||
assert controller.current_display_mode == 'clock'
|
||||
assert controller._on_demand_loaded_plugins == set()
|
||||
controller.plugin_manager.unload_plugin.assert_not_called()
|
||||
|
||||
def test_a_plugin_that_loads_but_has_no_modes_is_unloaded_again(self, controller):
|
||||
"""Registered, then the activation fails: the release removes it."""
|
||||
controller._on_demand_modes_for_plugin = MagicMock(return_value=[])
|
||||
|
||||
_start(controller)
|
||||
assert controller.on_demand_last_error == 'no-modes'
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_called_once_with('preview-me')
|
||||
assert controller.available_modes == ['clock']
|
||||
|
||||
|
||||
class TestReleasingThePlugin:
|
||||
def test_it_stays_loaded_while_on_demand_shows_it(self, controller):
|
||||
_start(controller)
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_not_called()
|
||||
assert 'preview_a' in controller.plugin_modes
|
||||
|
||||
def test_a_stop_unloads_it_and_resumes_the_rotation(self, controller):
|
||||
_start(controller)
|
||||
controller._clear_on_demand(reason='requested-stop')
|
||||
# Deferred to the main loop: the stop may be read mid-display().
|
||||
controller.plugin_manager.unload_plugin.assert_not_called()
|
||||
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_called_once_with('preview-me')
|
||||
assert controller.available_modes == ['clock']
|
||||
assert 'preview-me' not in controller.plugin_display_modes
|
||||
assert 'preview_a' not in controller.plugin_modes
|
||||
assert controller.current_display_mode == 'clock'
|
||||
assert controller._on_demand_loaded_plugins == set()
|
||||
assert controller.test_config['preview-me'] == {'enabled': False}
|
||||
|
||||
def test_expiry_unloads_it(self, controller):
|
||||
_start(controller, duration=30)
|
||||
controller.on_demand_expires_at = time.time() - 1
|
||||
controller._check_on_demand_expiration()
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
assert controller.on_demand_last_event == 'expired'
|
||||
controller.plugin_manager.unload_plugin.assert_called_once_with('preview-me')
|
||||
|
||||
def test_a_request_for_another_plugin_unloads_it(self, controller):
|
||||
_start(controller)
|
||||
_start(controller, plugin_id='clock')
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_called_once_with('preview-me')
|
||||
assert controller.on_demand_active is True
|
||||
assert controller.on_demand_plugin_id == 'clock'
|
||||
assert controller.current_display_mode == 'clock'
|
||||
|
||||
def test_a_failed_request_that_ends_the_session_unloads_it(self, controller):
|
||||
_start(controller)
|
||||
_start(controller, plugin_id='uninstalled')
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_called_once_with('preview-me')
|
||||
assert controller.current_display_mode == 'clock'
|
||||
assert controller.force_change is True
|
||||
|
||||
def test_a_plugin_enabled_during_the_session_stays_loaded(self, controller):
|
||||
_start(controller)
|
||||
controller.test_config['preview-me'] = {'enabled': True}
|
||||
controller._clear_on_demand(reason='requested-stop')
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_not_called()
|
||||
assert 'preview_a' in controller.available_modes
|
||||
assert controller._on_demand_loaded_plugins == set()
|
||||
|
||||
def test_the_main_loop_releases_right_after_its_own_poll(self, controller):
|
||||
"""A stop read by the main loop unloads before the next screen, not
|
||||
one screen later. That poll runs with no display() on the stack."""
|
||||
import inspect
|
||||
source = inspect.getsource(type(controller).run)
|
||||
poll = source.index('self._check_on_demand_expiration()')
|
||||
release = source.index('self._release_on_demand_plugins()')
|
||||
render = source.index('self._tick_plugin_updates()')
|
||||
assert poll < release < render
|
||||
|
||||
def test_a_reconcile_that_runs_first_unloads_it_the_same_way(self, controller):
|
||||
"""A reconcile queued during the session runs at the top of the loop,
|
||||
before the release: it removes the plugin itself (not in the enabled
|
||||
set) and the release is then a no-op."""
|
||||
_start(controller)
|
||||
controller._clear_on_demand(reason='requested-stop')
|
||||
|
||||
controller._reconcile_enabled_plugins()
|
||||
controller._release_on_demand_plugins()
|
||||
|
||||
controller.plugin_manager.unload_plugin.assert_called_once_with('preview-me')
|
||||
assert controller.available_modes == ['clock']
|
||||
assert controller._on_demand_loaded_plugins == set()
|
||||
|
||||
def test_a_config_save_mid_session_keeps_the_instance_enabled(self, controller):
|
||||
"""on_config_change would otherwise read enabled: false and switch it off."""
|
||||
controller.config_service.subscribe = MagicMock()
|
||||
_start(controller)
|
||||
callback = controller._plugin_config_callbacks['preview-me']
|
||||
controller.plugin_manager.prepare_plugin_config = None
|
||||
|
||||
callback({}, {'enabled': False, 'color': 'red'})
|
||||
|
||||
plugin = controller.plugin_modes['preview_a']
|
||||
plugin.on_config_change.assert_called_once_with({'enabled': True, 'color': 'red'})
|
||||
|
||||
|
||||
class TestRestoredSession:
|
||||
"""A restart during a session for a disabled plugin restores it the same way."""
|
||||
|
||||
def test_the_plugin_is_tracked_and_config_is_left_alone(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
c.config.update({'clock': {'enabled': True}, 'disabled-one': {'enabled': False}})
|
||||
|
||||
selected = c._select_startup_plugins(
|
||||
['clock', 'disabled-one'], {'plugin_id': 'disabled-one', 'mode': 'x'})
|
||||
|
||||
assert 'disabled-one' in selected
|
||||
assert c._on_demand_loaded_plugins == {'disabled-one'}
|
||||
assert c.config['disabled-one']['enabled'] is False
|
||||
|
||||
|
||||
class TestResumingAfterTheSession:
|
||||
"""Ending a session never resumes the rotation onto the plugin that is
|
||||
about to be unloaded."""
|
||||
|
||||
def _restored_session(self, c, other_modes=('clock',)):
|
||||
"""As after a restart: no saved resume index, and the plugin's modes
|
||||
ordered in ahead of the rest (load order is not deterministic)."""
|
||||
c._on_demand_loaded_plugins.add('preview-me')
|
||||
c.plugin_manager.load_plugin('preview-me', force_enabled=True)
|
||||
c._register_loaded_plugin('preview-me')
|
||||
c.available_modes = ['preview_a', 'preview_b'] + list(other_modes)
|
||||
c.on_demand_active = True
|
||||
c.on_demand_status = 'active'
|
||||
c.on_demand_plugin_id = 'preview-me'
|
||||
c.on_demand_modes = ['preview_a', 'preview_b']
|
||||
c.rotation_resume_index = None
|
||||
c.current_mode_index = 0
|
||||
c.current_display_mode = 'preview_a'
|
||||
|
||||
def test_a_restored_session_resumes_on_an_enabled_mode(self, controller):
|
||||
self._restored_session(controller)
|
||||
|
||||
controller._clear_on_demand(reason='requested-stop')
|
||||
|
||||
assert controller.current_display_mode == 'clock'
|
||||
controller._release_on_demand_plugins()
|
||||
assert controller.available_modes == ['clock']
|
||||
assert controller.current_display_mode == 'clock'
|
||||
|
||||
def test_with_nothing_else_enabled_the_display_goes_idle(self, controller):
|
||||
controller._unregister_plugin('clock')
|
||||
self._restored_session(controller, other_modes=())
|
||||
|
||||
controller._clear_on_demand(reason='requested-stop')
|
||||
assert controller.current_display_mode is None
|
||||
|
||||
controller._release_on_demand_plugins()
|
||||
assert controller.available_modes == []
|
||||
assert controller.current_display_mode is None
|
||||
|
||||
def test_a_saved_resume_index_is_still_used(self, controller):
|
||||
c = controller
|
||||
c.available_modes = ['clock', 'other']
|
||||
c.plugin_modes['other'] = MagicMock()
|
||||
c.current_mode_index = 1
|
||||
c.current_display_mode = 'other'
|
||||
|
||||
_start(c)
|
||||
c._clear_on_demand(reason='requested-stop')
|
||||
|
||||
assert c.current_display_mode == 'other'
|
||||
|
||||
|
||||
class TestStopClearsAnError:
|
||||
def _post_stop(self, c):
|
||||
stop = {'request_id': 'S1', 'action': 'stop'}
|
||||
c._last_on_demand_poll = None
|
||||
c.cache_manager.get = MagicMock(
|
||||
side_effect=lambda key, *a, **kw:
|
||||
stop if key == 'display_on_demand_request' else None)
|
||||
c.cache_manager.delete = MagicMock()
|
||||
c._poll_on_demand_requests()
|
||||
|
||||
def test_a_stop_after_a_failed_request_clears_the_error(self, controller):
|
||||
_start(controller, plugin_id='uninstalled')
|
||||
assert controller.on_demand_status == 'error'
|
||||
|
||||
self._post_stop(controller)
|
||||
|
||||
assert controller.on_demand_status == 'idle'
|
||||
assert controller.on_demand_last_error is None
|
||||
state = controller.cache_manager.set.call_args_list[-1].args[1]
|
||||
assert state['status'] == 'idle'
|
||||
assert state['error'] is None
|
||||
|
||||
def test_clearing_the_error_leaves_the_rotation_alone(self, controller):
|
||||
_start(controller, plugin_id='uninstalled')
|
||||
controller.force_change = False
|
||||
|
||||
self._post_stop(controller)
|
||||
|
||||
assert controller.current_display_mode == 'clock'
|
||||
assert controller.force_change is False
|
||||
|
||||
def test_a_stop_while_idle_is_still_just_acknowledged(self, controller):
|
||||
controller._clear_on_demand = MagicMock()
|
||||
|
||||
self._post_stop(controller)
|
||||
|
||||
assert controller.on_demand_status == 'idle'
|
||||
assert controller.on_demand_request_id == 'S1'
|
||||
controller._clear_on_demand.assert_not_called()
|
||||
|
||||
|
||||
class TestForceEnabledLoad:
|
||||
"""PluginManager.load_plugin(force_enabled=True) runs the plugin enabled
|
||||
without touching the config it read."""
|
||||
|
||||
class _Plugin:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.enabled_calls = 0
|
||||
|
||||
def on_enable(self):
|
||||
self.enabled_calls += 1
|
||||
|
||||
@pytest.fixture
|
||||
def pm(self, tmp_path):
|
||||
plugins_dir = tmp_path / 'plugins'
|
||||
(plugins_dir / 'demo').mkdir(parents=True)
|
||||
manager = PluginManager(plugins_dir=str(plugins_dir))
|
||||
manager.plugin_manifests['demo'] = {'id': 'demo', 'name': 'Demo'}
|
||||
manager.schema_manager = MagicMock()
|
||||
manager.schema_manager.get_schema_path.return_value = None
|
||||
# Hand the section back as-is, as the fallback path can: the copy in
|
||||
# load_plugin is what keeps the cached config clean.
|
||||
manager.schema_manager.prepare_plugin_config.side_effect = (
|
||||
lambda pid, cfg, schema=None, changed_paths=None: cfg)
|
||||
manager.plugin_loader = MagicMock()
|
||||
manager.plugin_loader.find_plugin_directory.return_value = plugins_dir / 'demo'
|
||||
manager.plugin_loader.load_plugin.side_effect = (
|
||||
lambda **kw: (self._Plugin(kw['config']), None))
|
||||
manager.config_manager = MagicMock()
|
||||
manager.cached_config = {'demo': {'enabled': False, 'color': 'red'}}
|
||||
manager.config_manager.load_config.return_value = manager.cached_config
|
||||
return manager
|
||||
|
||||
def test_a_disabled_plugin_loads_disabled_by_default(self, pm):
|
||||
assert pm.load_plugin('demo') is True
|
||||
assert pm.plugins['demo'].enabled_calls == 0
|
||||
assert pm.state_manager.get_state('demo') == PluginState.DISABLED
|
||||
|
||||
def test_force_enabled_runs_it_enabled(self, pm):
|
||||
assert pm.load_plugin('demo', force_enabled=True) is True
|
||||
plugin = pm.plugins['demo']
|
||||
assert plugin.config == {'enabled': True, 'color': 'red'}
|
||||
assert plugin.enabled_calls == 1
|
||||
assert pm.state_manager.get_state('demo') == PluginState.ENABLED
|
||||
|
||||
def test_force_enabled_does_not_touch_the_cached_config(self, pm):
|
||||
pm.load_plugin('demo', force_enabled=True)
|
||||
assert pm.cached_config['demo'] == {'enabled': False, 'color': 'red'}
|
||||
Reference in New Issue
Block a user