feat(ipc): control socket stage 2 - wake the render thread, brightness.set, plugin.reload (#720)

Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 20:37:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f72d69c2b0
commit 746dcfcadb
22 changed files with 1998 additions and 119 deletions
+69 -5
View File
@@ -391,8 +391,9 @@ class FakeVegas:
run_iteration() renders frames at 125 Hz on the fake clock for
``cycle`` seconds and returns True, or returns False as soon as the
interrupt checker (every 10 frames) or the live-priority checker (every
0.25 s) asks it to yield -- the same cadence the real coordinator uses.
interrupt checker (every 10 frames, or at the next frame when the
``urgent`` test says so) or the live-priority checker (every 0.25 s)
asks it to yield -- the same cadence the real coordinator uses.
A live-priority pause is lifted by the next call, as in the real one.
"""
@@ -408,14 +409,16 @@ class FakeVegas:
self.vegas_config = SimpleNamespace(live_in_ticker=live_in_ticker)
self.render_pipeline = None
self._interrupt: Optional[Callable[[], bool]] = None
self._urgent: Optional[Callable[[], bool]] = None
self._live: Optional[Callable[[], Any]] = None
self._paused_for_live = False
def set_live_priority_checker(self, fn):
self._live = fn
def set_interrupt_checker(self, fn, check_interval=10):
def set_interrupt_checker(self, fn, check_interval=10, urgent=None):
self._interrupt = fn
self._urgent = urgent
def apply_pending_config_if_idle(self):
pass
@@ -444,13 +447,68 @@ class FakeVegas:
h.log("vegas-frame", None, quiet=True)
clock.sleep(self.FRAME)
frames += 1
if self._interrupt and frames % self.INTERRUPT_EVERY == 0 and self._interrupt():
due = frames % self.INTERRUPT_EVERY == 0 or (self._urgent and self._urgent())
if self._interrupt and due and self._interrupt():
h.log("vegas-interrupt")
return False
if clock.now - start >= self.cycle:
return True
class FakeControlServer:
"""The ControlServer surface run() uses (src/ipc/server.py), on the fake clock.
``post()`` queues a real QueuedCommand when the clock reaches ``t``, as a
connection thread would. ``wait_for_command`` advances the clock to the
first of: a command being queued, or the timeout -- the timed Event wait
the real server does, without threads.
"""
def __init__(self, harness: "RunLoopHarness"):
self._h = harness
self.queue: List[Any] = []
self.posted: List[Any] = []
self.closed = False
@property
def has_pending(self) -> bool:
return bool(self.queue)
def drain(self) -> List[Any]:
out, self.queue = self.queue, []
return out
def wait_for_command(self, timeout: float) -> bool:
clock = self._h.clock
target = clock.now + max(0.0, timeout)
while not self.queue:
nxt = clock._alarms[0][0] if clock._alarms else None
if nxt is None or nxt > target:
clock.sleep(target - clock.now)
return bool(self.queue)
clock.sleep(max(0.0, nxt - clock.now))
return True
def post(self, t: float, cmd: str, args: Dict[str, Any], request_id: str = "sock"):
from src.ipc.contract import AWAITED_COMMANDS, parse_args
from src.ipc.server import CommandOutcome, QueuedCommand
command = QueuedCommand(
request_id=request_id, cmd=cmd, args=parse_args(cmd, args), # type: ignore[arg-type]
received_at=0.0,
outcome=CommandOutcome() if cmd in AWAITED_COMMANDS else None)
self.posted.append(command)
def enqueue():
self._h.log("socket", cmd)
self.queue.append(command)
self._h.clock.at(t, enqueue)
return command
def close(self):
self.closed = True
# ---------------------------------------------------------------------------
# Harness
# ---------------------------------------------------------------------------
@@ -669,6 +727,12 @@ class RunLoopHarness:
encoding="utf-8")
self.clock.at(t, write)
def control_socket(self) -> FakeControlServer:
"""Serve the control socket (a FakeControlServer) for this run."""
server = FakeControlServer(self)
self.controller._control_server = server
return server
def enable_vegas(self, cycle: float = 30.0, live_in_ticker: bool = False) -> FakeVegas:
"""Install FakeVegas, wired up as _initialize_vegas_mode wires the real one."""
dc = self.controller
@@ -676,7 +740,7 @@ class RunLoopHarness:
vegas.set_live_priority_checker(dc._check_live_priority)
vegas.set_interrupt_checker(
lambda: dc._check_vegas_interrupt() or dc.sync_manager.is_follower_active(),
check_interval=10)
check_interval=10, urgent=dc._control_command_pending)
dc.vegas_coordinator = vegas
return vegas
+108
View File
@@ -0,0 +1,108 @@
"""POST /api/v3/config/main with a brightness: on the panel at once.
The saved brightness used to reach the panel when the display's config
watcher next noticed config.json (it polls every 2 s) and the render thread
then applied it (every 0.25 s). After a successful save the route now also
sends ``brightness.set`` over the control socket (src/ipc), which the
display applies on its render thread at once. When the socket cannot carry
it, nothing changes: the watcher applies the saved value as before. The
response says which (``brightness_transport``), and why
(``brightness_socket_error``).
"""
import json
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
from src.ipc import client as control_client # noqa: E402
SET = "web_interface.blueprints.api_v3.control_client.brightness_set"
@pytest.fixture
def saved(api_v3_module, monkeypatch):
captured = {'ok': True}
api_v3_module.api_v3.config_manager.load_config.return_value = {}
def fake_save(_manager, config, **_kwargs):
captured['config'] = config
return (True, '') if captured['ok'] else (False, 'disk full')
monkeypatch.setattr(api_v3_module, '_save_config_atomic', fake_save)
return captured
def _post(client, body):
return client.post('/api/v3/config/main', data=json.dumps(body),
content_type='application/json')
@pytest.mark.parametrize('value', [55, '55'], ids=['number', 'form-string'])
def test_a_saved_brightness_goes_over_the_socket(api_v3_client, saved, value):
with patch(SET, return_value={'brightness': 55, 'panel_brightness': 55,
'dimmed': False, 'display_active': True}) as send:
resp = _post(api_v3_client, {'brightness': value})
assert resp.status_code == 200, resp.get_json()
send.assert_called_once_with(55)
body = resp.get_json()
assert body['brightness_transport'] == 'socket'
assert 'brightness_socket_error' not in body
# Saved as well: the socket's value is transient on the display.
assert saved['config']['display']['hardware']['brightness'] == 55
@pytest.mark.parametrize('reason', ['no_socket', 'unknown_command', 'pending', 'failed',
'timeout', 'busy'])
def test_without_the_socket_the_config_watcher_applies_it(api_v3_client, saved, reason):
with patch(SET, side_effect=control_client.ControlError(reason, 'x')):
resp = _post(api_v3_client, {'brightness': 40})
assert resp.status_code == 200
body = resp.get_json()
assert body['brightness_transport'] == 'config'
assert body['brightness_socket_error'] == reason
assert saved['config']['display']['hardware']['brightness'] == 40
def test_a_client_bug_never_fails_the_save(api_v3_client, saved):
with patch(SET, side_effect=RuntimeError('bug')):
resp = _post(api_v3_client, {'brightness': 40})
assert resp.status_code == 200
assert resp.get_json()['brightness_socket_error'] == 'internal'
def test_the_test_suite_has_no_socket(api_v3_client, saved):
"""LEDMATRIX_CONTROL_SOCKET=off (conftest): the real client says so."""
body = _post(api_v3_client, {'brightness': 40}).get_json()
assert body['brightness_transport'] == 'config'
assert body['brightness_socket_error'] in ('disabled', 'unsupported')
def test_a_save_without_a_brightness_sends_nothing(api_v3_client, saved):
with patch(SET) as send:
resp = _post(api_v3_client, {'rows': 32})
assert resp.status_code == 200
send.assert_not_called()
assert 'brightness_transport' not in resp.get_json()
@pytest.mark.parametrize('body', [{'brightness': 0}, {'brightness': 101}, {'brightness': 'x'}])
def test_a_refused_brightness_is_not_sent(api_v3_client, saved, body):
with patch(SET) as send:
resp = _post(api_v3_client, body)
assert resp.status_code == 400
send.assert_not_called()
def test_a_failed_save_is_not_sent(api_v3_client, saved):
saved['ok'] = False
with patch(SET) as send:
resp = _post(api_v3_client, {'brightness': 40})
assert resp.status_code == 500
send.assert_not_called()
+3 -1
View File
@@ -165,7 +165,9 @@ class TestOnDemandArgs:
@pytest.mark.parametrize('cmd', c.COMMANDS)
def test_every_command_has_an_argument_type(self, cmd):
args = {'plugin_id': 'p'} if cmd == Command.ON_DEMAND_START else {}
args = {Command.ON_DEMAND_START: {'plugin_id': 'p'},
Command.PLUGIN_RELOAD: {'plugin_id': 'p'},
Command.BRIGHTNESS_SET: {'brightness': 50}}.get(cmd, {})
c.parse_args(cmd, args)
def test_hello_versions(self):
+368
View File
@@ -0,0 +1,368 @@
"""DisplayController's side of control socket stage 2.
* ``brightness.set`` is applied on the render thread at once, repainted,
and answered with what the panel shows -- including when the dim schedule
holds it lower, when the panel refuses it, and when the schedule has the
display off;
* ``plugin.reload`` is answered from the top of the loop pass, for every
outcome: reloaded (new instance registered, Vegas told), not running,
loaded only for on-demand, failed to load, or raised;
* the render thread wakes for a command in real time, not just on the fake
clock (test_run_loop_socket_wake.py): within milliseconds on a static
screen and in a dwell, while a command that does not end the screen
leaves the frame wait to run its course;
* Vegas checks the queue every frame and runs its interrupt check at once.
"""
import threading
import time
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from src.ipc.contract import BrightnessSetArgs, Command, ErrorCode, PluginReloadArgs
from src.ipc.server import CommandOutcome, ControlServer, QueuedCommand
def _command(cmd, args):
return QueuedCommand(request_id='t1', cmd=cmd, args=args, received_at=time.time(),
outcome=CommandOutcome())
def _brightness(value):
return _command(Command.BRIGHTNESS_SET, BrightnessSetArgs(value))
def _reload(plugin_id):
return _command(Command.PLUGIN_RELOAD, PluginReloadArgs(plugin_id))
@pytest.fixture
def dc(test_display_controller):
c = test_display_controller
c.display_manager.set_brightness = MagicMock(return_value=True)
c.display_manager.update_display = MagicMock()
c.config = {'timezone': 'UTC', 'display': {'hardware': {'brightness': 90}}}
c._normal_brightness = 90
c.current_brightness = 90
c.is_display_active = True
c._tz = None
return c
class TestBrightness:
def test_applied_now_and_repainted(self, dc):
cmd = _brightness(40)
dc._apply_control_brightness(cmd)
dc.display_manager.set_brightness.assert_called_once_with(40)
dc.display_manager.update_display.assert_called()
assert dc.current_brightness == 40
assert cmd.outcome.result == {'brightness': 40, 'panel_brightness': 40,
'dimmed': False, 'display_active': True}
def test_the_dim_schedule_still_applies(self, dc):
now = datetime.now(timezone.utc)
dc.config['dim_schedule'] = {
'enabled': True, 'dim_brightness': 20,
'start_time': (now - timedelta(hours=1)).strftime('%H:%M'),
'end_time': (now + timedelta(hours=1)).strftime('%H:%M')}
# A stale per-minute answer from before the change must not stick.
dc._dim_checked_minute = (now.hour, now.minute)
dc._cached_target_brightness = 90
cmd = _brightness(70)
dc._apply_control_brightness(cmd)
assert cmd.outcome.result == {'brightness': 70, 'panel_brightness': 20,
'dimmed': True, 'display_active': True}
assert dc._normal_brightness == 70
def test_a_refused_brightness_is_reported(self, dc):
dc.display_manager.set_brightness.return_value = False
cmd = _brightness(40)
dc._apply_control_brightness(cmd)
assert cmd.outcome.error_code == ErrorCode.FAILED
assert dc.current_brightness == 90
def test_scheduled_off_keeps_it_for_later(self, dc):
dc.is_display_active = False
cmd = _brightness(40)
dc._apply_control_brightness(cmd)
dc.display_manager.set_brightness.assert_not_called()
assert cmd.outcome.result['display_active'] is False
assert cmd.outcome.result['panel_brightness'] == 90
# When the schedule turns the display back on, the new level is used.
dc.is_display_active = True
dc._apply_brightness_target()
dc.display_manager.set_brightness.assert_called_once_with(40)
def test_the_next_config_reload_wins(self, dc):
dc._apply_control_brightness(_brightness(40))
dc._refresh_config_cache({'display': {'hardware': {'brightness': 75}}})
assert dc._normal_brightness == 75
class FakeServer:
def __init__(self, *commands):
self.commands = list(commands)
@property
def has_pending(self):
return bool(self.commands)
def drain(self):
out, self.commands = self.commands, []
return out
def close(self):
pass
@pytest.fixture
def running(dc):
"""dc running 'clock' (two modes) and 'weather', with a reloadable manager."""
old = SimpleNamespace(modes=['clock_a', 'clock_b'])
new = SimpleNamespace(modes=['clock_a', 'clock_b'])
weather = SimpleNamespace(modes=['weather'])
pm = MagicMock()
pm.plugins = {'clock': old, 'weather': weather}
pm.plugin_manifests = {'clock': {'version': '1.0.0'}, 'weather': {}}
pm.get_plugin = lambda pid: pm.plugins.get(pid)
calls = []
def unload(pid):
calls.append(('unload', pid))
pm.plugins.pop(pid, None)
return True
def reload(pid):
calls.append(('reload', pid))
pm.plugins[pid] = new
pm.plugin_manifests[pid] = {'version': '2.0.0'}
return True
pm.unload_plugin = MagicMock(side_effect=unload)
pm.reload_plugin = MagicMock(side_effect=reload)
dc.plugin_manager = pm
dc.plugin_display_modes = {'clock': ['clock_a', 'clock_b'], 'weather': ['weather']}
dc.available_modes = ['clock_a', 'clock_b', 'weather']
dc.plugin_modes = {'clock_a': old, 'clock_b': old, 'weather': weather}
dc.mode_to_plugin_id = {'clock_a': 'clock', 'clock_b': 'clock', 'weather': 'weather'}
dc.current_mode_index = 2
dc.current_display_mode = 'weather'
dc.vegas_coordinator = MagicMock()
return SimpleNamespace(dc=dc, pm=pm, old=old, new=new, calls=calls)
class TestReload:
def _run(self, dc, *commands):
dc._control_server = FakeServer(*commands)
dc._poll_on_demand_requests() # the drain: queued, not applied
assert dc._plugin_reload_pending
dc._apply_pending_plugin_reloads() # the top of the next pass
assert not dc._plugin_reload_pending
def test_reloaded(self, running):
dc = running.dc
cmd = _reload('clock')
self._run(dc, cmd)
assert running.calls == [('unload', 'clock'), ('reload', 'clock')]
assert cmd.outcome.result == {'plugin_id': 'clock', 'reloaded': True,
'version': '2.0.0', 'modes': ['clock_a', 'clock_b']}
assert dc.plugin_modes['clock_a'] is running.new
# Its modes keep their place in the rotation.
assert dc.available_modes == ['clock_a', 'clock_b', 'weather']
# The screen that was showing is still the current one.
assert dc.current_display_mode == 'weather'
assert dc.available_modes[dc.current_mode_index] == 'weather'
dc.vegas_coordinator.mark_plugin_updated.assert_called_once_with('clock')
def test_not_running(self, running):
cmd = _reload('nope')
self._run(running.dc, cmd)
assert cmd.outcome.error_code == ErrorCode.NOT_LOADED
assert running.calls == []
def test_loaded_only_for_on_demand(self, running):
running.dc._on_demand_loaded_plugins = {'clock'}
cmd = _reload('clock')
self._run(running.dc, cmd)
assert cmd.outcome.error_code == ErrorCode.BUSY
assert running.calls == []
def test_does_not_load_again(self, running):
running.pm.reload_plugin = MagicMock(return_value=False)
cmd = _reload('clock')
self._run(running.dc, cmd)
assert cmd.outcome.error_code == ErrorCode.FAILED
assert running.dc.available_modes == ['weather']
assert 'clock' not in running.dc.plugin_display_modes
def test_raises(self, running):
running.pm.reload_plugin = MagicMock(side_effect=RuntimeError('boom'))
cmd = _reload('clock')
self._run(running.dc, cmd)
assert cmd.outcome.error_code == ErrorCode.INTERNAL
def test_a_pending_reload_ends_vegas_and_the_dwell(self, running):
dc = running.dc
dc._pending_plugin_reloads = (_reload('clock'),)
dc._check_wifi_status_message = MagicMock(return_value=None)
dc._service_pending_changes = MagicMock()
assert dc._check_vegas_interrupt() is True
started = time.monotonic()
dc._sleep_with_plugin_updates(5)
assert time.monotonic() - started < 0.1
def test_a_failing_brightness_does_not_stop_the_rest(self, running):
dc = running.dc
bad = _brightness(40)
dc._apply_control_brightness = MagicMock(side_effect=RuntimeError('x'))
good = _reload('clock')
self._run(dc, bad, good)
assert bad.outcome.error_code == ErrorCode.INTERNAL
assert good.outcome.result['reloaded'] is True
class TestRealTimeWake:
"""With a real ControlServer and real threads: what the fake clock tests
show, measured. A connection thread queues the command with
handle_line(), as it does for a socket client."""
@pytest.fixture
def server(self, dc, tmp_path):
server = ControlServer(str(tmp_path / 'unused.sock'))
dc._control_server = server
dc.current_display_mode = 'clock'
dc.on_demand_active = False
dc._activate_on_demand = MagicMock(
side_effect=lambda request: setattr(dc, 'current_display_mode', 'weather'))
dc._wifi_notice_pending = MagicMock(return_value=False)
dc._tick_plugin_updates = MagicMock()
dc._check_live_takeover = MagicMock()
dc.cache_manager.get = MagicMock(return_value=None)
dc.cache_manager.set = MagicMock()
return server
@staticmethod
def _post_later(server, line, delay, stamps):
def run():
time.sleep(delay)
stamps.append(time.monotonic())
server.handle_line(line)
t = threading.Thread(target=run, daemon=True)
t.start()
return t
@staticmethod
def _start_line(rid):
import json
return json.dumps({'v': 1, 'id': rid, 'cmd': Command.ON_DEMAND_START,
'args': {'plugin_id': 'weather'}}).encode()
def test_static_screen(self, dc, server):
latencies = []
for i in range(10):
dc.current_display_mode = 'clock'
stamps = []
t = self._post_later(server, self._start_line(f's{i}'), 0.05, stamps)
ended = dc._wait_frame_interval(1.0, 'clock')
woke = time.monotonic()
t.join()
assert ended is True
latencies.append(woke - stamps[0])
latencies.sort()
print(f"static-screen wake latency: median {latencies[5] * 1000:.2f} ms, "
f"max {latencies[-1] * 1000:.2f} ms")
assert latencies[-1] < 0.05, latencies
def test_dwell(self, dc, server):
latencies = []
for i in range(10):
dc.current_display_mode = 'clock'
stamps = []
t = self._post_later(server, self._start_line(f'd{i}'), 0.05, stamps)
dc._sleep_with_plugin_updates(5.0)
woke = time.monotonic()
t.join()
assert dc.current_display_mode == 'weather'
latencies.append(woke - stamps[0])
latencies.sort()
print(f"dwell wake latency: median {latencies[5] * 1000:.2f} ms, "
f"max {latencies[-1] * 1000:.2f} ms")
assert latencies[-1] < 0.05, latencies
def test_a_brightness_does_not_cut_the_frame_wait_short(self, dc, server):
import json
stamps = []
line = json.dumps({'v': 1, 'id': 'b1', 'cmd': Command.BRIGHTNESS_SET,
'args': {'brightness': 33}}).encode()
started = time.monotonic()
t = self._post_later(server, line, 0.1, stamps)
assert dc._wait_frame_interval(0.5, 'clock') is False
assert time.monotonic() - started >= 0.49
t.join()
dc.display_manager.set_brightness.assert_called_once_with(33)
def test_without_a_socket_it_is_a_plain_sleep(self, dc):
dc._control_server = None
started = time.monotonic()
assert dc._wait_frame_interval(0.2, 'clock') is False
assert time.monotonic() - started >= 0.19
class TestVegasChecksEveryFrame:
def _coordinator(self, urgent_from):
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.coordinator import VegasModeCoordinator
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 60, 'continuous_scroll': True}}})
coord.render_pipeline = MagicMock()
coord.render_pipeline.frame_interval = 0.0
coord.render_pipeline.target_fps = 90
coord.display_manager = MagicMock()
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
coord._state_lock = threading.Lock()
coord._is_active = True
coord._is_paused = False
coord._should_stop = False
coord._fps_last_health_log = 0.0
coord._fps_was_degraded = False
coord._live_priority_check = None
coord._live_priority_active = False
coord._update_callback = None
coord._update_tick_running = False
coord._check_static_plugin_trigger = lambda: None
frames = [0]
def run_frame():
frames[0] += 1
return True
coord.run_frame = run_frame
checked_at = []
def checker():
checked_at.append(frames[0])
return frames[0] >= urgent_from
coord.set_interrupt_checker(checker, check_interval=10,
urgent=lambda: frames[0] >= urgent_from)
return coord, checked_at
def test_an_urgent_frame_runs_the_check_at_once(self):
coord, checked_at = self._coordinator(urgent_from=3)
assert coord.run_iteration() is False
assert checked_at == [3]
def test_without_urgency_the_interval_holds(self):
coord, checked_at = self._coordinator(urgent_from=25)
coord._interrupt_urgent = None
assert coord.run_iteration() is False
assert checked_at == [10, 20, 30]
def test_a_raising_urgency_test_is_ignored(self):
coord, checked_at = self._coordinator(urgent_from=10)
coord._interrupt_urgent = MagicMock(side_effect=RuntimeError('x'))
assert coord.run_iteration() is False
assert checked_at == [10]
+299
View File
@@ -0,0 +1,299 @@
"""Control socket stage 2 (src/ipc): waking the render thread, and the
awaited commands ``brightness.set`` and ``plugin.reload``.
What these pin, on every platform unless marked:
* the new commands' arguments refuse what the display could not act on;
* an awaited command is answered with the render thread's outcome -- its
result, its error code, or ``pending`` when the render thread did not get
to it in time (the command stays queued and is still applied);
* ``wait_for_command`` returns as soon as a command is queued, and otherwise
sleeps for the whole timeout without spinning;
* over a real socket (Linux/macOS), the client waits for that outcome and
maps each error code to its ``ControlError`` reason.
"""
import json
import os
import threading
import time
import pytest
from src.ipc import client
from src.ipc import contract as c
from src.ipc.contract import (
BrightnessSetArgs, Command, ErrorCode, PluginReloadArgs, ProtocolError,
)
from src.ipc.server import CommandOutcome, ControlServer
needs_unix_sockets = pytest.mark.skipif(not c.socket_supported(),
reason='AF_UNIX sockets are Linux/macOS only')
def _req(cmd, args=None, rid='r1'):
return json.dumps({'v': 1, 'id': rid, 'cmd': cmd, 'args': args or {}}).encode()
def _server(tmp_path, **kw):
kw.setdefault('await_seconds', {Command.BRIGHTNESS_SET: 2.0, Command.PLUGIN_RELOAD: 2.0})
return ControlServer(str(tmp_path / 'unused.sock'), **kw)
class RenderThread:
"""Stands in for the display's render thread: waits on the server the
way DisplayController does and settles each command with ``handler``."""
def __init__(self, server, handler):
self.server = server
self.handler = handler
self.stop = threading.Event()
self.woke_at = []
self.thread = threading.Thread(target=self._run, daemon=True)
def _run(self):
while not self.stop.is_set():
if self.server.wait_for_command(0.05):
self.woke_at.append(time.monotonic())
for command in self.server.drain():
self.handler(command)
def __enter__(self):
self.thread.start()
return self
def __exit__(self, *exc):
self.stop.set()
self.thread.join(2)
class TestArguments:
@pytest.mark.parametrize('value', [0, 1, 55, 100])
def test_brightness_in_range(self, value):
assert BrightnessSetArgs.from_dict({'brightness': value}).brightness == value
@pytest.mark.parametrize('value', [-1, 101, 50.0, '50', True, None, [50]])
def test_brightness_refused(self, value):
with pytest.raises(ProtocolError) as e:
BrightnessSetArgs.from_dict({'brightness': value})
assert e.value.code == ErrorCode.INVALID_ARGS
def test_reload_needs_a_plugin_id(self):
assert PluginReloadArgs.from_dict({'plugin_id': 'clock'}).plugin_id == 'clock'
for bad in ({}, {'plugin_id': ''}, {'plugin_id': 5}, {'plugin_id': 'x' * 200},
{'plugin_id': 'a\nb'}):
with pytest.raises(ProtocolError) as e:
PluginReloadArgs.from_dict(bad)
assert e.value.code == ErrorCode.INVALID_ARGS
def test_the_new_commands_are_queued_and_awaited(self):
for cmd in (Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD):
assert cmd in c.COMMANDS and cmd in c.QUEUED_COMMANDS and cmd in c.AWAITED_COMMANDS
# On-demand stays an ack: its outcome is published, not awaited.
assert Command.ON_DEMAND_START not in c.AWAITED_COMMANDS
# Additive within version 1 (see the contract's docstring).
assert c.SUPPORTED_VERSIONS == (1,)
def test_the_client_outwaits_the_display(self):
"""The client's timeout must cover the display's wait, or a slow
reload would be reported as a timeout instead of ``pending``."""
for cmd in c.AWAITED_COMMANDS:
assert client._awaited_timeout(cmd) > c.AWAIT_SECONDS[cmd]
class TestOutcome:
def test_first_settlement_wins(self):
outcome = CommandOutcome()
outcome.succeed({'a': 1})
outcome.fail(ErrorCode.FAILED, 'late')
assert outcome.done and outcome.result == {'a': 1} and outcome.error_code is None
def test_wait_times_out(self):
assert CommandOutcome().wait(0.01) is False
class TestAwaitedCommands:
def test_brightness_answers_with_the_render_thread_result(self, tmp_path):
server = _server(tmp_path)
def apply(command):
assert command.cmd == Command.BRIGHTNESS_SET
command.succeed({'brightness': command.args.brightness, 'panel_brightness': 40,
'dimmed': False, 'display_active': True})
with RenderThread(server, apply):
resp = server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 40}))
assert resp.ok, resp.error
assert resp.result == {'brightness': 40, 'panel_brightness': 40,
'dimmed': False, 'display_active': True}
@pytest.mark.parametrize('code', [ErrorCode.NOT_LOADED, ErrorCode.FAILED, ErrorCode.BUSY])
def test_a_failure_carries_its_code(self, tmp_path, code):
server = _server(tmp_path)
with RenderThread(server, lambda command: command.fail(code, 'no')):
resp = server.handle_line(_req(Command.PLUGIN_RELOAD, {'plugin_id': 'clock'}))
assert not resp.ok and resp.error.code == code and resp.id == 'r1'
def test_no_render_thread_means_pending_and_the_command_stays_queued(self, tmp_path):
server = _server(tmp_path, await_seconds={Command.PLUGIN_RELOAD: 0.05})
started = time.monotonic()
resp = server.handle_line(_req(Command.PLUGIN_RELOAD, {'plugin_id': 'clock'}))
assert time.monotonic() - started < 1.0
assert not resp.ok and resp.error.code == ErrorCode.PENDING
# Still applied when the render thread gets there; nobody reads it.
queued = server.drain()
assert [(q.cmd, q.args.plugin_id) for q in queued] == [(Command.PLUGIN_RELOAD, 'clock')]
queued[0].succeed({'reloaded': True})
def test_bad_arguments_are_refused_before_queueing(self, tmp_path):
server = _server(tmp_path)
resp = server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 400}))
assert not resp.ok and resp.error.code == ErrorCode.INVALID_ARGS
assert not server.has_pending and server.drain() == []
def test_a_full_queue_is_busy_without_waiting(self, tmp_path):
server = _server(tmp_path, queue_size=1)
server.handle_line(_req(Command.ON_DEMAND_STOP, rid='fill'))
started = time.monotonic()
resp = server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 10}))
assert not resp.ok and resp.error.code == ErrorCode.BUSY
assert time.monotonic() - started < 0.5
def test_on_demand_is_still_acked_without_waiting(self, tmp_path):
server = _server(tmp_path)
resp = server.handle_line(_req(Command.ON_DEMAND_STOP))
assert resp.ok and resp.result['accepted'] is True
(queued,) = server.drain()
assert queued.outcome is None
queued.succeed({}) # a no-op for an acked command
def test_an_on_demand_payload_is_only_built_for_on_demand(self, tmp_path):
server = _server(tmp_path, await_seconds={Command.BRIGHTNESS_SET: 0.01})
server.handle_line(_req(Command.BRIGHTNESS_SET, {'brightness': 10}))
(queued,) = server.drain()
with pytest.raises(TypeError):
queued.as_on_demand_request()
class TestWake:
"""``wait_for_command`` is what the render thread waits on in place of a
sleep: it must return as soon as a command is queued, and not before."""
def test_returns_at_once_when_a_command_is_queued(self, tmp_path):
server = _server(tmp_path)
latencies = []
for _ in range(20):
queued_at = []
def post():
time.sleep(0.02)
queued_at.append(time.monotonic())
server.handle_line(_req(Command.ON_DEMAND_STOP))
t = threading.Thread(target=post)
t.start()
assert server.wait_for_command(2.0) is True
woke = time.monotonic()
t.join()
latencies.append(woke - queued_at[0])
server.drain()
latencies.sort()
# Typically well under a millisecond; 50 ms is the budget for a
# static screen, and a loaded CI box needs the slack.
assert latencies[len(latencies) // 2] < 0.01, latencies
assert latencies[-1] < 0.05, latencies
def test_sleeps_the_whole_timeout_when_nothing_comes(self, tmp_path):
server = _server(tmp_path)
started = time.monotonic()
cpu = time.process_time()
assert server.wait_for_command(0.3) is False
assert time.monotonic() - started >= 0.29
# A timed wait, not a polling loop.
assert time.process_time() - cpu < 0.05
def test_drain_rearms_it(self, tmp_path):
server = _server(tmp_path)
server.handle_line(_req(Command.ON_DEMAND_STOP))
assert server.wait_for_command(0) is True
server.drain()
assert server.wait_for_command(0.01) is False
# -- the real socket ------------------------------------------------------------------
@pytest.fixture
def sock_path():
import shutil
import tempfile
d = tempfile.mkdtemp(prefix='lmipc2-')
yield os.path.join(d, 'control.sock')
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture
def live(sock_path):
servers = []
def make(**kwargs):
s = ControlServer(sock_path, status_provider=lambda: {}, **kwargs)
assert s.start()
servers.append(s)
return s
yield make
for s in servers:
s.close()
@needs_unix_sockets
class TestOverTheSocket:
def test_brightness_round_trip(self, live, sock_path):
server = live()
def apply(command):
command.succeed({'brightness': command.args.brightness, 'panel_brightness': 30,
'dimmed': True, 'display_active': True})
with RenderThread(server, apply):
started = time.monotonic()
result = client.brightness_set(70, paths=[sock_path])
elapsed = time.monotonic() - started
assert result == {'brightness': 70, 'panel_brightness': 30, 'dimmed': True,
'display_active': True}
assert elapsed < 0.5, elapsed
def test_reload_round_trip(self, live, sock_path):
server = live()
def apply(command):
command.succeed({'plugin_id': command.args.plugin_id, 'reloaded': True,
'version': '2.0.0', 'modes': ['clock']})
with RenderThread(server, apply):
result = client.plugin_reload('clock', paths=[sock_path])
assert result['reloaded'] is True and result['version'] == '2.0.0'
@pytest.mark.parametrize('code', [ErrorCode.NOT_LOADED, ErrorCode.FAILED])
def test_reload_errors_reach_the_client(self, live, sock_path, code):
server = live()
with RenderThread(server, lambda command: command.fail(code, 'x')):
with pytest.raises(client.ControlError) as e:
client.plugin_reload('clock', paths=[sock_path])
assert e.value.reason == code
def test_pending_reaches_the_client_before_its_own_timeout(self, live, sock_path):
live(await_seconds={Command.PLUGIN_RELOAD: 0.1})
with pytest.raises(client.ControlError) as e:
client.plugin_reload('clock', paths=[sock_path], timeout=2.0)
assert e.value.reason == ErrorCode.PENDING
def test_invalid_arguments_never_leave_the_client(self, sock_path):
with pytest.raises(client.ControlError) as e:
client.brightness_set(500, paths=[sock_path])
assert e.value.reason == 'invalid_request'
def test_hello_lists_the_new_commands(self, live, sock_path):
live()
commands = client.hello(paths=[sock_path])['commands']
assert Command.BRIGHTNESS_SET in commands and Command.PLUGIN_RELOAD in commands
+179
View File
@@ -0,0 +1,179 @@
"""A control socket command wakes the run loop (control socket stage 2).
Stage 1's socket was no faster than the file mailbox: a queued command
waited for the same polls the mailbox does -- the 1 s frame sleep of a static
screen, the 0.25 s dwell tick, and Vegas's interrupt check every 10 frames
(about 0.4 s at the 24 fps a Pi 4 manages). Now the render thread waits on
the socket's queue instead of sleeping, and Vegas checks the queue every
frame, so a command is applied:
* at once on a static screen and in a dwell (here: at the instant it is
queued, on the fake clock);
* at the next frame in Vegas (8 ms here, at 125 fps).
Each test runs the real run() loop on the fake clock of
test/_run_loop_harness.py and compares the socket with the mailbox for the
same request. The latencies are the fake clock's, so they are exact.
"""
import os
import pytest
os.environ.setdefault("EMULATOR", "true")
from src.ipc.contract import Command, ErrorCode # noqa: E402
from test._run_loop_harness import FakePlugin, RunLoopHarness # noqa: E402
POSTED = 10.3
def _event_time(trace, kind):
return next(e[0] for e in trace["events"] if e[1] == kind)
def _on_demand_latency(tmp_path, build, via, horizon=40):
h = RunLoopHarness(tmp_path, horizon=horizon)
build(h)
if via == "socket":
h.control_socket().post(POSTED, Command.ON_DEMAND_START, {"plugin_id": "weather"})
else:
h.on_demand_request(POSTED, "mb1", plugin_id="weather")
trace = h.run()
return round(_event_time(trace, "on-demand-start") - POSTED, 3), trace
def _static(h):
h.add_plugin(FakePlugin("clock", ["clock"], duration=30))
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
def _dwell(h):
# Content on the first frame only: the 1 s loop ends at t=1 and the rest
# of the 30 s is made up in the dwell sleep (_sleep_with_plugin_updates).
h.add_plugin(FakePlugin("clock", ["clock"], duration=30, first_frame_only=True))
h.add_plugin(FakePlugin("weather", ["weather"], duration=30))
def _vegas(h):
_static(h)
h.enable_vegas(cycle=30)
@pytest.mark.parametrize("build, mailbox_latency", [
(_static, 0.7), # the next 1 s frame, at t=11
(_dwell, 0.2), # the next 0.25 s tick, at t=10.5
(_vegas, 0.02), # the next 10-frame check: 80 ms at 125 fps, ~0.4 s on a Pi 4
], ids=["static-screen", "dwell", "vegas"])
def test_a_socket_command_lands_at_once(tmp_path, build, mailbox_latency):
(tmp_path / "s").mkdir()
(tmp_path / "m").mkdir()
socket_latency, trace = _on_demand_latency(tmp_path / "s", build, "socket")
via_mailbox, _ = _on_demand_latency(tmp_path / "m", build, "mailbox")
assert via_mailbox == pytest.approx(mailbox_latency, abs=0.002)
if build is _vegas:
# One 8 ms frame: Vegas checks the queue every frame now.
assert socket_latency <= 0.008
else:
assert socket_latency == 0.0
# And the requested plugin is what the panel shows next, from then on.
row = next(r for r in trace["screens"] if r[1] == "weather")
assert row[0] == pytest.approx(POSTED + socket_latency, abs=0.001)
def test_a_brightness_lands_at_once_without_ending_the_screen(tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
_static(h)
command = h.control_socket().post(POSTED, Command.BRIGHTNESS_SET, {"brightness": 40})
trace = h.run()
assert [e for e in trace["events"] if e[1] == "brightness"] == [[POSTED, "brightness", 40]]
# The clock screen runs its full 30 s and the next starts on time.
assert trace["screens"][0][:4] == [0.0, "clock", 30.0, "duration"]
assert trace["screens"][1][:2] == [30.0, "weather"]
assert command.outcome.done and command.outcome.result["panel_brightness"] == 40
def test_the_static_frame_cadence_is_unchanged_by_a_command(tmp_path):
"""The wait resumes after a command that does not end the screen, so the
plugin is still drawn once a second, not once more at the command."""
h = RunLoopHarness(tmp_path, horizon=31)
_static(h)
quiet = h.run()
(tmp_path / "b").mkdir()
h2 = RunLoopHarness(tmp_path / "b", horizon=31)
_static(h2)
h2.control_socket().post(POSTED, Command.BRIGHTNESS_SET, {"brightness": 40})
busy = h2.run()
assert busy["screens"][0] == quiet["screens"][0]
def _with_reloadable(h, version="2.0.0", loads=True):
new = FakePlugin("clock", ["clock"], duration=30)
def reload_plugin(plugin_id):
h.log("reload", plugin_id)
if not loads:
return False
new._h = h
h.pm.plugins[plugin_id] = new
h.pm.plugin_manifests[plugin_id] = {"version": version, "display_modes": ["clock"]}
return True
h.pm.reload_plugin = reload_plugin
return new
def test_a_plugin_reload_ends_the_screen_and_reloads_before_the_next(tmp_path):
h = RunLoopHarness(tmp_path, horizon=70)
_static(h)
new = _with_reloadable(h)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
trace = h.run()
# Reloaded the moment it was asked for: the clock screen ends then, and
# the rotation moves on.
assert _event_time(trace, "reload") == POSTED
assert trace["screens"][0][:3] == [0.0, "clock", POSTED]
assert trace["screens"][1][:2] == [POSTED, "weather"]
assert command.outcome.result == {"plugin_id": "clock", "reloaded": True,
"version": "2.0.0", "modes": ["clock"]}
# The next clock screen is drawn by the reloaded instance.
assert h.controller.plugin_modes["clock"] is new
assert trace["screens"][2][1] == "clock"
def test_a_plugin_reload_during_vegas_yields_at_the_next_frame(tmp_path):
h = RunLoopHarness(tmp_path, horizon=70)
_vegas(h)
_with_reloadable(h)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
trace = h.run()
assert 0 <= _event_time(trace, "reload") - POSTED <= 0.008
assert command.outcome.result["reloaded"] is True
# The ticker resumes after the reload; no rotation screen in between.
after = [r for r in trace["screens"] if r[0] > POSTED]
assert after and after[0][1] == "<vegas>"
def test_a_plugin_that_does_not_load_again_is_reported(tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
_static(h)
_with_reloadable(h, loads=False)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "clock"})
h.run()
assert command.outcome.error_code == ErrorCode.FAILED
assert "clock" not in h.controller.available_modes
def test_reloading_a_plugin_that_is_not_running(tmp_path):
h = RunLoopHarness(tmp_path, horizon=40)
_static(h)
command = h.control_socket().post(POSTED, Command.PLUGIN_RELOAD, {"plugin_id": "nope"})
trace = h.run()
assert command.outcome.error_code == ErrorCode.NOT_LOADED
# It still cost the clock screen nothing but its end: rotation goes on.
assert [r[1] for r in trace["screens"][:2]] == ["clock", "weather"]
@@ -382,6 +382,95 @@ class TestStoreOperationsReachTheDisplay:
display_restart_required("reinstall", True)
RELOAD = "web_interface.blueprints.api_v3.control_client.plugin_reload"
class TestAnUpdateIsReloadedByTheDisplay:
"""Control socket stage 2: instead of asking for a restart, the update
route asks the running display to reload the plugin (``plugin.reload``),
and only falls back to ``restart_required`` when that does not work. The
web process still runs none of the plugin's code."""
def test_reloaded_over_the_socket(self, web):
from unittest.mock import patch
_bump_version(web, "2.0.0")
with patch(RELOAD, return_value={"plugin_id": PLUGIN_ID, "reloaded": True,
"version": "2.0.0", "modes": ["tripwire"]}) as reload:
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
reload.assert_called_once_with(PLUGIN_ID)
assert body["restart_required"] is False
assert body["reloaded"] is True and body["reloaded_version"] == "2.0.0"
assert "restart_message" not in body and "reload_error" not in body
assert "running the new version" in body["message"]
assert web.ran() == []
@pytest.mark.parametrize("reason", [
"no_socket", # display stopped, or predates the socket
"unknown_command", # a stage-1 display
"not_loaded", "failed", "pending", "busy", "timeout", "refused",
])
def test_any_failure_falls_back_to_a_restart(self, web, reason):
from unittest.mock import patch
from src.ipc import client as control_client
_bump_version(web, "2.0.0")
with patch(RELOAD, side_effect=control_client.ControlError(reason, "x")):
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True
assert "restart the display" in body["restart_message"]
assert body["reload_error"] == reason
assert "reloaded" not in body
def test_a_client_bug_falls_back_too(self, web):
from unittest.mock import patch
_bump_version(web, "2.0.0")
with patch(RELOAD, side_effect=RuntimeError("bug")):
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True and body["reload_error"] == "internal"
def test_without_a_socket_it_is_the_restart_banner_as_before(self, web):
# The test suite runs with LEDMATRIX_CONTROL_SOCKET=off (conftest).
_bump_version(web, "2.0.0")
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is True
# 'unsupported' on Windows, which has no Unix sockets at all.
assert body["reload_error"] in ("disabled", "unsupported")
def test_an_unchanged_plugin_asks_nothing(self, web):
from unittest.mock import patch
with patch(RELOAD) as reload:
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["data"]["update_status"] == "up_to_date"
assert body["restart_required"] is False
reload.assert_not_called()
def test_a_disabled_plugin_asks_nothing(self, disabled_web):
from unittest.mock import patch
_bump_version(disabled_web, "2.0.0")
with patch(RELOAD) as reload:
body = disabled_web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
assert body["restart_required"] is False
reload.assert_not_called()
def test_the_display_is_asked_by_the_manifest_id(self, web):
"""A store id can differ from the id the display runs the plugin
under (a registry alias: weather / ledmatrix-weather). The config
section and the reload both use the manifest's."""
from unittest.mock import patch
manifest_id = "ledmatrix-alias"
_write_plugin(web.plugins_dir, web.log, plugin_id=manifest_id)
web.config_file.write_text(json.dumps({manifest_id: {"enabled": True}}),
encoding="utf-8")
def update(plugin_id):
_write_plugin(web.plugins_dir, web.log, version="2.0.0", plugin_id=manifest_id)
return True
web.api.plugin_store_manager.update_plugin.side_effect = update
with patch(RELOAD, return_value={"reloaded": True, "version": "2.0.0"}) as reload:
body = web.post("/api/v3/plugins/update", {"plugin_id": "alias"})
reload.assert_called_once_with(manifest_id)
assert body["restart_required"] is False
class TestCatalogReadsWhatIsInstalled:
"""Real plugin directories: the repository's plugin-repos/ and fixtures."""