Files
LEDMatrix/test/test_ipc_server.py
ChuckandClaude Opus 5.5 695ff92009 feat(ipc): display control socket, stage 1 - on-demand with acks (#706)
The display serves a control socket (/run/ledmatrix/control.sock) carrying versioned JSON commands, one per line, each answered. Stage 1 covers on-demand start, stop and status; commands are queued on the socket thread and applied on the render thread through the mailbox's own handler, and the web interface falls back to the file mailbox when the socket is unavailable. Protocol and security model: docs/IPC_CONTROL_SOCKET.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:33:02 -04:00

571 lines
23 KiB
Python

"""The display side of the control socket (src/ipc/server.py).
Two layers:
* ``handle_line`` and the permission model are plain functions of their
input, tested on every platform: every request gets exactly one answer,
garbage is answered rather than raised, queued commands are acked with
their request id, and the render thread drains them in order.
* The socket itself (``TestLiveSocket``, ``TestPermissions``) needs AF_UNIX,
so those tests are skipped on Windows and run on Linux (CI, WSL, a Pi): a
real server on a tmp_path socket, driven by the real client and by raw
sockets that misbehave -- garbage, oversize lines, a client that hangs up
mid-message, one that never finishes -- while the server keeps serving.
"""
import json
import os
import socket
import stat
import threading
import time
import pytest
from src.ipc import client
from src.ipc import contract as c
from src.ipc import server as srv
from src.ipc.contract import Command, ErrorCode
from src.ipc.server import ControlServer, PeerCredentials, peer_allowed
needs_unix_sockets = pytest.mark.skipif(not c.socket_supported(),
reason='AF_UNIX sockets are Linux/macOS only')
def _line(obj):
return json.dumps(obj).encode()
def _req(cmd, args=None, rid='r1', v=1):
return _line({'v': v, 'id': rid, 'cmd': cmd, 'args': args or {}})
@pytest.fixture
def status():
return {'on_demand': {'active': False, 'status': 'idle'}, 'current_mode': 'clock'}
@pytest.fixture
def server(status, tmp_path):
"""A server that is never started: handle_line and drain only."""
return ControlServer(str(tmp_path / 'unused.sock'), status_provider=lambda: dict(status),
queue_size=3)
class TestHandleLine:
def test_ping(self, server):
resp = server.handle_line(_req(Command.PING))
assert resp.ok and resp.id == 'r1' and resp.result == {'pong': True}
def test_hello_negotiates(self, server):
resp = server.handle_line(_req(Command.HELLO, {'versions': [1, 5], 'client': 't'}, v=5))
assert resp.ok
assert resp.result['version'] == 1
assert resp.result['commands'] == list(c.COMMANDS)
assert resp.result['max_message_bytes'] == c.MAX_MESSAGE_BYTES
assert resp.v == 1
def test_hello_with_nothing_in_common(self, server):
resp = server.handle_line(_req(Command.HELLO, {'versions': [9]}, v=9))
assert not resp.ok and resp.error.code == ErrorCode.UNSUPPORTED_VERSION
def test_other_commands_need_a_supported_version(self, server):
resp = server.handle_line(_req(Command.PING, v=2))
assert not resp.ok and resp.error.code == ErrorCode.UNSUPPORTED_VERSION
assert resp.id == 'r1'
@pytest.mark.parametrize('line, code, rid', [
(b'not json', ErrorCode.BAD_JSON, None),
(b'[1]', ErrorCode.BAD_JSON, None),
(b'\xff', ErrorCode.BAD_JSON, None),
(_line({'v': 1, 'cmd': 'ping'}), ErrorCode.BAD_REQUEST, None),
(_line({'v': 'one', 'id': 'q', 'cmd': 'ping'}), ErrorCode.BAD_REQUEST, 'q'),
(_req('shutdown_the_pi'), ErrorCode.UNKNOWN_COMMAND, 'r1'),
(_req(Command.ON_DEMAND_START, {}), ErrorCode.INVALID_ARGS, 'r1'),
(_req(Command.ON_DEMAND_START, {'plugin_id': 'p', 'duration': 'x'}),
ErrorCode.INVALID_ARGS, 'r1'),
])
def test_garbage_is_answered_not_raised(self, server, line, code, rid):
resp = server.handle_line(line)
assert not resp.ok
assert resp.error.code == code
assert resp.id == rid
assert server.drain() == []
def test_start_is_queued_and_acked(self, server):
resp = server.handle_line(_req(Command.ON_DEMAND_START,
{'plugin_id': 'clock', 'duration': 30, 'pinned': True},
rid='abc'))
assert resp.ok
assert resp.result == {'accepted': True, 'request_id': 'abc', 'queued': 1}
assert server.has_pending
[cmd] = server.drain()
assert not server.has_pending
payload = cmd.as_on_demand_request()
assert payload['request_id'] == 'abc'
assert payload['action'] == 'start'
assert payload['plugin_id'] == 'clock'
assert payload['duration'] == 30.0 and payload['pinned'] is True
def test_stop_is_queued_and_acked(self, server):
resp = server.handle_line(_req(Command.ON_DEMAND_STOP, rid='s1'))
assert resp.ok and resp.result['request_id'] == 's1'
assert [x.as_on_demand_request()['action'] for x in server.drain()] == ['stop']
def test_drain_keeps_arrival_order(self, server):
for rid in ('a', 'b', 'c'):
server.handle_line(_req(Command.ON_DEMAND_START, {'plugin_id': 'p'}, rid=rid))
assert [x.request_id for x in server.drain()] == ['a', 'b', 'c']
assert server.drain() == []
def test_a_full_queue_says_busy_and_queues_nothing_more(self, server):
for rid in ('a', 'b', 'c'):
assert server.handle_line(_req(Command.ON_DEMAND_STOP, rid=rid)).ok
resp = server.handle_line(_req(Command.ON_DEMAND_STOP, rid='d'))
assert not resp.ok and resp.error.code == ErrorCode.BUSY and resp.id == 'd'
assert [x.request_id for x in server.drain()] == ['a', 'b', 'c']
def test_status_answers_from_the_provider_without_queueing(self, server, status):
status['on_demand']['active'] = True
resp = server.handle_line(_req(Command.ON_DEMAND_STATUS))
assert resp.ok and resp.result['on_demand']['active'] is True
assert not server.has_pending
def test_a_failing_status_provider_is_an_internal_error(self, tmp_path):
def boom():
raise RuntimeError('render thread mid-update')
s = ControlServer(str(tmp_path / 'x.sock'), status_provider=boom)
resp = s.handle_line(_req(Command.ON_DEMAND_STATUS, rid='z'))
assert not resp.ok and resp.error.code == ErrorCode.INTERNAL and resp.id == 'z'
class TestPermissionModel:
"""root, the display's own user, or the shared group -- nobody else."""
OWN, GROUP = 0, 990
@pytest.mark.parametrize('cred, groups, allowed', [
(PeerCredentials(1, 0, 0), None, True), # root
(PeerCredentials(1, 1000, 1000), frozenset({990}), True), # web user, in group
(PeerCredentials(1, 1000, 990), frozenset(), True), # primary group
(PeerCredentials(1, 1001, 1001), frozenset({27, 44}), False),
(PeerCredentials(1, 65534, 65534), frozenset(), False), # nobody
])
def test_model(self, cred, groups, allowed):
assert peer_allowed(cred, self.OWN, self.GROUP, groups) is allowed
def test_own_user_without_a_group(self):
assert peer_allowed(PeerCredentials(1, 1000, 1000), 1000, None, frozenset())
assert not peer_allowed(PeerCredentials(1, 1001, 1001), 1000, None, frozenset({1}))
def test_group_database_decides_when_proc_is_unreadable(self):
seen = []
def in_group(uid, gid):
seen.append((uid, gid))
return uid == 1000
cred = PeerCredentials(1, 1000, 1000)
assert peer_allowed(cred, 0, 990, None, in_group=in_group)
assert not peer_allowed(PeerCredentials(1, 1001, 1001), 0, 990, None, in_group=in_group)
assert seen == [(1000, 990), (1001, 990)]
@pytest.mark.skipif(os.name != 'posix', reason='POSIX permission bits')
def test_socket_group_follows_a_shared_cache_dir(self, tmp_path):
shared = tmp_path / 'cache'
shared.mkdir()
os.chmod(shared, 0o2775)
assert srv.resolve_socket_group(str(shared)) == shared.stat().st_gid
@pytest.mark.skipif(os.name != 'posix', reason='POSIX permission bits')
def test_a_private_cache_dir_falls_back_to_the_project_group(self, tmp_path, monkeypatch):
private = tmp_path / 'cache'
private.mkdir()
os.chmod(private, 0o755)
from src.common import permission_utils
monkeypatch.setattr(permission_utils, 'get_shared_group_gid', lambda: 4242)
assert srv.resolve_socket_group(str(private)) == 4242
def test_mode_is_group_only_with_a_group(self, tmp_path):
assert ControlServer(str(tmp_path / 'a'), group=990).socket_mode == 0o660
assert ControlServer(str(tmp_path / 'b'), group=None).socket_mode == 0o600
class TestWhereTheServerListens:
def test_off_means_no_server(self):
assert srv.server_socket_path({c.SOCKET_PATH_ENV: 'off'}) is None
assert srv.start_control_server(environ={c.SOCKET_PATH_ENV: 'off'}) is None
@needs_unix_sockets
def test_configured(self):
assert srv.server_socket_path({c.SOCKET_PATH_ENV: '/tmp/x.sock'}) == '/tmp/x.sock'
@needs_unix_sockets
def test_unprivileged_dev_run_uses_the_per_user_path(self, monkeypatch):
monkeypatch.setattr(os, 'geteuid', lambda: 1000)
monkeypatch.setattr(os, 'access', lambda p, m: False)
assert srv.server_socket_path({}) == c.dev_socket_path()
@needs_unix_sockets
def test_root_uses_run(self, monkeypatch):
monkeypatch.setattr(os, 'geteuid', lambda: 0)
assert srv.server_socket_path({}) == c.DEFAULT_SOCKET_PATH
@pytest.mark.skipif(c.socket_supported(), reason='Windows only')
def test_windows_skips_cleanly(self):
assert srv.server_socket_path({}) is None
assert ControlServer('x.sock').start() is False
with pytest.raises(client.ControlError) as e:
client.ping(paths=['x.sock'])
assert e.value.reason == 'unsupported'
# -- the real socket --------------------------------------------------------------------
@pytest.fixture
def sock_path(tmp_path_factory):
# AF_UNIX paths are limited to ~107 bytes; pytest's tmp_path can be longer.
import tempfile
d = tempfile.mkdtemp(prefix='lmipc-')
yield os.path.join(d, 'control.sock')
import shutil
shutil.rmtree(d, ignore_errors=True)
@pytest.fixture
def live(sock_path, status):
servers = []
def make(**kwargs):
kwargs.setdefault('status_provider', lambda: dict(status))
s = ControlServer(sock_path, **kwargs)
assert s.start()
servers.append(s)
return s
yield make
for s in servers:
s.close()
def _raw(path, timeout=2.0):
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(timeout)
s.connect(path)
return s
def _read_line(s):
buf = b''
while not buf.endswith(b'\n'):
try:
chunk = s.recv(1) # one byte at a time: never eat the next line
except ConnectionResetError:
break
if not chunk:
break
buf += chunk
return json.loads(buf) if buf.endswith(b'\n') else None
@needs_unix_sockets
class TestLiveSocket:
def test_client_round_trip(self, live, sock_path):
live()
assert client.ping(paths=[sock_path]) == {'pong': True}
assert client.hello(paths=[sock_path])['version'] == 1
assert client.on_demand_status(paths=[sock_path])['current_mode'] == 'clock'
def test_ack_path(self, live, sock_path):
server = live()
ack = client.on_demand_start('req-1', 'clock', None, 20, True, paths=[sock_path])
assert ack == {'accepted': True, 'request_id': 'req-1', 'queued': 1}
ack = client.on_demand_stop('req-2', paths=[sock_path])
assert ack['request_id'] == 'req-2'
assert [(x.request_id, x.cmd) for x in server.drain()] == [
('req-1', Command.ON_DEMAND_START), ('req-2', Command.ON_DEMAND_STOP)]
def test_socket_file_mode_and_cleanup(self, live, sock_path):
server = live(group=os.getgid())
st = os.lstat(sock_path)
assert stat.S_ISSOCK(st.st_mode)
assert stat.S_IMODE(st.st_mode) == 0o660
assert st.st_gid == os.getgid()
assert not [f for f in os.listdir(os.path.dirname(sock_path)) if f.endswith('.tmp')]
server.close()
assert not os.path.exists(sock_path)
def test_without_a_group_only_the_owner_may_connect(self, live, sock_path):
live(group=None)
assert stat.S_IMODE(os.lstat(sock_path).st_mode) == 0o600
def test_garbage_then_a_good_request_on_one_connection(self, live, sock_path):
live()
s = _raw(sock_path)
try:
s.sendall(b'this is not json\n')
assert _read_line(s)['error']['code'] == ErrorCode.BAD_JSON
s.sendall(_req(Command.PING, rid='after') + b'\n')
resp = _read_line(s)
assert resp['ok'] and resp['id'] == 'after'
finally:
s.close()
def test_two_requests_in_one_write(self, live, sock_path):
live()
s = _raw(sock_path)
try:
s.sendall(_req(Command.PING, rid='a') + b'\n' + _req(Command.PING, rid='b') + b'\n')
assert _read_line(s)['id'] == 'a'
assert _read_line(s)['id'] == 'b'
finally:
s.close()
def test_oversize_is_refused_and_the_server_lives_on(self, live, sock_path):
live()
s = _raw(sock_path)
try:
try:
# Exactly the limit with no newline: the server has read it
# all when it refuses, so its answer is not lost to a reset.
s.sendall(b'{"pad":"' + b'x' * (c.MAX_MESSAGE_BYTES - 8))
except OSError:
pass # the server may hang up before we finish writing
resp = _read_line(s)
assert resp['error']['code'] == ErrorCode.MESSAGE_TOO_LARGE
assert s.recv(10) == b'' # and hung up
finally:
s.close()
assert client.ping(paths=[sock_path]) == {'pong': True}
def test_a_client_that_hangs_up_mid_message(self, live, sock_path):
server = live()
s = _raw(sock_path)
s.sendall(b'{"v":1,"id":"half","cmd":"on_demand.st')
s.close()
time.sleep(0.2)
assert client.ping(paths=[sock_path]) == {'pong': True}
assert server.drain() == []
def test_a_slow_client_is_dropped_and_blocks_nobody(self, live, sock_path):
live(io_timeout=0.2, message_timeout=0.5)
slow = _raw(sock_path, timeout=3)
try:
slow.sendall(b'{"v":1,') # ...and never finishes
t0 = time.monotonic()
assert client.ping(paths=[sock_path]) == {'pong': True}
assert time.monotonic() - t0 < 0.5, 'a slow client held up another'
assert slow.recv(100) == b'' # hung up on, not answered
finally:
slow.close()
def test_an_idle_connection_is_closed(self, live, sock_path):
live(io_timeout=0.1, idle_timeout=0.3)
s = _raw(sock_path, timeout=3)
try:
assert s.recv(100) == b''
finally:
s.close()
def test_too_many_clients_are_told_busy(self, live, sock_path):
live(max_clients=2, io_timeout=0.2, idle_timeout=5)
held = [_raw(sock_path) for _ in range(2)]
try:
time.sleep(0.1)
extra = _raw(sock_path)
try:
assert _read_line(extra)['error']['code'] == ErrorCode.BUSY
finally:
extra.close()
finally:
for s in held:
s.close()
time.sleep(0.3)
assert client.ping(paths=[sock_path]) == {'pong': True}
def test_many_concurrent_clients(self, live, sock_path):
server = live(queue_size=64)
errors = []
def go(n):
try:
client.on_demand_start(f'r{n}', 'p', None, paths=[sock_path], timeout=3)
except client.ControlError as e: # busy is allowed under load
if e.reason != ErrorCode.BUSY:
errors.append(e)
threads = [threading.Thread(target=go, args=(n,)) for n in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert errors == []
assert 0 < len(server.drain()) <= 20
def test_a_stale_socket_is_replaced(self, sock_path, status):
dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
dead.bind(sock_path)
dead.close() # file left behind, nothing listening
s = ControlServer(sock_path, status_provider=lambda: status)
try:
assert s.start()
assert client.ping(paths=[sock_path]) == {'pong': True}
finally:
s.close()
def test_a_live_socket_is_not_stolen(self, live, sock_path, status):
live()
second = ControlServer(sock_path, status_provider=lambda: status)
assert second.start() is False
assert client.ping(paths=[sock_path]) == {'pong': True}
def test_a_regular_file_is_never_removed(self, sock_path):
with open(sock_path, 'w') as f:
f.write('precious')
assert ControlServer(sock_path).start() is False
with open(sock_path) as f:
assert f.read() == 'precious'
def test_close_leaves_a_successor_s_socket_alone(self, sock_path, status):
first = ControlServer(sock_path, status_provider=lambda: status)
assert first.start()
first._close_socket() # dead, but still owns the path
os.unlink(sock_path)
second = ControlServer(sock_path, status_provider=lambda: status)
assert second.start()
try:
first.close() # must not unlink second's file
assert client.ping(paths=[sock_path]) == {'pong': True}
finally:
second.close()
def test_client_reasons(self, sock_path, tmp_path):
with pytest.raises(client.ControlError) as e:
client.ping(paths=[sock_path])
assert e.value.reason == 'no_socket'
dead = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
dead.bind(sock_path)
try:
with pytest.raises(client.ControlError) as e:
client.ping(paths=[sock_path])
assert e.value.reason == 'refused'
finally:
dead.close()
with pytest.raises(client.ControlError) as e:
client.ping(paths=[])
assert e.value.reason == 'disabled'
with pytest.raises(client.ControlError) as e:
client.on_demand_start('x', None, None, paths=[sock_path])
assert e.value.reason == 'invalid_request'
def test_a_display_that_never_answers_times_out(self, sock_path):
mute = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
mute.bind(sock_path)
mute.listen(1) # accepts at the kernel, never replies
try:
t0 = time.monotonic()
with pytest.raises(client.ControlError) as e:
client.ping(paths=[sock_path], timeout=0.3)
assert e.value.reason == 'timeout'
assert time.monotonic() - t0 < 1.0
finally:
mute.close()
def test_the_dev_directory_must_be_private(self, monkeypatch, tmp_path):
d = tmp_path / 'shared'
d.mkdir()
target = d / 'control.sock'
monkeypatch.setattr(srv, 'dev_socket_path', lambda: str(target))
monkeypatch.setattr(os, 'geteuid', lambda: os.getuid() + 1) # "someone else's"
assert ControlServer(str(target)).start() is False
@needs_unix_sockets
@pytest.mark.skipif(not hasattr(socket, 'SO_PEERCRED'), reason='SO_PEERCRED is Linux-only')
class TestPermissions:
def test_peer_credentials_are_read(self, live, sock_path):
server = live()
s = _raw(sock_path)
try:
s.sendall(_req(Command.PING) + b'\n')
assert _read_line(s)['ok']
finally:
s.close()
a, b = socket.socketpair(socket.AF_UNIX)
try:
cred = srv.peer_credentials(a)
assert cred.uid == os.geteuid() and cred.pid == os.getpid()
finally:
a.close()
b.close()
assert srv.process_groups(os.getpid()) == frozenset(os.getgroups())
assert server.running
@pytest.mark.skipif(hasattr(os, 'geteuid') and os.geteuid() == 0,
reason='root may always connect')
def test_a_peer_outside_the_model_is_refused(self, live, sock_path):
server = live(group=None)
# Pretend the display runs as someone else: this process is then
# neither root, the display's user, nor in its (absent) group.
server._own_uid = os.geteuid() + 12345
s = _raw(sock_path)
try:
resp = _read_line(s)
assert resp['error']['code'] == ErrorCode.FORBIDDEN
assert s.recv(10) == b''
finally:
s.close()
with pytest.raises(client.ControlError) as e:
client.on_demand_stop('nope', paths=[sock_path])
assert e.value.reason == ErrorCode.FORBIDDEN
assert server.drain() == []
@pytest.mark.skipif(not (hasattr(os, 'geteuid') and os.geteuid() == 0),
reason='needs root to switch users (run under WSL as root, or on a Pi)')
def test_the_kernel_enforces_the_group(self, live, sock_path):
"""The real deployment shape: root serves, an unprivileged user connects.
nobody in the socket's group gets in; nobody outside it gets EACCES
from connect() -- the kernel's check, before any byte is read.
"""
import pwd
nobody = pwd.getpwnam('nobody')
allowed_gid = nobody.pw_gid
live(group=allowed_gid)
os.chmod(os.path.dirname(sock_path), 0o755)
def try_as(gid):
r, w = os.pipe()
pid = os.fork()
if pid == 0: # child: drop to nobody with only `gid`
os.close(r)
try:
os.setgroups([])
os.setgid(gid)
os.setuid(nobody.pw_uid)
result = json.dumps(client.ping(paths=[sock_path]))
except client.ControlError as e:
result = 'error:' + e.reason
except Exception as e: # report anything else to the parent
result = 'crash:' + repr(e)
os.write(w, result.encode())
os._exit(0)
os.close(w)
out = b''
while True:
chunk = os.read(r, 4096)
if not chunk:
break
out += chunk
os.close(r)
os.waitpid(pid, 0)
return out.decode()
assert try_as(allowed_gid) == '{"pong": true}'
other_gid = allowed_gid - 1 if allowed_gid > 1 else allowed_gid + 1
assert try_as(other_gid) == 'error:refused'
# Someone loosens the mode by hand: the kernel lets the outsider
# connect, and SO_PEERCRED still turns it away.
os.chmod(sock_path, 0o666)
assert try_as(other_gid) == 'error:forbidden'
assert try_as(allowed_gid) == '{"pong": true}'