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>
This commit is contained in:
Chuck
2026-10-01 10:33:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 74696d2108
commit 695ff92009
18 changed files with 3158 additions and 42 deletions
+87 -18
View File
@@ -42,6 +42,7 @@ from src.cache_manager import CacheManager
from src.font_manager import FontManager
from src.logging_config import get_logger
from src.common.sync_manager import DisplaySyncManager, SyncRole
from src.ipc.server import ControlServer, start_control_server
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
# Get logger with consistent configuration
@@ -251,6 +252,10 @@ class DisplayController:
# Monotonic stamp of the last _service_pending_changes pass; same
# "None means never" convention as _last_on_demand_poll.
self._last_pending_service: Optional[float] = None
# The control socket (src/ipc), started by run(). None when it is not
# served (Windows, LEDMATRIX_CONTROL_SOCKET=off, a bind failure);
# the file mailbox works either way.
self._control_server = None
# A brightness set_brightness() refused, so the periodic service pass
# doesn't retry (and log) the same failure several times a second.
self._failed_brightness_target: Optional[int] = None
@@ -1313,10 +1318,12 @@ class DisplayController:
def _get_on_demand_remaining(self) -> Optional[float]:
"""Calculate remaining time for an active on-demand session."""
if not self.on_demand_active or self.on_demand_expires_at is None:
# Read once: the control socket's status command calls this from
# its own thread, while the render thread may be clearing the field.
expires_at = self.on_demand_expires_at
if not self.on_demand_active or expires_at is None:
return None
remaining = self.on_demand_expires_at - time.time()
return max(0.0, remaining)
return max(0.0, expires_at - time.time())
def _publish_current_mode_state(self) -> None:
"""Publish the currently active display mode/plugin to cache for the web UI."""
@@ -1350,23 +1357,27 @@ class DisplayController:
or time.monotonic() - self._last_published_at >= CURRENT_STATE_REFRESH_SECONDS):
self._publish_current_mode_state()
def _on_demand_state(self) -> Dict[str, Any]:
"""The on-demand state as published to the cache and the control socket."""
return {
'active': self.on_demand_active,
'mode': self.on_demand_mode,
'plugin_id': self.on_demand_plugin_id,
'requested_at': self.on_demand_requested_at,
'expires_at': self.on_demand_expires_at,
'duration': self.on_demand_duration,
'pinned': self.on_demand_pinned,
'status': self.on_demand_status,
'error': self.on_demand_last_error,
'last_event': self.on_demand_last_event,
'remaining': self._get_on_demand_remaining(),
'last_updated': time.time()
}
def _publish_on_demand_state(self) -> None:
"""Publish current on-demand state to cache for external consumers."""
try:
state = {
'active': self.on_demand_active,
'mode': self.on_demand_mode,
'plugin_id': self.on_demand_plugin_id,
'requested_at': self.on_demand_requested_at,
'expires_at': self.on_demand_expires_at,
'duration': self.on_demand_duration,
'pinned': self.on_demand_pinned,
'status': self.on_demand_status,
'error': self.on_demand_last_error,
'last_event': self.on_demand_last_event,
'remaining': self._get_on_demand_remaining(),
'last_updated': time.time()
}
state = self._on_demand_state()
self.cache_manager.set('display_on_demand_state', state)
except (OSError, RuntimeError, ValueError, TypeError) as err:
logger.error("Failed to publish on-demand state: %s", err, exc_info=True)
@@ -1426,6 +1437,9 @@ class DisplayController:
#: whole cost is one monotonic-clock compare.
PENDING_CHANGES_INTERVAL = ON_DEMAND_POLL_INTERVAL
#: Class-level default for controllers built without __init__ (tests).
_control_server: Optional[ControlServer] = None
def _service_pending_changes(self) -> None:
"""Apply changes made elsewhere while the display thread is busy.
@@ -1446,7 +1460,10 @@ class DisplayController:
"""
now = time.monotonic()
last = self._last_pending_service
if last is not None and now - last < self.PENDING_CHANGES_INTERVAL:
# A command queued on the control socket skips the floor: it is in
# memory, so applying it now costs no disk read.
if (last is not None and now - last < self.PENDING_CHANGES_INTERVAL
and not (self._control_server and self._control_server.has_pending)):
return
self._last_pending_service = now
@@ -1588,8 +1605,49 @@ class DisplayController:
except (OSError, AttributeError, KeyError) as err:
logger.debug("Could not clear the on-demand request mailbox: %s", err)
def _start_control_server(self) -> None:
"""Serve the control socket (src/ipc/server.py). Never raises.
Its handlers only queue commands; _drain_control_commands applies
them on the render thread, where the mailbox is read.
"""
if self._control_server is not None:
return
try:
self._control_server = start_control_server(
status_provider=self._control_status,
cache_dir=getattr(self.cache_manager, 'cache_dir', None))
except Exception: # pylint: disable=broad-except
logger.exception("Control socket not started; using the file mailbox only")
def _control_status(self) -> Dict[str, Any]:
"""The socket's on_demand.status answer. Runs on the socket's thread: reads only."""
return {'on_demand': self._on_demand_state(),
'current_mode': self.current_display_mode,
'display_active': self.is_display_active}
def _drain_control_commands(self) -> None:
"""Apply on-demand commands that arrived over the control socket.
Each goes through _handle_on_demand_request, the mailbox's own
handler, so both ways in behave the same, and a request that came
both ways (a client that timed out and fell back) has one request
id and is processed once.
"""
server = self._control_server
if server is None or not server.has_pending:
return
for command in server.drain():
try:
self._handle_on_demand_request(command.as_on_demand_request())
except Exception: # pylint: disable=broad-except
logger.exception("Failed to apply control socket command %s",
command.request_id)
def _poll_on_demand_requests(self) -> None:
"""Poll cache for new on-demand requests from external controllers."""
# Socket commands are already in memory: no disk read, so no floor.
self._drain_control_commands()
now = time.monotonic()
if (self._last_on_demand_poll is not None
and now - self._last_on_demand_poll < self.ON_DEMAND_POLL_INTERVAL):
@@ -1614,7 +1672,10 @@ class DisplayController:
if not request:
return
self._handle_on_demand_request(request)
def _handle_on_demand_request(self, request: Dict[str, Any]) -> None:
"""Process one on-demand request, from the mailbox or the control socket."""
request_id = request.get('request_id')
if not request_id:
return
@@ -2248,6 +2309,7 @@ class DisplayController:
# vouch for: beats from any other thread are ignored, so a render
# thread stuck inside a plugin stops them.
display_watchdog.watchdog.bind_render_thread()
self._start_control_server()
try:
# Initialize with cached data for fast startup - let background updates refresh naturally
@@ -3649,6 +3711,13 @@ class DisplayController:
# First: a clean stop is not a hang, and a heartbeat left behind
# would read as a frozen panel to the web interface.
display_watchdog.watchdog.stopping()
# Stop taking commands; the socket file goes with it.
if self._control_server is not None:
try:
self._control_server.close()
except Exception as e:
logger.warning("Error closing the control socket: %s", e)
self._control_server = None
# Stop the async update worker first so no in-flight update() call
# is still touching display/cache-backed resources while they're
# torn down below.
+11
View File
@@ -0,0 +1,11 @@
"""The display process's control socket: web -> display commands with acks.
- :mod:`src.ipc.contract` -- the versioned messages, the framing and where
the socket lives. Shared by both sides; standard library only.
- :mod:`src.ipc.server` -- the display side: a threaded Unix-socket server
whose handlers only queue work for the render thread.
- :mod:`src.ipc.client` -- the web side: one short-timeout request.
See docs/IPC_CONTROL_SOCKET.md for the protocol, the security model and the
stage plan.
"""
+200
View File
@@ -0,0 +1,200 @@
"""The web side of the control socket: one request, a short timeout, no retries.
Every failure -- no socket (the display is stopped, or predates the socket),
a refused or timed-out connection, a reply that breaks the contract, or an
error the display returned -- raises :class:`ControlError` with a short
``reason``, and the caller falls back to the file mailbox. Nothing here
blocks for longer than ``timeout`` in total.
"""
from __future__ import annotations
import socket
import time
import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence
from src.ipc.contract import (
MAX_MESSAGE_BYTES,
PROTOCOL_VERSION,
SUPPORTED_VERSIONS,
Command,
FrameReader,
ProtocolError,
Request,
Response,
client_socket_paths,
decode_message,
encode_message,
parse_args,
socket_supported,
)
#: Total budget for one request: connect, send and the reply. The display
#: answers from a thread that does no rendering, normally within a few
#: milliseconds; this only bounds a wedged one. The web route then falls back
#: to the mailbox, so a timeout costs this much latency and nothing else.
DEFAULT_TIMEOUT_SECONDS = 1.0
class ControlError(Exception):
"""The socket could not carry the request. ``reason`` is a short code.
Transport reasons: ``disabled``, ``unsupported``, ``no_socket``,
``refused``, ``timeout``, ``closed``, ``bad_response``, ``invalid_request``.
When the display answered with an error, ``reason`` is that error's
:class:`~src.ipc.contract.ErrorCode` (``busy``, ``unknown_command``, ...).
"""
def __init__(self, reason: str, message: str = ''):
super().__init__(reason, message)
self.reason = reason
self.message = message
def __str__(self) -> str:
return f'{self.reason}: {self.message}' if self.message else self.reason
def request(cmd: str, args: Optional[Mapping[str, Any]] = None, *,
request_id: Optional[str] = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Send one command and return its ``result``. Raises :class:`ControlError`."""
args = dict(args or {})
request_id = request_id or str(uuid.uuid4())
try:
# Refuse locally what the display would refuse: a malformed id
# (callers may pass their own) or arguments that break the contract.
envelope = Request.from_dict({'v': PROTOCOL_VERSION, 'id': request_id,
'cmd': cmd, 'args': args})
parse_args(cmd, args)
payload = encode_message(envelope.to_dict())
except ProtocolError as e:
raise ControlError('invalid_request', e.message) from None
if not socket_supported():
raise ControlError('unsupported', 'no Unix sockets on this platform')
candidates: List[str] = list(paths) if paths is not None else client_socket_paths()
if not candidates:
raise ControlError('disabled', 'the control socket is turned off')
deadline = time.monotonic() + timeout
sock = _connect(candidates, deadline)
try:
response = _exchange(sock, payload, deadline)
finally:
sock.close()
# A refusal before the request was read (forbidden, too many
# connections) carries no id.
if response.id != request_id and not (response.id is None and not response.ok):
raise ControlError('bad_response', 'the reply is for a different request')
if not response.ok:
error = response.error
raise ControlError(error.code if error else 'bad_response',
error.message if error else '')
return dict(response.result or {})
def _remaining(deadline: float) -> float:
left = deadline - time.monotonic()
if left <= 0:
raise ControlError('timeout', 'no reply in time')
return left
def _connect(paths: Sequence[str], deadline: float) -> socket.socket:
last = ControlError('no_socket', 'the display is not serving the control socket')
for path in paths:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
sock.settimeout(_remaining(deadline))
sock.connect(path)
return sock
except (FileNotFoundError, NotADirectoryError):
sock.close()
continue
except ConnectionRefusedError:
sock.close()
last = ControlError('refused', f'nothing is listening at {path}')
except BlockingIOError:
# EAGAIN: the listen backlog is full -- a live but swamped display.
sock.close()
raise ControlError('busy', 'the display is not accepting connections') from None
except PermissionError:
sock.close()
last = ControlError('refused', f'no permission to connect to {path}')
except socket.timeout:
sock.close()
raise ControlError('timeout', 'connect timed out') from None
except ControlError:
sock.close()
raise
except OSError as e:
sock.close()
last = ControlError('refused', f'{path}: {e}')
raise last
def _exchange(sock: socket.socket, payload: bytes, deadline: float) -> Response:
try:
sock.settimeout(_remaining(deadline))
sock.sendall(payload)
reader = FrameReader(MAX_MESSAGE_BYTES)
while True:
sock.settimeout(_remaining(deadline))
data = sock.recv(4096)
if not data:
raise ControlError('closed', 'the display closed the connection')
lines = reader.feed(data)
if lines:
return Response.from_dict(decode_message(lines[0]))
except socket.timeout:
raise ControlError('timeout', 'no reply in time') from None
except ProtocolError as e:
raise ControlError('bad_response', e.message) from None
except ControlError:
raise
except OSError as e:
raise ControlError('closed', str(e)) from None
# -- commands ---------------------------------------------------------------------------
def on_demand_start(request_id: str, plugin_id: Optional[str], mode: Optional[str],
duration: Any = None, pinned: bool = False, *,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Ask the display to show a plugin now. Returns the ack; raises :class:`ControlError`.
``request_id`` doubles as the on-demand request id, so a request that a
timed-out caller then also writes to the mailbox is processed only once.
"""
args = {'plugin_id': plugin_id, 'mode': mode, 'duration': duration, 'pinned': pinned}
return request(Command.ON_DEMAND_START, args, request_id=request_id,
timeout=timeout, paths=paths)
def on_demand_stop(request_id: str, *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Ask the display to end on-demand. Returns the ack; raises :class:`ControlError`."""
return request(Command.ON_DEMAND_STOP, {}, request_id=request_id,
timeout=timeout, paths=paths)
def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""The display's live on-demand state. Raises :class:`ControlError`."""
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
return request(Command.PING, {}, timeout=timeout, paths=paths)
def hello(client: str = 'web', *, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Version negotiation: the result's ``version`` is the one both sides speak."""
return request(Command.HELLO, {'versions': list(SUPPORTED_VERSIONS), 'client': client},
timeout=timeout, paths=paths)
+527
View File
@@ -0,0 +1,527 @@
"""The control socket's contract: versioned messages, framing and location.
Both processes import this module -- the display serves the socket
(:mod:`src.ipc.server`) and the web interface calls it
(:mod:`src.ipc.client`) -- so it is the one definition of what goes over the
wire. Standard library only, and no import of the rest of ``src``.
Wire format (protocol version 1)
--------------------------------
One JSON object per line (newline-delimited JSON), UTF-8, at most
:data:`MAX_MESSAGE_BYTES` per line including the newline. Messages are
encoded with ``ensure_ascii``, so a newline never appears inside one.
Request::
{"v": 1, "id": "<1-128 chars>", "cmd": "on_demand.start", "args": {...}}
Response, always carrying the request's ``id`` (``null`` when the request
could not be parsed far enough to have one)::
{"v": 1, "id": "...", "ok": true, "result": {...}}
{"v": 1, "id": "...", "ok": false, "error": {"code": "...", "message": "..."}}
A connection may carry several requests; each gets exactly one response, in
order. Commands that change what the panel shows are *acknowledged*, not
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
has the command queued and will apply it at its next on-demand check. Its
outcome is published the way it always was (``display_on_demand_state``,
later the state stream).
See docs/IPC_CONTROL_SOCKET.md for the full description.
"""
from __future__ import annotations
import json
import math
import os
import tempfile
from dataclasses import dataclass, field
from typing import Any, Dict, List, Mapping, Optional, Tuple, TypedDict, TypeGuard, Union
# -- versions and limits -------------------------------------------------------
#: The protocol version this code speaks by default.
PROTOCOL_VERSION = 1
#: Every version this code can speak; ``hello`` picks the highest common one.
SUPPORTED_VERSIONS: Tuple[int, ...] = (1,)
#: The largest message either side sends or accepts, newline included. A
#: stage-1 message is well under 1 KiB; this only bounds a broken or hostile
#: peer, so a reader never buffers more than this per connection.
MAX_MESSAGE_BYTES = 64 * 1024
#: Longest request id. Ids are also the on-demand ``request_id``, which the
#: display logs and stores, so they are kept short.
MAX_ID_LENGTH = 128
#: Longest plugin id or mode name an on-demand command may carry.
MAX_NAME_LENGTH = 128
# -- where the socket lives ------------------------------------------------------
#: ``RuntimeDirectory=ledmatrix`` in ledmatrix.service creates this (tmpfs,
#: root-owned, 0755); a display under an older unit creates it itself, as it
#: does for the heartbeat (src/display_watchdog.py).
DEFAULT_SOCKET_DIR = '/run/ledmatrix'
SOCKET_NAME = 'control.sock'
DEFAULT_SOCKET_PATH = DEFAULT_SOCKET_DIR + '/' + SOCKET_NAME
#: Overrides the socket path for both processes (a dev checkout, a second
#: instance, tests). One of :data:`DISABLED_VALUES` turns the socket off: the
#: display does not serve it and the web interface goes straight to the
#: file mailbox.
SOCKET_PATH_ENV = 'LEDMATRIX_CONTROL_SOCKET'
DISABLED_VALUES = frozenset({'off', '0', 'false', 'no', 'none', 'disabled'})
def socket_supported() -> bool:
"""Whether this platform has Unix sockets at all (Windows Python does not)."""
import socket
return os.name == 'posix' and hasattr(socket, 'AF_UNIX')
def socket_disabled(environ: Optional[Mapping[str, str]] = None) -> bool:
"""True when :data:`SOCKET_PATH_ENV` switches the socket off."""
env = os.environ if environ is None else environ
value = (env.get(SOCKET_PATH_ENV) or '').strip()
return value.lower() in DISABLED_VALUES
def configured_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
"""The path :data:`SOCKET_PATH_ENV` names, or None when it is unset or 'off'."""
env = os.environ if environ is None else environ
value = (env.get(SOCKET_PATH_ENV) or '').strip()
if not value or value.lower() in DISABLED_VALUES:
return None
return value
def dev_socket_path(uid: Optional[int] = None) -> str:
"""Where a display that cannot use /run/ledmatrix serves the socket.
A per-user directory under the temp dir, so a dev checkout run as an
ordinary user (``python3 run.py -e``) and its web interface, run by the
same user, find each other with no configuration.
"""
if uid is None:
getuid = getattr(os, 'getuid', None)
uid = getuid() if getuid is not None else 0
return os.path.join(tempfile.gettempdir(), f'ledmatrix-{uid}', SOCKET_NAME)
def client_socket_paths(environ: Optional[Mapping[str, str]] = None) -> List[str]:
"""The paths a client tries, in order; empty when the socket is off."""
if socket_disabled(environ):
return []
configured = configured_socket_path(environ)
if configured:
return [configured]
return [DEFAULT_SOCKET_PATH, dev_socket_path()]
# -- commands and error codes ----------------------------------------------------
class Command:
"""Command names. Dotted names group a feature's commands."""
HELLO = 'hello'
PING = 'ping'
ON_DEMAND_START = 'on_demand.start'
ON_DEMAND_STOP = 'on_demand.stop'
ON_DEMAND_STATUS = 'on_demand.status'
#: Every command version 1 defines, in the order ``hello`` reports them.
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
Command.ON_DEMAND_START,
Command.ON_DEMAND_STOP,
Command.ON_DEMAND_STATUS,
)
#: Commands that are queued for the render thread and answered with an ack.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP})
class ErrorCode:
"""``error.code`` values. Clients branch on these, never on the message."""
BAD_JSON = 'bad_json' # a line that is not a JSON object
BAD_REQUEST = 'bad_request' # the envelope is malformed
MESSAGE_TOO_LARGE = 'message_too_large' # over MAX_MESSAGE_BYTES
UNSUPPORTED_VERSION = 'unsupported_version' # no version in common
UNKNOWN_COMMAND = 'unknown_command'
INVALID_ARGS = 'invalid_args'
BUSY = 'busy' # queue full / too many clients
FORBIDDEN = 'forbidden' # peer credentials refused
INTERNAL = 'internal' # a bug on the display side
class ProtocolError(Exception):
"""A message that breaks the contract. ``code`` is an :class:`ErrorCode`."""
def __init__(self, code: str, message: str, request_id: Optional[str] = None):
super().__init__(code, message, request_id)
self.code = code
self.message = message
self.request_id = request_id
def __str__(self) -> str:
return f'{self.code}: {self.message}'
# -- the envelope ------------------------------------------------------------------
def _is_int(value: Any) -> TypeGuard[int]:
return isinstance(value, int) and not isinstance(value, bool)
def _valid_id(value: Any) -> bool:
return (isinstance(value, str) and 0 < len(value) <= MAX_ID_LENGTH
and value.isprintable())
@dataclass(frozen=True)
class Request:
"""``{v, id, cmd, args}``."""
id: str
cmd: str
args: Dict[str, Any] = field(default_factory=dict)
v: int = PROTOCOL_VERSION
def to_dict(self) -> Dict[str, Any]:
return {'v': self.v, 'id': self.id, 'cmd': self.cmd, 'args': dict(self.args)}
@classmethod
def from_dict(cls, obj: Any) -> 'Request':
"""Validate an envelope. Raises :class:`ProtocolError`.
The version is checked by the server, not here, so that ``hello``
can negotiate across versions.
"""
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a request must be a JSON object')
raw_id = obj.get('id')
request_id = raw_id if _valid_id(raw_id) else None
if request_id is None:
raise ProtocolError(ErrorCode.BAD_REQUEST,
f'id must be a printable string of 1-{MAX_ID_LENGTH} characters')
version = obj.get('v')
if not _is_int(version):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer', request_id)
cmd = obj.get('cmd')
if not isinstance(cmd, str) or not cmd:
raise ProtocolError(ErrorCode.BAD_REQUEST, 'cmd must be a non-empty string', request_id)
args = obj.get('args', {})
if args is None:
args = {}
if not isinstance(args, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'args must be a JSON object', request_id)
return cls(id=request_id, cmd=cmd, args=args, v=version)
@dataclass(frozen=True)
class ErrorInfo:
code: str
message: str
def to_dict(self) -> Dict[str, str]:
return {'code': self.code, 'message': self.message}
@dataclass(frozen=True)
class Response:
"""``{v, id, ok, result}`` or ``{v, id, ok: false, error: {code, message}}``."""
id: Optional[str]
ok: bool
result: Optional[Dict[str, Any]] = None
error: Optional[ErrorInfo] = None
v: int = PROTOCOL_VERSION
@classmethod
def success(cls, request_id: Optional[str], result: Mapping[str, Any],
v: int = PROTOCOL_VERSION) -> 'Response':
return cls(id=request_id, ok=True, result=dict(result), v=v)
@classmethod
def failure(cls, request_id: Optional[str], code: str, message: str,
v: int = PROTOCOL_VERSION) -> 'Response':
return cls(id=request_id, ok=False, error=ErrorInfo(code, message), v=v)
def to_dict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {'v': self.v, 'id': self.id, 'ok': self.ok}
if self.ok:
out['result'] = dict(self.result or {})
else:
error = self.error or ErrorInfo(ErrorCode.INTERNAL, 'unknown error')
out['error'] = error.to_dict()
return out
@classmethod
def from_dict(cls, obj: Any) -> 'Response':
"""Validate a response. Raises :class:`ProtocolError` (BAD_REQUEST)."""
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'a response must be a JSON object')
version = obj.get('v')
if not _is_int(version):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'v must be an integer')
raw_id = obj.get('id')
if raw_id is not None and not isinstance(raw_id, str):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'id must be a string or null')
ok = obj.get('ok')
if not isinstance(ok, bool):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'ok must be a boolean')
if ok:
result = obj.get('result', {})
if not isinstance(result, dict):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'result must be a JSON object')
return cls(id=raw_id, ok=True, result=result, v=version)
error = obj.get('error')
if (not isinstance(error, dict) or not isinstance(error.get('code'), str)
or not isinstance(error.get('message', ''), str)):
raise ProtocolError(ErrorCode.BAD_REQUEST, 'error must be {code, message}')
return cls(id=raw_id, ok=False,
error=ErrorInfo(error['code'], error.get('message', '')), v=version)
# -- command arguments -------------------------------------------------------------
def _optional_name(args: Mapping[str, Any], key: str) -> Optional[str]:
value = args.get(key)
if value is None or value == '':
return None
if not isinstance(value, str) or len(value) > MAX_NAME_LENGTH or not value.isprintable():
raise ProtocolError(ErrorCode.INVALID_ARGS,
f'{key} must be a printable string of at most '
f'{MAX_NAME_LENGTH} characters')
return value
def _optional_duration(value: Any) -> Optional[float]:
"""Seconds, or None for "until stopped". 0 means the same as None.
Numbers and numeric strings are accepted, the same as the REST route and
the file mailbox take them; anything else is refused rather than guessed.
"""
if value is None or value == '':
return None
if isinstance(value, bool):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'duration must be a number of seconds')
try:
seconds = float(value)
except (TypeError, ValueError):
raise ProtocolError(ErrorCode.INVALID_ARGS,
'duration must be a number of seconds') from None
if not math.isfinite(seconds) or seconds < 0:
raise ProtocolError(ErrorCode.INVALID_ARGS,
'duration must be a finite, non-negative number of seconds')
return seconds or None
@dataclass(frozen=True)
class HelloArgs:
"""``hello``: the versions the client speaks, and a name for the logs."""
versions: Tuple[int, ...] = (PROTOCOL_VERSION,)
client: str = ''
def to_dict(self) -> Dict[str, Any]:
return {'versions': list(self.versions), 'client': self.client}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'HelloArgs':
versions = args.get('versions', [PROTOCOL_VERSION])
if (not isinstance(versions, list) or not versions or len(versions) > 32
or not all(_is_int(v) for v in versions)):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'versions must be a list of integers')
client = args.get('client', '')
if not isinstance(client, str) or len(client) > MAX_NAME_LENGTH:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'client must be a short string')
return cls(versions=tuple(versions), client=client)
@dataclass(frozen=True)
class OnDemandStartArgs:
"""``on_demand.start``: show a plugin (or one of its modes) now.
The same fields the file mailbox carries. At least one of ``plugin_id``
and ``mode`` is required; the display resolves the other.
"""
plugin_id: Optional[str] = None
mode: Optional[str] = None
duration: Optional[float] = None
pinned: bool = False
def to_dict(self) -> Dict[str, Any]:
return {'plugin_id': self.plugin_id, 'mode': self.mode,
'duration': self.duration, 'pinned': self.pinned}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStartArgs':
plugin_id = _optional_name(args, 'plugin_id')
mode = _optional_name(args, 'mode')
if plugin_id is None and mode is None:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id or mode is required')
pinned = args.get('pinned', False)
if pinned is None:
pinned = False
if not isinstance(pinned, bool):
raise ProtocolError(ErrorCode.INVALID_ARGS, 'pinned must be a boolean')
return cls(plugin_id=plugin_id, mode=mode,
duration=_optional_duration(args.get('duration')), pinned=pinned)
@dataclass(frozen=True)
class OnDemandStopArgs:
"""``on_demand.stop``: end the on-demand session and resume rotation."""
def to_dict(self) -> Dict[str, Any]:
return {}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'OnDemandStopArgs':
return cls()
@dataclass(frozen=True)
class NoArgs:
"""``ping`` and ``on_demand.status`` take no arguments (extra ones are ignored)."""
def to_dict(self) -> Dict[str, Any]:
return {}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'NoArgs':
return cls()
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs]
_ARG_TYPES: Dict[str, Any] = {
Command.HELLO: HelloArgs,
Command.PING: NoArgs,
Command.ON_DEMAND_START: OnDemandStartArgs,
Command.ON_DEMAND_STOP: OnDemandStopArgs,
Command.ON_DEMAND_STATUS: NoArgs,
}
def parse_args(cmd: str, args: Mapping[str, Any]) -> CommandArgs:
"""Typed arguments for ``cmd``. Raises :class:`ProtocolError`."""
arg_type = _ARG_TYPES.get(cmd)
if arg_type is None:
raise ProtocolError(ErrorCode.UNKNOWN_COMMAND, f'unknown command: {cmd[:64]}')
parsed: CommandArgs = arg_type.from_dict(args)
return parsed
def on_demand_request(request_id: str, args: Union[OnDemandStartArgs, OnDemandStopArgs],
timestamp: float) -> Dict[str, Any]:
"""The file-mailbox payload for a queued on-demand command.
The display hands socket commands to the same code that handles the
mailbox (``DisplayController._handle_on_demand_request``), so a command
behaves identically whichever way it arrived, and a request that came
both ways (a client that timed out and fell back) is processed once: the
request id is the same.
"""
if isinstance(args, OnDemandStartArgs):
return {'request_id': request_id, 'action': 'start', 'plugin_id': args.plugin_id,
'mode': args.mode, 'duration': args.duration, 'pinned': args.pinned,
'timestamp': timestamp, 'source': 'socket'}
return {'request_id': request_id, 'action': 'stop', 'timestamp': timestamp,
'source': 'socket'}
# -- results -----------------------------------------------------------------------
class HelloResult(TypedDict):
version: int
versions: List[int]
commands: List[str]
max_message_bytes: int
server: str
class PingResult(TypedDict):
pong: bool
class AckResult(TypedDict):
"""The answer to a queued command: the render thread will apply it."""
accepted: bool
request_id: str
queued: int
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
"""The highest version both sides speak, or None."""
common = set(client_versions) & set(SUPPORTED_VERSIONS)
return max(common) if common else None
# -- framing -----------------------------------------------------------------------
def encode_message(obj: Mapping[str, Any]) -> bytes:
"""One newline-terminated JSON line. Raises :class:`ProtocolError` when too big."""
try:
text = json.dumps(obj, separators=(',', ':'), ensure_ascii=True, allow_nan=False)
except (TypeError, ValueError) as e:
raise ProtocolError(ErrorCode.BAD_REQUEST, f'message is not JSON-serialisable: {e}') from None
data = text.encode('ascii') + b'\n'
if len(data) > MAX_MESSAGE_BYTES:
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
f'message is {len(data)} bytes; the limit is {MAX_MESSAGE_BYTES}')
return data
def decode_message(line: bytes) -> Dict[str, Any]:
"""Parse one line (newline optional). Raises :class:`ProtocolError` (BAD_JSON)."""
try:
obj = json.loads(line.decode('utf-8'))
except ValueError: # UnicodeDecodeError and JSONDecodeError are both ValueErrors
raise ProtocolError(ErrorCode.BAD_JSON, 'not valid UTF-8 JSON') from None
if not isinstance(obj, dict):
raise ProtocolError(ErrorCode.BAD_JSON, 'a message must be a JSON object')
return obj
class FrameReader:
"""Splits a byte stream into lines, never holding more than one message.
``feed()`` returns the complete lines (without their newlines) the new
bytes finished, and raises :class:`ProtocolError` (MESSAGE_TOO_LARGE) as
soon as a line is longer than the limit, newline or not, so a peer that
never sends one cannot make the reader buffer without bound.
"""
def __init__(self, max_bytes: int = MAX_MESSAGE_BYTES):
self._max = max_bytes
self._buffer = bytearray()
@property
def pending(self) -> int:
"""Bytes of an unfinished message held."""
return len(self._buffer)
def feed(self, data: bytes) -> List[bytes]:
self._buffer.extend(data)
lines: List[bytes] = []
while True:
newline = self._buffer.find(b'\n')
if newline < 0:
break
if newline + 1 > self._max:
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
f'message exceeds {self._max} bytes')
line = bytes(self._buffer[:newline])
del self._buffer[:newline + 1]
if line.strip():
lines.append(line)
if len(self._buffer) >= self._max:
raise ProtocolError(ErrorCode.MESSAGE_TOO_LARGE,
f'message exceeds {self._max} bytes')
return lines
+643
View File
@@ -0,0 +1,643 @@
"""The display side of the control socket.
A small threaded server on a Unix stream socket (``/run/ledmatrix/control.sock``
by default; see :mod:`src.ipc.contract` for the protocol). It never touches
rendering: a command that changes the panel is validated, put on a bounded
queue and acknowledged, and the render thread drains that queue at the point
where it reads the file mailbox (``DisplayController._poll_on_demand_requests``),
handing each command to the same code. Queries (``on_demand.status``) are
answered from a snapshot callable the display provides.
Robustness rules, because this runs inside the display process:
* every connection has its own daemon thread, at most :data:`MAX_CLIENTS` at
once; one more is told ``busy`` and closed;
* every read and write has a timeout, a message must arrive whole within
:data:`MESSAGE_TIMEOUT_SECONDS`, and an idle connection is closed after
:data:`IDLE_TIMEOUT_SECONDS` -- a slow or stuck client costs one thread for
a few seconds, never the render loop;
* a line that is not JSON is answered with ``bad_json`` and the connection
carries on; a line over the size limit closes the connection; a client
that disconnects mid-message is simply dropped;
* no exception from a handler leaves the connection thread.
Who may connect (see docs/IPC_CONTROL_SOCKET.md, "Security model"): the
socket file is ``0660`` and group-owned by the group the display and the web
interface share -- the cache directory's group, the same rule DiskCache uses
for the files it shares -- so the kernel refuses everyone else at connect().
Where the kernel reports the peer's credentials (``SO_PEERCRED``, Linux) the
server checks them again: root, its own user, or a member of that group.
"""
from __future__ import annotations
import logging
import os
import queue
import socket
import stat
import struct
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional, Union
from src.ipc.contract import (
COMMANDS,
DEFAULT_SOCKET_DIR,
DEFAULT_SOCKET_PATH,
MAX_MESSAGE_BYTES,
PROTOCOL_VERSION,
QUEUED_COMMANDS,
SUPPORTED_VERSIONS,
AckResult,
Command,
ErrorCode,
FrameReader,
HelloArgs,
HelloResult,
OnDemandStartArgs,
OnDemandStopArgs,
ProtocolError,
Request,
Response,
configured_socket_path,
decode_message,
dev_socket_path,
encode_message,
negotiate_version,
on_demand_request,
parse_args,
socket_disabled,
socket_supported,
)
logger = logging.getLogger(__name__)
#: Concurrent connections served. The web interface opens one per request
#: and closes it; this only bounds a misbehaving client.
MAX_CLIENTS = 8
#: Commands waiting for the render thread. It drains them at least every
#: 0.25 s, so a full queue means the render thread is stuck, and the client
#: is told ``busy`` (and falls back to the mailbox) instead of piling up work.
QUEUE_SIZE = 16
#: Timeout for one recv()/send() on a connection.
IO_TIMEOUT_SECONDS = 2.0
#: A message must arrive whole within this long of its first byte.
MESSAGE_TIMEOUT_SECONDS = 5.0
#: A connection with no message in progress is closed after this long.
IDLE_TIMEOUT_SECONDS = 10.0
#: How often the accept loop wakes to notice close().
_ACCEPT_POLL_SECONDS = 0.5
_LISTEN_BACKLOG = 64
# -- queued work ---------------------------------------------------------------------
@dataclass(frozen=True)
class QueuedCommand:
"""A command waiting for the render thread."""
request_id: str
cmd: str
args: Union[OnDemandStartArgs, OnDemandStopArgs]
received_at: float # time.time() when it was accepted
peer_uid: Optional[int] = None
def as_on_demand_request(self) -> Dict[str, Any]:
"""The mailbox-shaped payload the display's on-demand handler takes."""
return on_demand_request(self.request_id, self.args, self.received_at)
# -- peer credentials ------------------------------------------------------------------
@dataclass(frozen=True)
class PeerCredentials:
pid: int
uid: int
gid: int
def peer_credentials(conn: socket.socket) -> Optional[PeerCredentials]:
"""The connecting process's pid/uid/gid, where the kernel reports them.
``SO_PEERCRED`` is Linux's; elsewhere this is None and the socket file's
mode is the only gate.
"""
option = getattr(socket, 'SO_PEERCRED', None)
if option is None:
return None
try:
raw = conn.getsockopt(socket.SOL_SOCKET, option, struct.calcsize('3i'))
pid, uid, gid = struct.unpack('3i', raw)
except (OSError, struct.error):
return None
return PeerCredentials(pid=pid, uid=uid, gid=gid)
def process_groups(pid: int) -> Optional[FrozenSet[int]]:
"""A process's supplementary groups, from /proc; None when unreadable.
The web service's primary group is normally its user's own; the shared
group is a supplementary one, which ``SO_PEERCRED`` does not report.
"""
try:
with open(f'/proc/{int(pid)}/status', 'r', encoding='ascii', errors='replace') as f:
for line in f:
if line.startswith('Groups:'):
return frozenset(int(g) for g in line.split()[1:] if g.isdigit())
except (OSError, ValueError):
return None
return frozenset()
def user_in_group(uid: int, gid: int) -> bool:
"""Whether the account ``uid`` is listed in group ``gid`` (the group database)."""
try:
import grp
import pwd
name = pwd.getpwuid(uid).pw_name
group = grp.getgrgid(gid)
except (ImportError, KeyError, OSError):
return False
return name in group.gr_mem or pwd.getpwuid(uid).pw_gid == gid
def peer_allowed(cred: PeerCredentials, own_uid: int, allowed_gid: Optional[int],
groups: Optional[FrozenSet[int]] = None,
in_group: Callable[[int, int], bool] = user_in_group) -> bool:
"""The permission model: root, the server's own user, or the shared group.
``groups`` are the peer's supplementary groups (from /proc); when they
could not be read the group database decides instead.
"""
if cred.uid == 0 or cred.uid == own_uid:
return True
if allowed_gid is None:
return False
if cred.gid == allowed_gid:
return True
if groups is not None:
return allowed_gid in groups
return in_group(cred.uid, allowed_gid)
def resolve_socket_group(cache_dir: Optional[str]) -> Optional[int]:
"""The group the socket should belong to: the one the two services share.
The cache directory's group when the directory is group-writable --
the rule DiskCache applies to every file the display shares with the web
interface (``root:ledmatrix 2775`` on an installed device). Otherwise the
project directory's group (``get_shared_group_gid``), which config files
use. None when neither is known: then only root and the display's own
user can connect.
"""
if cache_dir:
try:
st = os.stat(cache_dir)
if st.st_mode & stat.S_IWGRP:
return st.st_gid
except OSError:
pass
try:
from src.common.permission_utils import get_shared_group_gid
return get_shared_group_gid()
except ImportError: # pragma: no cover - src is always importable here
return None
def server_socket_path(environ: Optional[Mapping[str, str]] = None) -> Optional[str]:
"""Where the display should serve the socket; None when it should not.
:data:`~src.ipc.contract.SOCKET_PATH_ENV` wins. Otherwise
/run/ledmatrix/control.sock when the display can create or write that
directory (root, which an installed display always is), and the per-user
dev path otherwise (an emulator run from a checkout).
"""
if not socket_supported() or socket_disabled(environ):
return None
configured = configured_socket_path(environ)
if configured:
return configured
geteuid = getattr(os, 'geteuid', None)
if (geteuid is not None and geteuid() == 0) or os.access(DEFAULT_SOCKET_DIR, os.W_OK):
return DEFAULT_SOCKET_PATH
return dev_socket_path()
# -- the server ------------------------------------------------------------------------
StatusProvider = Callable[[], Dict[str, Any]]
class ControlServer:
"""Serves the control socket on background threads.
``start()`` binds and starts accepting; ``drain()`` (render thread) takes
the queued commands; ``close()`` stops and removes the socket file.
"""
def __init__(self, path: str, status_provider: Optional[StatusProvider] = None,
group: Optional[int] = None, *, queue_size: int = QUEUE_SIZE,
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
check_peer: bool = True):
self.path = path
self._status_provider = status_provider
self._group = group
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
self._pending = threading.Event()
self._slots = threading.BoundedSemaphore(max_clients)
self._io_timeout = io_timeout
self._message_timeout = message_timeout
self._idle_timeout = idle_timeout
self._check_peer = check_peer
self._sock: Optional[socket.socket] = None
self._identity: Optional[tuple] = None # (st_dev, st_ino) of our socket file
self._thread: Optional[threading.Thread] = None
self._stopping = threading.Event()
self._own_uid = os.geteuid() if hasattr(os, 'geteuid') else -1
# -- lifecycle -------------------------------------------------------------
@property
def running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def socket_mode(self) -> int:
"""0660 with a shared group; 0600 (the display's user only) without one."""
return 0o660 if self._group is not None else 0o600
def start(self) -> bool:
"""Bind and start serving. False (logged) when the socket cannot be served.
Never raises: without the socket the web interface uses the file
mailbox, exactly as before.
"""
if not socket_supported():
logger.debug("Control socket not started: no Unix sockets on this platform")
return False
try:
self._prepare_directory()
if not self._clear_stale_socket():
return False
self._bind()
except OSError as e:
logger.warning("Control socket not started at %s (%s); the web interface "
"will use the file mailbox", self.path, e)
self._close_socket()
return False
self._stopping.clear()
self._thread = threading.Thread(target=self._accept_loop, name='ledmatrix-ipc',
daemon=True)
self._thread.start()
logger.info("Control socket listening at %s (mode %o, group %s)",
self.path, self.socket_mode,
self._group if self._group is not None else 'none')
return True
def close(self) -> None:
"""Stop accepting and remove the socket file (only if it is still ours)."""
self._stopping.set()
self._close_socket()
thread = self._thread
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
self._thread = None
if self._identity is not None:
try:
st = os.lstat(self.path)
if (st.st_dev, st.st_ino) == self._identity:
os.unlink(self.path)
except OSError:
pass
self._identity = None
def _close_socket(self) -> None:
sock, self._sock = self._sock, None
if sock is not None:
try:
sock.close()
except OSError:
pass
def _prepare_directory(self) -> None:
directory = os.path.dirname(os.path.abspath(self.path))
if self.path == dev_socket_path():
# The dev path is in the shared temp dir: private to this user,
# and refused if someone else got there first.
os.makedirs(directory, mode=0o700, exist_ok=True)
self._check_private_directory(directory)
elif not os.path.isdir(directory):
# /run/ledmatrix under a unit that predates RuntimeDirectory= (the
# display is root and makes it, as it does for the heartbeat), or
# a configured path. 0755: the web interface only needs to reach
# the socket; the socket's own mode decides who may connect.
os.makedirs(directory, mode=0o755, exist_ok=True)
def _check_private_directory(self, directory: str) -> None:
"""Refuse a dev directory someone else made (it lives in a shared /tmp)."""
st = os.lstat(directory)
if stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode):
raise OSError(f'{directory} is not a plain directory')
if hasattr(os, 'geteuid') and st.st_uid != os.geteuid():
raise OSError(f'{directory} belongs to uid {st.st_uid}, not this user')
def _clear_stale_socket(self) -> bool:
"""Remove a socket left by a display that died; never a live or foreign file."""
try:
st = os.lstat(self.path)
except FileNotFoundError:
return True
if not stat.S_ISSOCK(st.st_mode):
logger.error("Control socket not started: %s exists and is not a socket", self.path)
return False
probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
probe.settimeout(0.5)
try:
probe.connect(self.path)
except OSError:
os.unlink(self.path) # nothing listening: a previous display's leftover
return True
finally:
probe.close()
logger.warning("Control socket not started: another process is serving %s", self.path)
return False
def _bind(self) -> None:
"""Bind under a temporary name, set mode and group, then rename into place.
The rename makes the socket appear with its final permissions, never
briefly with the process umask's.
"""
tmp = f'{self.path}.{os.getpid()}.tmp'
try:
os.unlink(tmp)
except FileNotFoundError:
pass
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._sock = sock
try:
sock.bind(tmp)
os.chmod(tmp, self.socket_mode)
if self._group is not None and hasattr(os, 'chown'):
try:
os.chown(tmp, -1, self._group)
except OSError as e:
# Not root and not in the group (a dev run): only this user
# (and root) can connect, which is what a dev run needs.
logger.debug("Could not give the control socket group %s: %s",
self._group, e)
# The backlog is only the kernel's queue in front of accept();
# MAX_CLIENTS still bounds what is served. A short one makes a
# burst of clients fail connect() with EAGAIN instead of being
# answered (busy or otherwise).
sock.listen(_LISTEN_BACKLOG)
sock.settimeout(_ACCEPT_POLL_SECONDS)
os.rename(tmp, self.path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
st = os.lstat(self.path)
self._identity = (st.st_dev, st.st_ino)
# -- the render thread's side ------------------------------------------------
@property
def has_pending(self) -> bool:
"""Cheap check for queued commands, for the render thread's fast path."""
return self._pending.is_set()
def drain(self) -> List[QueuedCommand]:
"""Every queued command, oldest first. Called from the render thread."""
commands: List[QueuedCommand] = []
self._pending.clear()
while True:
try:
commands.append(self._queue.get_nowait())
except queue.Empty:
break
return commands
# -- serving -------------------------------------------------------------------
def _accept_loop(self) -> None:
while not self._stopping.is_set():
sock = self._sock
if sock is None:
break
try:
conn, _ = sock.accept()
except socket.timeout:
continue
except OSError as e:
if self._stopping.is_set():
break
logger.warning("Control socket accept failed: %s", e)
time.sleep(0.1)
continue
if not self._slots.acquire(blocking=False):
self._refuse(conn, ErrorCode.BUSY, 'too many connections')
continue
try:
threading.Thread(target=self._serve, args=(conn,), name='ledmatrix-ipc-conn',
daemon=True).start()
except RuntimeError: # can't start a thread: shed the client
self._slots.release()
self._refuse(conn, ErrorCode.BUSY, 'server overloaded')
def _refuse(self, conn: socket.socket, code: str, message: str) -> None:
try:
conn.settimeout(0.2)
conn.sendall(encode_message(Response.failure(None, code, message).to_dict()))
except OSError:
pass
finally:
conn.close()
def _serve(self, conn: socket.socket) -> None:
"""One connection: authenticate, then answer requests until it ends."""
try:
conn.settimeout(self._io_timeout)
peer = peer_credentials(conn)
if self._check_peer and peer is not None and not self._peer_ok(peer):
logger.warning("Control socket refused pid %d (uid %d, gid %d): not root, "
"this user or group %s", peer.pid, peer.uid, peer.gid, self._group)
self._send(conn, Response.failure(None, ErrorCode.FORBIDDEN, 'not permitted'))
return
self._read_requests(conn, peer)
except Exception: # pylint: disable=broad-except
logger.exception("Control socket connection failed")
finally:
try:
conn.close()
except OSError:
pass
self._slots.release()
def _peer_ok(self, peer: PeerCredentials) -> bool:
groups = None
if peer.uid not in (0, self._own_uid) and self._group is not None:
groups = process_groups(peer.pid)
return peer_allowed(peer, self._own_uid, self._group, groups)
def _read_requests(self, conn: socket.socket, peer: Optional[PeerCredentials]) -> None:
reader = FrameReader(MAX_MESSAGE_BYTES)
idle_since = time.monotonic()
message_started: Optional[float] = None
while not self._stopping.is_set():
now = time.monotonic()
if message_started is not None and now - message_started > self._message_timeout:
logger.debug("Control socket: dropping a client too slow to send a message")
return
if message_started is None and now - idle_since > self._idle_timeout:
return
try:
data = conn.recv(4096)
except socket.timeout:
continue
except OSError:
return
if not data:
return # closed, possibly mid-message: nothing to answer
try:
lines = reader.feed(data)
except ProtocolError as e:
self._send(conn, Response.failure(None, e.code, e.message))
return # can't find the next message boundary: hang up
for line in lines:
if not self._send(conn, self.handle_line(line, peer)):
return
if reader.pending:
if message_started is None or lines:
message_started = time.monotonic()
else:
message_started = None
idle_since = time.monotonic()
def _send(self, conn: socket.socket, response: Response) -> bool:
try:
data = encode_message(response.to_dict())
except ProtocolError as e:
# A status snapshot too big (or not JSON) to send is a display bug.
logger.error("Control socket response not sent: %s", e.message)
data = encode_message(Response.failure(
response.id, ErrorCode.INTERNAL, 'response could not be encoded').to_dict())
try:
conn.sendall(data)
return True
except OSError:
return False
# -- requests --------------------------------------------------------------------
def handle_line(self, line: bytes, peer: Optional[PeerCredentials] = None) -> Response:
"""Answer one request line. Never raises."""
request_id: Optional[str] = None
try:
obj = decode_message(line)
raw_id = obj.get('id')
request_id = raw_id if isinstance(raw_id, str) and len(raw_id) <= 128 else None
request = Request.from_dict(obj)
request_id = request.id
return self._dispatch(request, peer)
except ProtocolError as e:
return Response.failure(e.request_id or request_id, e.code, e.message)
except Exception: # pylint: disable=broad-except
logger.exception("Control socket handler failed")
return Response.failure(request_id, ErrorCode.INTERNAL, 'internal error')
def _dispatch(self, request: Request, peer: Optional[PeerCredentials]) -> Response:
if request.cmd == Command.HELLO:
# Exempt from the envelope version check: this is how a client
# that speaks other versions finds out which ones we share.
hello = HelloArgs.from_dict(request.args)
version = negotiate_version(hello.versions)
if version is None:
return Response.failure(
request.id, ErrorCode.UNSUPPORTED_VERSION,
f'no common protocol version; this display speaks {list(SUPPORTED_VERSIONS)}')
result: HelloResult = {
'version': version,
'versions': list(SUPPORTED_VERSIONS),
'commands': list(COMMANDS),
'max_message_bytes': MAX_MESSAGE_BYTES,
'server': 'ledmatrix-display',
}
return Response.success(request.id, dict(result), v=version)
if request.v not in SUPPORTED_VERSIONS:
return Response.failure(
request.id, ErrorCode.UNSUPPORTED_VERSION,
f'protocol version {request.v} is not supported; '
f'this display speaks {list(SUPPORTED_VERSIONS)}')
try:
args = parse_args(request.cmd, request.args)
except ProtocolError as e:
return Response.failure(request.id, e.code, e.message, v=request.v)
if request.cmd == Command.PING:
return Response.success(request.id, {'pong': True}, v=request.v)
if request.cmd == Command.ON_DEMAND_STATUS:
if self._status_provider is None:
return Response.failure(request.id, ErrorCode.INTERNAL, 'no status available',
v=request.v)
return Response.success(request.id, self._status_provider(), v=request.v)
if request.cmd in QUEUED_COMMANDS and isinstance(args, (OnDemandStartArgs,
OnDemandStopArgs)):
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
received_at=time.time(),
peer_uid=peer.uid if peer is not None else None)
try:
self._queue.put_nowait(command)
except queue.Full:
logger.warning("Control socket queue full; refusing %s %s",
request.cmd, request.id)
return Response.failure(request.id, ErrorCode.BUSY,
'the display is not taking commands right now',
v=request.v)
self._pending.set()
ack: AckResult = {'accepted': True, 'request_id': request.id,
'queued': self._queue.qsize()}
logger.info("Control socket accepted %s %s", request.cmd, request.id)
return Response.success(request.id, dict(ack), v=request.v)
# A command in COMMANDS with no handler here is a bug in this module.
return Response.failure(request.id, ErrorCode.INTERNAL,
f'{request.cmd} is not implemented', v=request.v)
def start_control_server(status_provider: Optional[StatusProvider] = None,
cache_dir: Optional[str] = None,
environ: Optional[Mapping[str, str]] = None) -> Optional[ControlServer]:
"""Start the display's control socket, or return None when it can't run.
None covers Windows, ``LEDMATRIX_CONTROL_SOCKET=off`` and any failure to
bind; in every case the web interface falls back to the file mailbox.
"""
path = server_socket_path(environ)
if path is None:
logger.debug("Control socket disabled or unsupported here; using the file mailbox only")
return None
server = ControlServer(path, status_provider, resolve_socket_group(cache_dir))
return server if server.start() else None
__all__ = [
'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
'peer_allowed', 'peer_credentials', 'process_groups', 'resolve_socket_group',
'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
]