Files
LEDMatrix/test/test_ipc_contract.py
T
ChuckandClaude Opus 5.5 af8dc3940a feat(ipc): display control socket, stage 1: on-demand with acks
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>
2026-09-30 21:28:31 -04:00

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