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>
276 lines
11 KiB
Python
276 lines
11 KiB
Python
"""The control socket's contract (src/ipc/contract.py): messages and framing.
|
|
|
|
Pure data, so every test here runs on every platform. What they pin:
|
|
|
|
* a request and a response survive encode -> decode -> parse unchanged, and
|
|
the on-demand arguments carry exactly what the file mailbox carries;
|
|
* the envelope and the arguments refuse what the display could not act on
|
|
(missing ids, wrong types, a non-finite duration) with a stable error code;
|
|
* framing never holds more than one message's worth of bytes, however the
|
|
bytes arrive;
|
|
* where the socket is looked for, and how it is switched off.
|
|
"""
|
|
|
|
import json
|
|
import math
|
|
|
|
import pytest
|
|
|
|
from src.ipc import contract as c
|
|
from src.ipc.contract import (
|
|
Command, ErrorCode, FrameReader, OnDemandStartArgs, OnDemandStopArgs,
|
|
ProtocolError, Request, Response,
|
|
)
|
|
|
|
|
|
def _wire(obj):
|
|
"""Encode then decode, as one side's bytes reach the other."""
|
|
data = c.encode_message(obj)
|
|
assert data.endswith(b'\n') and data.count(b'\n') == 1
|
|
return c.decode_message(data)
|
|
|
|
|
|
class TestRoundTrip:
|
|
def test_request(self):
|
|
req = Request(id='abc-1', cmd=Command.ON_DEMAND_START,
|
|
args={'plugin_id': 'clock', 'mode': None, 'duration': 30.0,
|
|
'pinned': True})
|
|
back = Request.from_dict(_wire(req.to_dict()))
|
|
assert back == req
|
|
assert back.v == c.PROTOCOL_VERSION
|
|
|
|
def test_success_response(self):
|
|
resp = Response.success('abc-1', {'accepted': True, 'request_id': 'abc-1', 'queued': 1})
|
|
back = Response.from_dict(_wire(resp.to_dict()))
|
|
assert back == resp
|
|
assert back.ok and back.error is None
|
|
|
|
def test_failure_response(self):
|
|
resp = Response.failure('abc-1', ErrorCode.BUSY, 'queue full')
|
|
wire = _wire(resp.to_dict())
|
|
assert wire == {'v': 1, 'id': 'abc-1', 'ok': False,
|
|
'error': {'code': 'busy', 'message': 'queue full'}}
|
|
assert Response.from_dict(wire) == resp
|
|
|
|
def test_failure_without_an_id(self):
|
|
wire = _wire(Response.failure(None, ErrorCode.BAD_JSON, 'nope').to_dict())
|
|
assert wire['id'] is None
|
|
assert Response.from_dict(wire).id is None
|
|
|
|
def test_start_args_round_trip(self):
|
|
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=45.0,
|
|
pinned=True)
|
|
assert OnDemandStartArgs.from_dict(_wire(args.to_dict())) == args
|
|
|
|
def test_encoded_messages_are_ascii_single_lines(self):
|
|
data = c.encode_message({'v': 1, 'id': 'x', 'cmd': 'ping',
|
|
'args': {'text': 'line1\nline2 café'}})
|
|
assert data.count(b'\n') == 1
|
|
data.decode('ascii')
|
|
assert c.decode_message(data)['args']['text'] == 'line1\nline2 café'
|
|
|
|
|
|
class TestEnvelopeValidation:
|
|
@pytest.mark.parametrize('obj', [
|
|
[], 'x', 1, None,
|
|
])
|
|
def test_not_an_object(self, obj):
|
|
with pytest.raises(ProtocolError) as e:
|
|
Request.from_dict(obj)
|
|
assert e.value.code == ErrorCode.BAD_REQUEST
|
|
|
|
@pytest.mark.parametrize('bad_id', [None, '', 7, 'x' * (c.MAX_ID_LENGTH + 1), 'a\nb'])
|
|
def test_bad_id(self, bad_id):
|
|
with pytest.raises(ProtocolError) as e:
|
|
Request.from_dict({'v': 1, 'id': bad_id, 'cmd': 'ping'})
|
|
assert e.value.code == ErrorCode.BAD_REQUEST
|
|
assert e.value.request_id is None
|
|
|
|
@pytest.mark.parametrize('v', [None, '1', 1.0, True])
|
|
def test_bad_version_type_keeps_the_id(self, v):
|
|
with pytest.raises(ProtocolError) as e:
|
|
Request.from_dict({'v': v, 'id': 'r1', 'cmd': 'ping'})
|
|
assert e.value.code == ErrorCode.BAD_REQUEST
|
|
assert e.value.request_id == 'r1'
|
|
|
|
def test_missing_cmd(self):
|
|
with pytest.raises(ProtocolError) as e:
|
|
Request.from_dict({'v': 1, 'id': 'r1'})
|
|
assert e.value.code == ErrorCode.BAD_REQUEST
|
|
|
|
def test_args_default_to_empty(self):
|
|
assert Request.from_dict({'v': 1, 'id': 'r', 'cmd': 'ping'}).args == {}
|
|
assert Request.from_dict({'v': 1, 'id': 'r', 'cmd': 'ping', 'args': None}).args == {}
|
|
|
|
def test_args_must_be_an_object(self):
|
|
with pytest.raises(ProtocolError) as e:
|
|
Request.from_dict({'v': 1, 'id': 'r', 'cmd': 'ping', 'args': [1]})
|
|
assert e.value.code == ErrorCode.BAD_REQUEST
|
|
|
|
def test_an_unknown_version_parses(self):
|
|
# The server, not the parser, decides about versions, so that hello
|
|
# can negotiate.
|
|
assert Request.from_dict({'v': 99, 'id': 'r', 'cmd': 'hello'}).v == 99
|
|
|
|
@pytest.mark.parametrize('obj', [
|
|
{'v': 1, 'id': 'r', 'ok': 'yes'},
|
|
{'v': 1, 'id': 'r', 'ok': False},
|
|
{'v': 1, 'id': 'r', 'ok': False, 'error': {'message': 'x'}},
|
|
{'v': 1, 'id': 5, 'ok': True},
|
|
{'v': 1, 'id': 'r', 'ok': True, 'result': [1]},
|
|
{'id': 'r', 'ok': True},
|
|
])
|
|
def test_malformed_responses(self, obj):
|
|
with pytest.raises(ProtocolError):
|
|
Response.from_dict(obj)
|
|
|
|
|
|
class TestOnDemandArgs:
|
|
def test_plugin_or_mode_is_required(self):
|
|
with pytest.raises(ProtocolError) as e:
|
|
OnDemandStartArgs.from_dict({'duration': 10})
|
|
assert e.value.code == ErrorCode.INVALID_ARGS
|
|
|
|
def test_mode_alone_is_enough(self):
|
|
assert OnDemandStartArgs.from_dict({'mode': 'nfl_live'}).mode == 'nfl_live'
|
|
|
|
@pytest.mark.parametrize('raw, seconds', [
|
|
(None, None), ('', None), (0, None), (45, 45.0), (2.5, 2.5), ('30', 30.0),
|
|
])
|
|
def test_duration(self, raw, seconds):
|
|
assert OnDemandStartArgs.from_dict({'plugin_id': 'p', 'duration': raw}).duration == seconds
|
|
|
|
@pytest.mark.parametrize('raw', [-1, 'soon', True, [5], math.inf, math.nan, 'inf'])
|
|
def test_bad_duration(self, raw):
|
|
with pytest.raises(ProtocolError) as e:
|
|
OnDemandStartArgs.from_dict({'plugin_id': 'p', 'duration': raw})
|
|
assert e.value.code == ErrorCode.INVALID_ARGS
|
|
|
|
@pytest.mark.parametrize('pinned', ['true', 1, 'false'])
|
|
def test_pinned_must_be_a_real_boolean(self, pinned):
|
|
# The web route coerces "false" to False before it gets here; the
|
|
# contract does not guess (bool("false") is True).
|
|
with pytest.raises(ProtocolError):
|
|
OnDemandStartArgs.from_dict({'plugin_id': 'p', 'pinned': pinned})
|
|
|
|
@pytest.mark.parametrize('name', [5, 'x' * (c.MAX_NAME_LENGTH + 1), 'a\nb'])
|
|
def test_bad_names(self, name):
|
|
with pytest.raises(ProtocolError):
|
|
OnDemandStartArgs.from_dict({'plugin_id': name})
|
|
|
|
def test_unknown_command(self):
|
|
with pytest.raises(ProtocolError) as e:
|
|
c.parse_args('reboot', {})
|
|
assert e.value.code == ErrorCode.UNKNOWN_COMMAND
|
|
|
|
@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 {}
|
|
c.parse_args(cmd, args)
|
|
|
|
def test_hello_versions(self):
|
|
assert c.HelloArgs.from_dict({'versions': [1, 2], 'client': 'web'}).versions == (1, 2)
|
|
for bad in ([], ['1'], 'x', [True]):
|
|
with pytest.raises(ProtocolError):
|
|
c.HelloArgs.from_dict({'versions': bad})
|
|
|
|
def test_negotiation(self):
|
|
assert c.negotiate_version((1,)) == 1
|
|
assert c.negotiate_version((1, 7)) == 1
|
|
assert c.negotiate_version((7,)) is None
|
|
|
|
|
|
class TestMailboxShape:
|
|
"""Socket commands are handed to the mailbox's own handler, so they must
|
|
look exactly like what the web route writes to the mailbox."""
|
|
|
|
def test_start(self):
|
|
args = OnDemandStartArgs(plugin_id='clock', mode='clock_main', duration=60.0,
|
|
pinned=True)
|
|
payload = c.on_demand_request('rid', args, 123.0)
|
|
assert payload == {'request_id': 'rid', 'action': 'start', 'plugin_id': 'clock',
|
|
'mode': 'clock_main', 'duration': 60.0, 'pinned': True,
|
|
'timestamp': 123.0, 'source': 'socket'}
|
|
|
|
def test_stop(self):
|
|
payload = c.on_demand_request('rid', OnDemandStopArgs(), 5.0)
|
|
assert payload['action'] == 'stop' and payload['request_id'] == 'rid'
|
|
|
|
|
|
class TestFraming:
|
|
def test_one_message_in_pieces(self):
|
|
data = c.encode_message({'v': 1, 'id': 'a', 'cmd': 'ping'})
|
|
reader = FrameReader()
|
|
out = []
|
|
for i in range(len(data)):
|
|
out += reader.feed(data[i:i + 1])
|
|
assert [json.loads(x) for x in out] == [{'v': 1, 'id': 'a', 'cmd': 'ping'}]
|
|
assert reader.pending == 0
|
|
|
|
def test_several_messages_in_one_chunk(self):
|
|
data = b''.join(c.encode_message({'n': n}) for n in range(3))
|
|
assert [json.loads(x)['n'] for x in FrameReader().feed(data)] == [0, 1, 2]
|
|
|
|
def test_blank_lines_are_skipped(self):
|
|
assert FrameReader().feed(b'\n\r\n \n{"a":1}\n') == [b'{"a":1}']
|
|
|
|
def test_a_line_over_the_limit_is_refused(self):
|
|
reader = FrameReader(max_bytes=32)
|
|
with pytest.raises(ProtocolError) as e:
|
|
reader.feed(b'x' * 40 + b'\n')
|
|
assert e.value.code == ErrorCode.MESSAGE_TOO_LARGE
|
|
|
|
def test_a_line_that_never_ends_is_refused_at_the_limit(self):
|
|
reader = FrameReader(max_bytes=32)
|
|
reader.feed(b'x' * 31)
|
|
with pytest.raises(ProtocolError) as e:
|
|
reader.feed(b'x')
|
|
assert e.value.code == ErrorCode.MESSAGE_TOO_LARGE
|
|
|
|
def test_exactly_the_limit_is_allowed(self):
|
|
reader = FrameReader(max_bytes=8)
|
|
assert reader.feed(b'1234567\n') == [b'1234567']
|
|
|
|
def test_encode_refuses_an_oversize_message(self):
|
|
with pytest.raises(ProtocolError) as e:
|
|
c.encode_message({'blob': 'x' * c.MAX_MESSAGE_BYTES})
|
|
assert e.value.code == ErrorCode.MESSAGE_TOO_LARGE
|
|
|
|
def test_encode_refuses_non_json(self):
|
|
with pytest.raises(ProtocolError):
|
|
c.encode_message({'x': math.nan})
|
|
with pytest.raises(ProtocolError):
|
|
c.encode_message({'x': object()})
|
|
|
|
@pytest.mark.parametrize('line', [b'{', b'[1,2]', b'"x"', b'\xff\xfe', b'null'])
|
|
def test_decode_garbage(self, line):
|
|
with pytest.raises(ProtocolError) as e:
|
|
c.decode_message(line)
|
|
assert e.value.code == ErrorCode.BAD_JSON
|
|
|
|
|
|
class TestSocketLocation:
|
|
def test_default(self):
|
|
paths = c.client_socket_paths({})
|
|
assert paths[0] == '/run/ledmatrix/control.sock'
|
|
assert len(paths) == 2 and paths[1].endswith('control.sock')
|
|
|
|
def test_configured_path_is_the_only_one_tried(self):
|
|
assert c.client_socket_paths({c.SOCKET_PATH_ENV: '/x/y.sock'}) == ['/x/y.sock']
|
|
|
|
@pytest.mark.parametrize('value', ['off', 'OFF', '0', 'false', 'disabled', ' none '])
|
|
def test_switched_off(self, value):
|
|
env = {c.SOCKET_PATH_ENV: value}
|
|
assert c.socket_disabled(env)
|
|
assert c.client_socket_paths(env) == []
|
|
assert c.configured_socket_path(env) is None
|
|
|
|
def test_dev_path_is_per_user(self):
|
|
assert c.dev_socket_path(1000) != c.dev_socket_path(1001)
|
|
assert 'ledmatrix-1000' in c.dev_socket_path(1000)
|
|
|
|
def test_the_default_dir_is_the_heartbeats(self):
|
|
# One RuntimeDirectory= serves both (#687).
|
|
from src import display_watchdog
|
|
assert c.DEFAULT_SOCKET_DIR == display_watchdog.HEARTBEAT_DIR
|