Files
LEDMatrix/test/test_ipc_contract.py
T
ChuckandClaude Opus 5.5 397333ae59 feat(ipc)!: remove the cache-key mailboxes, control socket stage 5 (for the release after 3.8.1) (#773)
* feat(ipc)!: remove the cache-key mailboxes (control socket stage 5)

The control socket is now the only way the web interface sends the display a
command. The display stops reading display_on_demand_request and
plugin_error_clear_request, and the web interface stops writing them.

- Display: no mailbox poll (MailboxWatch, the 1 s / 0.25 s cadence,
  _consume_on_demand_request, the deprecation log) and no persisted
  display_on_demand_processed_id guard; the error publisher reads no clear
  request. CacheManager.file_signature and MailboxWatch are removed.
- A write to either retired key is dropped by CacheManager.save_cache and
  logged once per writer, naming the plugin from the call stack (or the
  request's plugin_id), with the API to move to.
- Web: on-demand start with no display listening starts the service (when
  start_service) and sends the request again once the socket answers (45 s,
  10 s for a running service without a socket yet); every other failure is
  a 503 (400 for invalid_args). Stop answers 503 when no display listens,
  unless stop_service. errors/clear answers 503 with a reason-specific
  message instead of writing a request; clear_pending is always false.
  src.ipc.client.should_fall_back is replaced by display_not_listening.
- Kept: display_current_state, display_on_demand_state,
  plugin_runtime_snapshot and the heartbeat (read whenever the socket cannot
  answer), and display_on_demand_config (the display's resume record).

Tests: mailbox-only tests removed (test_on_demand_mailbox.py, the mailbox
cadence, file_signature and MailboxWatch tests); tests that injected
requests through the mailbox now use the socket queue or a plugin's
in-process request. The run-loop harness sends on-demand requests over its
fake control socket, so four golden traces change: on-demand starts and
stops land at the request instant instead of the next 0.25 s mailbox look
(one frame fewer on the screen they end), and in vegas.json within one
frame instead of 263 ms, which shifts the later 1 s-throttled WiFi-notice
check by under a second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: the on-demand routes' other callers ack over the socket, not the mailbox

test_api_v3_bool_coercion, test_api_v3_lazy_plugin_discovery and
test_web_api's stop test checked the mailbox write; they now get an ack
from a mocked control socket. The stop route touches no manager any more,
so it leaves the removed-catch-all sample. A request that was sent is
never 'not listening' (pins the sent check the mutation run let through).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web): a start that waits for the display answers 202 and is delivered in the background

The start route held a request open for up to 45 s while a cold-started
display loaded its plugins; the MQTT bridge (15 s timeout) and browsers
reported a failure for a request that was then delivered.

Now, when no display is listening, the route starts the service if asked
and answers 202 with status "starting" at once. A single worker in the web
process (web_interface/on_demand_dispatch.py) sends the request until the
display acknowledges it or the wait runs out (45 s cold start, 10 s for a
running service without a socket yet). A newer start supersedes the
pending one; a stop cancels it (and succeeds, with cancelled_request_id,
even with no display listening). The outcome is reported by
/display/on-demand/status (source "web": starting, or error with
start-timeout or the socket's reason, until the display publishes
something newer) and by /display/current-status as on_demand_pending.

Callers: the web UI's on-demand modal and "Preview on display" treat
"starting" as taken (an info toast); the MQTT bridge already treats any
non-error 2xx as success (now pinned by a test).

Tests: the dispatcher (ack, retry then ack, start-timeout, other failures,
superseded, an in-flight ack for a superseded start, stop while pending,
a per-start wait, outcome lifetime); the routes (202, status routes while
pending and after a timeout, a later display state replacing the failure,
stop while pending, a new start superseding); a JS suite for app.js.
Mutation check: 20 mutants on the worker, the routes and app.js, 20 killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): a cancelled on-demand start never reaches the display after its replacement

CI caught a race in test_a_new_start_supersedes_the_pending_one: the
dispatcher's worker could read the old start, the route cancel it and send
the new one, and the worker's send of the old one land after it. cancel()
now waits out a send already in flight (the worker holds a send lock while
it reads the pending start and sends it), so whatever the caller sends next
lands after it. Pinned by test_a_cancel_waits_for_a_send_in_flight, which
fails without the wait.

The Linux-only TestRealSocket test for a display that went away now
expects the 202 and the start-timeout that follows, as the route answers
since the background dispatcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): a delivered on-demand start reads as starting until the display acts on it

On ledpi (three cold starts) the display acknowledged the start as its
socket opened, then took ~5 s to act on it while Vegas built its first
strip; the status routes meanwhile showed the display's own idle state, so
a UI polling every 700 ms flashed idle.

The display's on-demand state now names the request it answers
(request_id). A delivered start keeps reading as status "starting" with
delivered: true, in /display/on-demand/status and as on_demand_pending in
/display/current-status, until the display publishes state for that
request id (a display without the field: any state newer than the
delivery), for at most DELIVERED_SHOWN_SECONDS (30 s). The display's
startup state, which can be published after the acknowledgement, names no
request and does not end it.

Tests: stays starting against the startup idle state (no id, an older id);
the matching active state and the matching error take over; an older
display's newer state takes over; the 30 s cap; the display's state names
its request. Mutation check: 11 mutants, 11 killed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(display): on_demand_request_id has a class default for controllers built without __init__

_on_demand_state now publishes it, and test_state_stream_readers builds
controllers with __new__.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test: the case-spelling on-demand test patches _send_on_demand now the mailbox is gone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* docs: blank line before 3.8.7 heading

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:31:35 -04:00

281 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 REST route sends;
* 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)
wire = _wire({'plugin_id': 'clock', 'mode': 'clock_main', 'duration': 45.0,
'pinned': True})
assert OnDemandStartArgs.from_dict(wire) == 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 = {Command.ON_DEMAND_START: {'plugin_id': 'p'},
Command.PLUGIN_RELOAD: {'plugin_id': 'p'},
Command.BRIGHTNESS_SET: {'brightness': 50},
Command.ERRORS_CLEAR: {'cutoff': 1790000000.0}}.get(cmd, {})
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 display's on-demand handler, so they
must look exactly like the request dict it takes."""
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