mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
The display process now serves a Unix socket, /run/ledmatrix/control.sock, carrying versioned newline-delimited JSON commands that are acknowledged. Stage 1 moves on-demand start/stop (plus status, hello and ping) onto it; the cache-file mailbox stays as the fallback for one release. - src/ipc/contract.py: typed request/response envelopes, command args, error codes, NDJSON framing with a 64 KiB limit, socket path rules. - src/ipc/server.py: threaded server owned by the display. Handlers only queue onto a bounded queue and ack with the request id; the render thread drains it where it reads the mailbox. Bounded clients, timeouts, garbage/oversize/disconnect handling; 0660 socket in the cache dir's group plus SO_PEERCRED checks; skips cleanly on Windows or when off. - src/ipc/client.py: one short-timeout request; any failure raises ControlError(reason). - api_v3/display.py: on-demand start/stop try the socket, fall back to the mailbox exactly as before, and report transport/socket_error. - display_controller.py: start/close the server; the mailbox handler body is extracted into _handle_on_demand_request and shared by both paths. - docs/IPC_CONTROL_SOCKET.md: protocol, security model, stage plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
8.6 KiB
Python
193 lines
8.6 KiB
Python
"""POST /display/on-demand/start and /stop: control socket first, mailbox fallback.
|
|
|
|
The routes hand the request to the display over the control socket
|
|
(src/ipc) and get an acknowledgement. On any failure -- no socket (a stopped
|
|
display, or one older than the socket), a timeout, a refusal, a bug in the
|
|
client -- they write the file mailbox exactly as they did before the socket
|
|
existed. These tests pin both paths, that exactly one of them is used, that
|
|
the response says which, and that the request id is the same either way (the
|
|
display deduplicates on it).
|
|
|
|
The socket client is patched at the route's module attribute; the last class
|
|
runs a real server on a temp socket (Linux/macOS only).
|
|
"""
|
|
|
|
import os
|
|
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
|
|
from src.ipc import contract as c # noqa: E402
|
|
|
|
START_URL = "/api/v3/display/on-demand/start"
|
|
STOP_URL = "/api/v3/display/on-demand/stop"
|
|
MAILBOX = "display_on_demand_request"
|
|
CLIENT = "web_interface.blueprints.api_v3.display.control_client"
|
|
|
|
|
|
@pytest.fixture
|
|
def service(api_v3_module):
|
|
"""A running display service; records systemctl calls and mailbox writes."""
|
|
api_v3_module.api_v3.plugin_catalog = None
|
|
api_v3_module.api_v3.config_manager = None
|
|
state = {"active": True}
|
|
calls = []
|
|
|
|
def status():
|
|
return {"active": state["active"]}
|
|
|
|
def systemctl(args):
|
|
calls.append(("systemctl", args[-2]))
|
|
if args[-2:] == ["start", "ledmatrix.service"]:
|
|
state["active"] = True
|
|
return {"returncode": 0, "stdout": "", "stderr": ""}
|
|
|
|
cache = api_v3_module.api_v3.cache_manager
|
|
cache.set.side_effect = lambda key, value, *a, **kw: calls.append(("cache", key))
|
|
with patch("web_interface.blueprints.api_v3._get_display_service_status",
|
|
side_effect=status), \
|
|
patch("web_interface.blueprints.api_v3.display._get_display_service_status",
|
|
side_effect=status), \
|
|
patch("web_interface.blueprints.api_v3._run_systemctl_command",
|
|
side_effect=systemctl), \
|
|
patch("web_interface.blueprints.api_v3.display._stop_display_service"):
|
|
yield {"state": state, "cache": cache, "calls": calls}
|
|
|
|
|
|
def _mailbox_writes(cache):
|
|
return [call.args[1] for call in cache.set.call_args_list
|
|
if call.args and call.args[0] == MAILBOX]
|
|
|
|
|
|
def _ack(request_id, *a, **kw):
|
|
return {"accepted": True, "request_id": request_id, "queued": 1}
|
|
|
|
|
|
class TestSocketPath:
|
|
def test_start_goes_over_the_socket_and_skips_the_mailbox(self, api_v3_client, service):
|
|
with patch(f"{CLIENT}.on_demand_start", side_effect=_ack) as start:
|
|
resp = api_v3_client.post(START_URL, json={
|
|
"plugin_id": "weather", "mode": "weather_current",
|
|
"duration": 60, "pinned": True})
|
|
assert resp.status_code == 200, resp.get_json()
|
|
data = resp.get_json()["data"]
|
|
assert data["transport"] == "socket"
|
|
assert "socket_error" not in data
|
|
assert _mailbox_writes(service["cache"]) == []
|
|
start.assert_called_once_with(data["request_id"], "weather", "weather_current", 60, True)
|
|
|
|
def test_a_callers_request_id_is_passed_through(self, api_v3_client, service):
|
|
with patch(f"{CLIENT}.on_demand_start", side_effect=_ack) as start:
|
|
data = api_v3_client.post(START_URL, json={
|
|
"plugin_id": "weather", "request_id": "ha-123"}).get_json()["data"]
|
|
assert data["request_id"] == "ha-123"
|
|
assert start.call_args.args[0] == "ha-123"
|
|
|
|
def test_stop_goes_over_the_socket(self, api_v3_client, service):
|
|
with patch(f"{CLIENT}.on_demand_stop", side_effect=_ack) as stop:
|
|
data = api_v3_client.post(STOP_URL, json={}).get_json()["data"]
|
|
assert data["transport"] == "socket"
|
|
stop.assert_called_once_with(data["request_id"])
|
|
assert _mailbox_writes(service["cache"]) == []
|
|
|
|
|
|
class TestMailboxFallback:
|
|
@pytest.mark.parametrize("reason", [
|
|
"no_socket", "refused", "timeout", "closed", "bad_response", "invalid_request",
|
|
"busy", "unknown_command", "unsupported_version", "disabled", "unsupported",
|
|
])
|
|
def test_any_socket_failure_writes_the_mailbox_as_before(
|
|
self, api_v3_client, service, reason):
|
|
with patch(f"{CLIENT}.on_demand_start",
|
|
side_effect=control_client.ControlError(reason, "x")):
|
|
resp = api_v3_client.post(START_URL, json={
|
|
"plugin_id": "weather", "mode": "weather_current",
|
|
"duration": 60, "pinned": True})
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()["data"]
|
|
assert data["transport"] == "mailbox"
|
|
assert data["socket_error"] == reason
|
|
[write] = _mailbox_writes(service["cache"])
|
|
assert write["request_id"] == data["request_id"]
|
|
assert write["action"] == "start"
|
|
assert (write["plugin_id"], write["mode"], write["duration"], write["pinned"]) == \
|
|
("weather", "weather_current", 60, True)
|
|
|
|
def test_a_client_bug_still_falls_back(self, api_v3_client, service):
|
|
with patch(f"{CLIENT}.on_demand_start", side_effect=RuntimeError("boom")):
|
|
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
|
assert resp.status_code == 200
|
|
assert resp.get_json()["data"]["socket_error"] == "internal"
|
|
assert len(_mailbox_writes(service["cache"])) == 1
|
|
|
|
def test_stop_falls_back(self, api_v3_client, service):
|
|
with patch(f"{CLIENT}.on_demand_stop",
|
|
side_effect=control_client.ControlError("timeout")):
|
|
data = api_v3_client.post(STOP_URL, json={}).get_json()["data"]
|
|
assert data["transport"] == "mailbox"
|
|
[write] = _mailbox_writes(service["cache"])
|
|
assert write == {"request_id": data["request_id"], "action": "stop",
|
|
"timestamp": write["timestamp"]}
|
|
|
|
def test_a_stopped_display_gets_the_mailbox_before_it_is_started(
|
|
self, api_v3_client, service):
|
|
service["state"]["active"] = False
|
|
with patch(f"{CLIENT}.on_demand_start",
|
|
side_effect=control_client.ControlError("no_socket")):
|
|
resp = api_v3_client.post(START_URL, json={"plugin_id": "weather"})
|
|
assert resp.status_code == 200
|
|
assert service["calls"] == [("cache", MAILBOX), ("systemctl", "start")]
|
|
|
|
def test_the_socket_is_off_in_the_test_suite(self, api_v3_client, service):
|
|
# conftest's _hermetic_control_socket: a suite run on a device must
|
|
# not drive the live display.
|
|
assert os.environ[c.SOCKET_PATH_ENV] == "off"
|
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
|
assert data["transport"] == "mailbox"
|
|
assert data["socket_error"] in ("disabled", "unsupported") # Linux, Windows
|
|
|
|
|
|
@pytest.mark.skipif(not c.socket_supported(), reason="AF_UNIX sockets are Linux/macOS only")
|
|
class TestRealSocket:
|
|
@pytest.fixture
|
|
def live(self, monkeypatch):
|
|
import shutil
|
|
import tempfile
|
|
from src.ipc.server import ControlServer
|
|
d = tempfile.mkdtemp(prefix="lmipc-")
|
|
path = os.path.join(d, "control.sock")
|
|
server = ControlServer(path, status_provider=dict)
|
|
assert server.start()
|
|
monkeypatch.setenv(c.SOCKET_PATH_ENV, path)
|
|
yield server
|
|
server.close()
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
def test_start_is_acked_and_queued(self, api_v3_client, service, live):
|
|
data = api_v3_client.post(START_URL, json={
|
|
"plugin_id": "weather", "duration": "30"}).get_json()["data"]
|
|
assert data["transport"] == "socket"
|
|
[cmd] = live.drain()
|
|
payload = cmd.as_on_demand_request()
|
|
assert payload["request_id"] == data["request_id"]
|
|
assert payload["plugin_id"] == "weather" and payload["duration"] == 30.0
|
|
assert _mailbox_writes(service["cache"]) == []
|
|
|
|
def test_stop_is_acked_and_queued(self, api_v3_client, service, live):
|
|
data = api_v3_client.post(STOP_URL, json={}).get_json()["data"]
|
|
assert data["transport"] == "socket"
|
|
assert [x.request_id for x in live.drain()] == [data["request_id"]]
|
|
|
|
def test_a_display_that_went_away_falls_back(self, api_v3_client, service, live):
|
|
live.close()
|
|
data = api_v3_client.post(START_URL, json={"plugin_id": "weather"}).get_json()["data"]
|
|
assert data["transport"] == "mailbox" and data["socket_error"] == "no_socket"
|
|
assert len(_mailbox_writes(service["cache"])) == 1
|