mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-16 03:49:13 +00:00
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5) The control socket is now the only way the web interface sends the display a command. The display stops reading display_on_demand_request and plugin_error_clear_request, and the web interface stops writing them. - Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence, _consume_on_demand_request, the deprecation log) and no persisted display_on_demand_processed_id guard; the error publisher reads no clear request. CacheManager.file_signature and MailboxWatch are removed. - A write to either retired key is dropped by CacheManager.save_cache and logged once per writer, naming the plugin from the call stack (or the request's plugin_id), with the API to move to. - Web: on-demand start with no display listening starts the service (when start_service) and sends the request again once the socket answers (45 s, 10 s for a running service without a socket yet); every other failure is a 503 (400 for invalid_args). Stop answers 503 when no display listens, unless stop_service. errors/clear answers 503 with a reason-specific message instead of writing a request; clear_pending is always false. src.ipc.client.should_fall_back is replaced by display_not_listening. - Kept: display_current_state, display_on_demand_state, plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot answer), and display_on_demand_config (the display's resume record). Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox cadence, file_signature and MailboxWatch tests); tests that injected requests through the mailbox now use the socket queue or a plugin's in-process request. The run-loop harness sends on-demand requests over its fake control socket, so four golden traces change: on-demand starts and stops land at the request instant instead of the next 0.25 s mailbox look (one frame fewer on the screen they end), and in vegas.json within one frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice check by under a second. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the on-demand routes' other callers ack over the socket, not the mailbox test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and test_web_api's stop test checked the mailbox write; they now get an ack from a mocked control socket. The stop route touches no manager any more, so it leaves the removed-catch-all sample. A request that was sent is never 'not listening' (pins the sent check the mutation run let through). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(web): a start that waits for the display answers 202 and is delivered in the background The start route held a request open for up to 45 s while a cold-started display loaded its plugins; the MQTT bridge (15 s timeout) and browsers reported a failure for a request that was then delivered. Now, when no display is listening, the route starts the service if asked and answers 202 with status "starting" at once. A single worker in the web process (web_interface/on_demand_dispatch.py) sends the request until the display acknowledges it or the wait runs out (45 s cold start, 10 s for a running service without a socket yet). A newer start supersedes the pending one; a stop cancels it (and succeeds, with cancelled_request_id, even with no display listening). The outcome is reported by /display/on-demand/status (source "web": starting, or error with start-timeout or the socket's reason, until the display publishes something newer) and by /display/current-status as on_demand_pending. Callers: the web UI's on-demand modal and "Preview on display" treat "starting" as taken (an info toast); the MQTT bridge already treats any non-error 2xx as success (now pinned by a test). Tests: the dispatcher (ack, retry then ack, start-timeout, other failures, superseded, an in-flight ack for a superseded start, stop while pending, a per-start wait, outcome lifetime); the routes (202, status routes while pending and after a timeout, a later display state replacing the failure, stop while pending, a new start superseding); a JS suite for app.js. Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a cancelled on-demand start never reaches the display after its replacement CI caught a race in test_a_new_start_supersedes_the_pending_one: the dispatcher's worker could read the old start, the route cancel it and send the new one, and the worker's send of the old one land after it. cancel() now waits out a send already in flight (the worker holds a send lock while it reads the pending start and sends it), so whatever the caller sends next lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which fails without the wait. The Linux-only TestRealSocket test for a display that went away now expects the 202 and the start-timeout that follows, as the route answers since the background dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a delivered on-demand start reads as starting until the display acts on it On ledpi (three cold starts) the display acknowledged the start as its socket opened, then took ~5 s to act on it while Vegas built its first strip; the status routes meanwhile showed the display's own idle state, so a UI polling every 700 ms flashed idle. The display's on-demand state now names the request it answers (request_id). A delivered start keeps reading as status "starting" with delivered: true, in /display/on-demand/status and as on_demand_pending in /display/current-status, until the display publishes state for that request id (a display without the field: any state newer than the delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's startup state, which can be published after the acknowledgement, names no request and does not end it. Tests: stays starting against the startup idle state (no id, an older id); the matching active state and the matching error take over; an older display's newer state takes over; the 30 s cap; the display's state names its request. Mutation check: 11 mutants, 11 killed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(display): on_demand_request_id has a class default for controllers built without __init__ _on_demand_state now publishes it, and test_state_stream_readers builds controllers with __new__. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> * docs: blank line before 3.8.7 heading --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
16 KiB
Python
407 lines
16 KiB
Python
"""Plugins asking for the screen in-process: BasePlugin.request_on_demand()
|
|
and end_on_demand().
|
|
|
|
A plugin running in the display process used to write the
|
|
``display_on_demand_request`` mailbox, which the display no longer reads
|
|
(stage 5). These tests pin the way in that replaced it:
|
|
|
|
* BasePlugin -> PluginManager -> DisplayController.submit_plugin_on_demand,
|
|
which only queues, from any thread;
|
|
* the render thread applies the queue where it applies socket commands,
|
|
through _handle_on_demand_request, at once, and woken by the control
|
|
socket when it is up;
|
|
* a plugin's stop ends only its own session;
|
|
* no display to ask (the web interface's plugin manager, an old core's
|
|
plugin manager) answers None.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from src.ipc.server import ControlServer
|
|
from src.plugin_system.base_plugin import BasePlugin
|
|
from src.plugin_system.plugin_manager import PluginManager
|
|
|
|
|
|
class _Plugin(BasePlugin):
|
|
def update(self):
|
|
pass
|
|
|
|
def display(self, force_clear=False):
|
|
pass
|
|
|
|
|
|
def _plugin(plugin_id, manager):
|
|
plugin = _Plugin.__new__(_Plugin)
|
|
plugin.plugin_id = plugin_id
|
|
plugin.plugin_manager = manager
|
|
return plugin
|
|
|
|
|
|
def _manager(handler=None):
|
|
manager = PluginManager.__new__(PluginManager)
|
|
manager.logger = logging.getLogger('test.plugin_on_demand')
|
|
if handler is not None:
|
|
manager.set_on_demand_handler(handler)
|
|
return manager
|
|
|
|
|
|
class _WakeServer:
|
|
"""The parts of ControlServer the controller uses, with no socket."""
|
|
|
|
def __init__(self):
|
|
self.woken = 0
|
|
self.has_pending = False
|
|
|
|
def wake(self):
|
|
self.woken += 1
|
|
self.has_pending = True
|
|
|
|
def drain(self):
|
|
self.has_pending = False
|
|
return []
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(test_display_controller):
|
|
c_ = test_display_controller
|
|
c_.on_demand_active = False
|
|
c_.on_demand_request_id = None
|
|
c_.cache_manager.get = MagicMock(return_value=None)
|
|
c_.cache_manager.set = MagicMock()
|
|
c_.cache_manager.delete = MagicMock()
|
|
c_._activate_on_demand = MagicMock()
|
|
return c_
|
|
|
|
|
|
@pytest.fixture
|
|
def wired(controller):
|
|
"""A real PluginManager wired to the controller, as __init__ wires it."""
|
|
manager = _manager(controller.submit_plugin_on_demand)
|
|
return controller, manager
|
|
|
|
|
|
class TestWiring:
|
|
def test_the_controller_wires_its_plugin_manager(self, controller):
|
|
controller.plugin_manager.set_on_demand_handler.assert_called_once_with(
|
|
controller.submit_plugin_on_demand)
|
|
|
|
def test_a_start_reaches_the_on_demand_handler(self, wired):
|
|
controller, manager = wired
|
|
rid = _plugin('pomodoro-timer', manager).request_on_demand(
|
|
mode='pomodoro', duration=30, pinned=True)
|
|
assert isinstance(rid, str) and rid
|
|
controller._activate_on_demand.assert_not_called() # only queued
|
|
controller._poll_on_demand_requests()
|
|
controller._activate_on_demand.assert_called_once()
|
|
request = controller._activate_on_demand.call_args.args[0]
|
|
assert request['request_id'] == rid
|
|
assert request['action'] == 'start'
|
|
assert request['plugin_id'] == 'pomodoro-timer'
|
|
assert request['mode'] == 'pomodoro'
|
|
assert request['duration'] == 30.0 and request['pinned'] is True
|
|
assert request['source'] == 'plugin'
|
|
assert controller.on_demand_request_id == rid
|
|
|
|
def test_a_plugin_request_never_touches_the_mailbox(self, wired):
|
|
controller, manager = wired
|
|
_plugin('on-air', manager).request_on_demand(mode='on_air')
|
|
controller._drain_control_commands()
|
|
controller._activate_on_demand.assert_called_once()
|
|
mailbox_reads = [call for call in controller.cache_manager.get.call_args_list
|
|
if call.args[0] == 'display_on_demand_request']
|
|
assert mailbox_reads == []
|
|
controller.cache_manager.delete.assert_not_called()
|
|
|
|
def test_requests_apply_in_order(self, wired):
|
|
controller, manager = wired
|
|
seen = []
|
|
controller._activate_on_demand = MagicMock(
|
|
side_effect=lambda r: seen.append(r['mode']))
|
|
plugin = _plugin('p', manager)
|
|
for mode in ('a', 'b', 'c'):
|
|
plugin.request_on_demand(mode=mode)
|
|
controller._poll_on_demand_requests()
|
|
assert seen == ['a', 'b', 'c']
|
|
|
|
def test_a_failing_request_is_contained(self, wired):
|
|
controller, manager = wired
|
|
calls = []
|
|
|
|
def activate(request):
|
|
calls.append(request['mode'])
|
|
if request['mode'] == 'bad':
|
|
raise RuntimeError('plugin exploded')
|
|
|
|
controller._activate_on_demand = MagicMock(side_effect=activate)
|
|
plugin = _plugin('p', manager)
|
|
plugin.request_on_demand(mode='bad')
|
|
plugin.request_on_demand(mode='good')
|
|
controller._poll_on_demand_requests()
|
|
assert calls == ['bad', 'good']
|
|
|
|
|
|
class TestPromptness:
|
|
def test_a_plugin_request_skips_the_pending_changes_floor(self, wired):
|
|
controller, manager = wired
|
|
controller._control_server = None
|
|
controller._service_pending_changes()
|
|
_plugin('p', manager).request_on_demand()
|
|
controller._service_pending_changes() # well inside the 0.25 s floor
|
|
controller._activate_on_demand.assert_called_once()
|
|
|
|
def test_a_plugin_request_lands_on_the_next_poll(self, wired):
|
|
controller, manager = wired
|
|
controller._control_server = _WakeServer()
|
|
controller._poll_on_demand_requests()
|
|
_plugin('p', manager).request_on_demand()
|
|
controller._poll_on_demand_requests()
|
|
controller._activate_on_demand.assert_called_once()
|
|
|
|
def test_it_wakes_the_control_socket_wait(self, wired):
|
|
controller, manager = wired
|
|
server = ControlServer('/nonexistent/control.sock') # never started
|
|
controller._control_server = server
|
|
assert not server.wait_for_command(0)
|
|
_plugin('p', manager).request_on_demand()
|
|
assert server.has_pending
|
|
assert controller._wait_for_control(5.0) is True # returns at once
|
|
assert controller._control_command_pending()
|
|
controller._poll_on_demand_requests()
|
|
controller._activate_on_demand.assert_called_once()
|
|
assert not server.has_pending
|
|
assert not controller._control_command_pending()
|
|
|
|
def test_without_a_socket_a_waiting_request_cuts_the_sleep(self, wired):
|
|
controller, manager = wired
|
|
controller._control_server = None
|
|
_plugin('p', manager).request_on_demand()
|
|
started = time.monotonic()
|
|
assert controller._wait_for_control(5.0) is True
|
|
assert time.monotonic() - started < 1.0
|
|
assert controller._control_command_pending()
|
|
|
|
def test_nothing_waiting_keeps_the_floor(self, controller):
|
|
controller._control_server = None
|
|
controller._poll_on_demand_requests = MagicMock()
|
|
controller._service_pending_changes()
|
|
controller._service_pending_changes()
|
|
assert controller._poll_on_demand_requests.call_count == 1
|
|
|
|
|
|
class TestThreads:
|
|
def test_requests_from_many_threads_all_land_in_order_per_thread(self, wired):
|
|
controller, manager = wired
|
|
seen = []
|
|
controller._activate_on_demand = MagicMock(
|
|
side_effect=lambda r: seen.append(r['mode']))
|
|
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 10_000
|
|
threads_n, each = 8, 50
|
|
barrier = threading.Barrier(threads_n)
|
|
|
|
def ask(n):
|
|
plugin = _plugin(f'p{n}', manager)
|
|
barrier.wait()
|
|
for i in range(each):
|
|
assert plugin.request_on_demand(mode=f'{n}:{i}')
|
|
|
|
threads = [threading.Thread(target=ask, args=(n,)) for n in range(threads_n)]
|
|
for t in threads:
|
|
t.start()
|
|
# Drain while they ask, as the render thread would.
|
|
while any(t.is_alive() for t in threads):
|
|
controller._drain_control_commands()
|
|
for t in threads:
|
|
t.join()
|
|
controller._drain_control_commands()
|
|
assert len(seen) == threads_n * each
|
|
for n in range(threads_n):
|
|
mine = [int(m.split(':')[1]) for m in seen if m.startswith(f'{n}:')]
|
|
assert mine == list(range(each))
|
|
|
|
def test_a_full_queue_refuses(self, wired, caplog):
|
|
controller, manager = wired
|
|
controller.PLUGIN_ON_DEMAND_QUEUE_SIZE = 2
|
|
plugin = _plugin('p', manager)
|
|
assert plugin.request_on_demand()
|
|
assert plugin.request_on_demand()
|
|
assert plugin.request_on_demand() is None
|
|
assert 'queue full' in caplog.text
|
|
controller._poll_on_demand_requests()
|
|
assert controller._activate_on_demand.call_count == 2
|
|
assert plugin.request_on_demand() # room again
|
|
|
|
|
|
class TestStop:
|
|
def test_a_plugin_ends_its_own_session(self, wired):
|
|
controller, manager = wired
|
|
controller.on_demand_active = True
|
|
controller.on_demand_plugin_id = 'on-air'
|
|
controller._clear_on_demand = MagicMock()
|
|
assert _plugin('on-air', manager).end_on_demand()
|
|
controller._poll_on_demand_requests()
|
|
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
|
controller.cache_manager.delete.assert_not_called()
|
|
|
|
def test_a_plugin_cannot_end_another_plugins_session(self, wired):
|
|
controller, manager = wired
|
|
controller.on_demand_active = True
|
|
controller.on_demand_plugin_id = 'clock' # the user started it
|
|
controller.on_demand_request_id = 'user'
|
|
controller._clear_on_demand = MagicMock()
|
|
_plugin('pomodoro-timer', manager).end_on_demand()
|
|
controller._poll_on_demand_requests()
|
|
controller._clear_on_demand.assert_not_called()
|
|
assert controller.on_demand_request_id == 'user'
|
|
|
|
def test_a_stop_with_no_session_does_nothing(self, wired):
|
|
controller, manager = wired
|
|
controller.on_demand_status = 'error'
|
|
controller._clear_on_demand = MagicMock()
|
|
_plugin('on-air', manager).end_on_demand()
|
|
controller._poll_on_demand_requests()
|
|
controller._clear_on_demand.assert_not_called()
|
|
|
|
def test_a_socket_stop_still_ends_any_session(self, wired):
|
|
controller, _ = wired
|
|
controller.on_demand_active = True
|
|
controller.on_demand_plugin_id = 'clock'
|
|
controller._clear_on_demand = MagicMock()
|
|
controller._handle_on_demand_request({'request_id': 's', 'action': 'stop',
|
|
'source': 'socket'})
|
|
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
|
|
|
def test_start_then_stop_from_one_thread_ends_the_session(self, wired):
|
|
controller, manager = wired
|
|
|
|
def activate(request):
|
|
controller.on_demand_active = True
|
|
controller.on_demand_plugin_id = request['plugin_id']
|
|
|
|
controller._activate_on_demand = MagicMock(side_effect=activate)
|
|
controller._clear_on_demand = MagicMock()
|
|
plugin = _plugin('pomodoro-timer', manager)
|
|
plugin.request_on_demand(mode='pomodoro', pinned=True)
|
|
plugin.end_on_demand()
|
|
controller._poll_on_demand_requests()
|
|
controller._activate_on_demand.assert_called_once()
|
|
controller._clear_on_demand.assert_called_once_with(reason='requested-stop')
|
|
|
|
|
|
class TestNoDisplay:
|
|
"""None: no display in this process took the request."""
|
|
|
|
def test_a_manager_with_no_handler_answers_none(self):
|
|
plugin = _plugin('p', _manager())
|
|
assert plugin.request_on_demand() is None
|
|
assert plugin.end_on_demand() is None
|
|
|
|
def test_no_plugin_manager_answers_none(self):
|
|
plugin = _plugin('p', None)
|
|
assert plugin.request_on_demand() is None
|
|
assert plugin.end_on_demand() is None
|
|
|
|
def test_an_old_cores_plugin_manager_answers_none(self):
|
|
class OldManager:
|
|
plugin_manifests = {}
|
|
|
|
plugin = _plugin('p', OldManager())
|
|
assert plugin.request_on_demand() is None
|
|
assert plugin.end_on_demand() is None
|
|
|
|
def test_a_handler_that_raises_answers_none(self):
|
|
def broken(request):
|
|
raise RuntimeError('boom')
|
|
|
|
plugin = _plugin('p', _manager(broken))
|
|
assert plugin.request_on_demand() is None
|
|
assert plugin.end_on_demand() is None
|
|
|
|
def test_a_handler_that_refuses_answers_none(self):
|
|
plugin = _plugin('p', _manager(lambda request: False))
|
|
assert plugin.request_on_demand() is None
|
|
|
|
def test_a_controller_built_without_init_refuses(self):
|
|
from src.display_controller import DisplayController
|
|
bare = DisplayController.__new__(DisplayController)
|
|
assert bare.submit_plugin_on_demand({'action': 'start'}) is False
|
|
assert bare._plugin_on_demand_pending() is False
|
|
bare._drain_plugin_on_demand() # nothing to do, no error
|
|
|
|
def test_a_mailbox_write_after_none_is_dropped_with_a_warning(self, tmp_path,
|
|
monkeypatch, caplog):
|
|
"""What a plugin written for older cores does on None now: its
|
|
fallback write to the retired key stores nothing, and the log names
|
|
it once."""
|
|
from src import cache_manager as cache_module
|
|
from src.cache_manager import CacheManager
|
|
monkeypatch.setattr(CacheManager, '_get_writable_cache_dir',
|
|
lambda self: str(tmp_path))
|
|
monkeypatch.setattr(cache_module, '_retired_writers_warned', set())
|
|
cache = CacheManager()
|
|
try:
|
|
plugin = _plugin('birdnet-go', _manager())
|
|
plugin.cache_manager = cache
|
|
caplog.set_level(logging.WARNING)
|
|
for _ in range(2):
|
|
if plugin.request_on_demand(mode='m') is None:
|
|
plugin.cache_manager.set('display_on_demand_request', {
|
|
'request_id': 'r', 'action': 'start', 'plugin_id': 'birdnet-go'})
|
|
assert cache.get('display_on_demand_request', max_age=None, memory_ttl=0) is None
|
|
lines = [r.getMessage() for r in caplog.records if 'retired' in r.getMessage()]
|
|
assert len(lines) == 1 and "plugin 'birdnet-go'" in lines[0]
|
|
finally:
|
|
cache.stop_cleanup_thread()
|
|
|
|
|
|
class TestArguments:
|
|
def test_the_manager_shapes_the_request(self):
|
|
got = []
|
|
plugin = _plugin('p', _manager(lambda r: got.append(r) or True))
|
|
plugin.request_on_demand()
|
|
plugin.end_on_demand()
|
|
start, stop = got
|
|
assert start['plugin_id'] == 'p' and start['mode'] is None
|
|
assert start['duration'] is None and start['pinned'] is False
|
|
assert start['source'] == 'plugin' and start['timestamp'] > 0
|
|
assert stop == {'action': 'stop', 'plugin_id': 'p', 'request_id': stop['request_id'],
|
|
'timestamp': stop['timestamp'], 'source': 'plugin'}
|
|
assert start['request_id'] != stop['request_id']
|
|
|
|
@pytest.mark.parametrize('duration', [0, -5, float('inf'), float('nan')])
|
|
def test_no_positive_duration_means_no_limit(self, duration):
|
|
got = []
|
|
_plugin('p', _manager(lambda r: got.append(r) or True)).request_on_demand(
|
|
duration=duration)
|
|
assert got[0]['duration'] is None
|
|
|
|
@pytest.mark.parametrize('kwargs', [{'mode': 5}, {'mode': ''}, {'duration': '30'},
|
|
{'duration': True}])
|
|
def test_bad_arguments_raise(self, kwargs):
|
|
plugin = _plugin('p', _manager(lambda r: True))
|
|
with pytest.raises(ValueError):
|
|
plugin.request_on_demand(**kwargs)
|
|
|
|
|
|
class TestMockManagers:
|
|
def test_a_magicmock_manager_reads_as_not_taken(self):
|
|
"""A plugin's test with a MagicMock manager reads as "not taken"."""
|
|
plugin = _plugin('p', MagicMock())
|
|
assert plugin.request_on_demand(mode='m') is None
|
|
assert plugin.end_on_demand() is None
|
|
plugin.plugin_manager.request_on_demand.assert_called_once_with(
|
|
'p', mode='m', duration=None, pinned=False)
|
|
plugin.plugin_manager.end_on_demand.assert_called_once_with('p')
|
|
|
|
def test_a_mocked_id_is_passed_through(self):
|
|
manager = MagicMock()
|
|
manager.request_on_demand.return_value = 'rid'
|
|
manager.end_on_demand.return_value = 'rid2'
|
|
plugin = _plugin('p', manager)
|
|
assert plugin.request_on_demand() == 'rid'
|
|
assert plugin.end_on_demand() == 'rid2'
|