Files
LEDMatrix/test/test_ipc_state_stream.py
T
ChuckandClaude Opus 5.5 515248b34e feat(ipc): control socket stage 3 - a state stream replaces polled cache keys (#735)
Adds state.get / state.subscribe to the display's control socket (StateHub in src/ipc/server.py). The web interface holds one subscription per process (web_interface/display_state.py) and reads current-status, on-demand status, plugin runtime and /health's display_loop from it, falling back to the cache keys and heartbeat file. While the socket serves readers, display_current_state and plugin_runtime_snapshot are written less often (about 1.5 instead of 5 cache writes a minute for 15 s screens).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:56:47 -04:00

492 lines
20 KiB
Python

"""Control socket stage 3: the display's state over the socket (state.get,
state.subscribe) instead of polled cache keys.
* ``StateHub`` and the request handling are plain Python, tested on every
platform: versions move only when something a reader sees changes, the
``since``/``epoch`` short answer, an oversized snapshot drops only its
plugin section, and publishing never waits for a reader.
* ``TestLiveStream`` needs AF_UNIX (Linux, WSL, a Pi; skipped on Windows): a
real server pushing to real subscribers -- several at once, one that never
reads, one whose display goes away and comes back.
"""
import json
import socket
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, StateHub, fit_snapshot
needs_unix_sockets = pytest.mark.skipif(not c.socket_supported(),
reason='AF_UNIX sockets are Linux/macOS only')
class FakeClock:
def __init__(self, now=1000.0):
self.now = now
def __call__(self):
return self.now
def _req(cmd, args=None, rid='r1', v=1):
return json.dumps({'v': v, 'id': rid, 'cmd': cmd, 'args': args or {}}).encode()
def _loop(age=1.0):
return lambda: {'heartbeat_age_seconds': age, 'armed': age is not None,
'stale_after': 60.0}
def _display(mode='clock', active=True, updated=None):
return {'mode': mode, 'plugin_id': mode, 'mode_index': 0, 'total_modes': 2,
'on_demand_active': False, 'is_display_active': active,
'last_updated': time.time() if updated is None else updated}
@pytest.fixture
def hub():
h = StateHub(loop_probe=_loop(), epoch='e1', pid=4242)
h.publish('display', _display(), volatile=('last_updated',))
return h
# --- the hub ---------------------------------------------------------------------
class TestStateHub:
def test_snapshot_has_every_section_and_the_envelope(self, hub):
snap = hub.snapshot()
assert snap['schema'] == c.STATE_SCHEMA
assert (snap['version'], snap['epoch'], snap['pid']) == (1, 'e1', 4242)
assert snap['changed'] is True
assert list(snap['state']) == list(c.STATE_SECTIONS)
assert snap['state']['display']['mode'] == 'clock'
assert snap['state']['on_demand'] is None # not published yet
assert snap['state']['loop'] == snap['loop'] == _loop()()
def test_only_a_real_change_is_a_new_version(self, hub):
assert not hub.publish('display', _display(updated=1.0), volatile=('last_updated',))
assert hub.version == 1
# ...but the latest timestamp is what a reader gets.
assert hub.snapshot()['state']['display']['last_updated'] == 1.0
assert hub.publish('display', _display(mode='weather'), volatile=('last_updated',))
assert hub.version == 2
assert hub.publish('brightness', {'brightness': 50})
assert not hub.publish('brightness', {'brightness': 50})
assert hub.version == 3
def test_the_published_dict_is_copied(self, hub):
value = {'brightness': 50}
hub.publish('brightness', value)
value['brightness'] = 10
assert hub.snapshot()['state']['brightness'] == {'brightness': 50}
def test_since_the_current_version_is_the_short_answer(self, hub):
short = hub.snapshot(since=1, epoch='e1')
assert short['changed'] is False and 'state' not in short
assert short['version'] == 1 and short['loop']['heartbeat_age_seconds'] == 1.0
# An older version, or another epoch (a restarted display): the full state.
assert hub.snapshot(since=0, epoch='e1')['changed'] is True
assert hub.snapshot(since=1, epoch='other')['changed'] is True
assert hub.snapshot(since=1)['changed'] is True
def test_each_display_run_has_its_own_epoch(self):
assert StateHub().epoch != StateHub().epoch
def test_the_loop_is_measured_when_asked(self):
ages = iter([1.0, 61.0])
hub = StateHub(loop_probe=lambda: {'heartbeat_age_seconds': next(ages),
'armed': True, 'stale_after': 60.0})
assert hub.snapshot()['loop']['heartbeat_age_seconds'] == 1.0
# Nothing was published -- a stuck render thread publishes nothing --
# and the age still moves.
assert hub.snapshot()['loop']['heartbeat_age_seconds'] == 61.0
def test_a_failing_probe_is_unknown_not_raised(self):
def boom():
raise RuntimeError('x')
assert StateHub(loop_probe=boom).loop()['heartbeat_age_seconds'] is None
assert StateHub().loop()['armed'] is False
def test_wait_for_change(self, hub):
assert hub.wait_for_change(1, 0.01) is False
threading.Timer(0.05, lambda: hub.publish('brightness', {'b': 1})).start()
start = time.monotonic()
assert hub.wait_for_change(1, 5.0) is True
assert time.monotonic() - start < 2.0
def test_wait_ends_on_stop(self, hub):
stop = threading.Event()
def end():
stop.set()
hub.wake()
threading.Timer(0.05, end).start()
start = time.monotonic()
assert hub.wait_for_change(1, 5.0, stop) is False
assert time.monotonic() - start < 2.0
def test_readers_active(self):
clock = FakeClock()
hub = StateHub(clock=clock, reader_window=60.0)
assert not hub.readers_active()
hub.note_read()
clock.now += 59
assert hub.readers_active()
clock.now += 2
assert not hub.readers_active()
hub.subscriber_joined()
clock.now += 1000
assert hub.readers_active() # for as long as it is connected
hub.subscriber_left()
assert hub.readers_active() # and one window after
clock.now += 61
assert not hub.readers_active()
def test_publishing_never_waits_for_readers(self, hub):
"""Many readers snapshotting and waiting at once: every publish from
the 'render thread' still returns in well under a frame."""
stop = threading.Event()
def reader():
version = 0
while not stop.is_set():
hub.wait_for_change(version, 0.01)
version = hub.snapshot()['version']
threads = [threading.Thread(target=reader, daemon=True) for _ in range(8)]
for t in threads:
t.start()
worst = 0.0
try:
for i in range(500):
start = time.perf_counter()
hub.publish('display', _display(mode=f'm{i}'), volatile=('last_updated',))
worst = max(worst, time.perf_counter() - start)
finally:
stop.set()
for t in threads:
t.join(2)
assert worst < 0.05
class TestFitSnapshot:
def test_small_is_unchanged(self, hub):
snap = hub.snapshot()
assert fit_snapshot(snap) is snap
def test_too_large_drops_only_the_plugins(self, hub):
plugins = {f'p{i}': {'error': {'message': 'x' * 200}} for i in range(400)}
hub.publish('plugins', {'schema': 1, 'plugins': plugins})
fitted = fit_snapshot(hub.snapshot())
assert fitted['state']['plugins'] is None
assert fitted['truncated'] == ['plugins']
assert fitted['state']['display']['mode'] == 'clock'
c.encode_message({'v': 1, 'id': 'x' * 128, 'ok': True, 'result': fitted})
# --- the commands, without a socket ------------------------------------------------
class TestHandleLine:
def test_state_get(self, hub):
server = ControlServer('/unused.sock', state_hub=hub)
resp = server.handle_line(_req(Command.STATE_GET))
assert resp.ok and resp.result['version'] == 1
assert resp.result['state']['display']['mode'] == 'clock'
assert hub.readers_active()
def test_state_get_since(self, hub):
server = ControlServer('/unused.sock', state_hub=hub)
resp = server.handle_line(_req(Command.STATE_GET, {'since': 1, 'epoch': 'e1'}))
assert resp.ok and resp.result['changed'] is False and 'state' not in resp.result
@pytest.mark.parametrize('args', [{'since': -1}, {'since': 'x'}, {'since': True},
{'epoch': 5}, {'epoch': 'x' * 200}])
def test_bad_args(self, hub, args):
server = ControlServer('/unused.sock', state_hub=hub)
resp = server.handle_line(_req(Command.STATE_GET, args))
assert not resp.ok and resp.error.code == ErrorCode.INVALID_ARGS
def test_without_a_hub_it_is_an_error_not_a_crash(self):
server = ControlServer('/unused.sock')
for cmd in (Command.STATE_GET, Command.STATE_SUBSCRIBE):
resp = server.handle_line(_req(cmd))
assert not resp.ok and resp.error.code == ErrorCode.INTERNAL
def test_hello_lists_the_new_commands(self, hub):
resp = ControlServer('/unused.sock', state_hub=hub).handle_line(
_req(Command.HELLO, {'versions': [1]}))
assert {Command.STATE_GET, Command.STATE_SUBSCRIBE} <= set(resp.result['commands'])
def test_state_commands_are_not_queued(self, hub):
server = ControlServer('/unused.sock', state_hub=hub)
server.handle_line(_req(Command.STATE_GET))
assert not server.has_pending and server.drain() == []
class TestEvents:
def test_round_trip(self):
event = c.StateEvent('s1', c.StateEventKind.TICK, {'version': 3})
obj = json.loads(c.encode_message(event.to_dict()))
assert c.is_event(obj)
assert c.StateEvent.from_dict(obj) == event
def test_a_response_is_not_an_event(self):
assert not c.is_event(c.Response.success('x', {}).to_dict())
@pytest.mark.parametrize('obj', [
[], {'v': 1, 'id': 's', 'event': 'other', 'result': {}},
{'v': 1, 'id': 's', 'event': 'state', 'result': []},
{'v': '1', 'id': 's', 'event': 'state', 'result': {}},
{'v': 1, 'id': None, 'event': 'state', 'result': {}},
])
def test_bad_events_are_refused(self, obj):
with pytest.raises(c.ProtocolError):
c.StateEvent.from_dict(obj)
class TestSubscriptionStore:
"""StateSubscription's bookkeeping, without a socket."""
def test_latest_is_none_until_a_snapshot_and_after_silence(self, hub):
clock = FakeClock()
sub = client.StateSubscription(paths=['/nowhere'], silence=15.0, clock=clock)
assert sub.latest() is None
sub._store(hub.snapshot(), full=True)
latest = sub.latest()
assert latest['version'] == 1 and latest['received_mono'] == clock.now
clock.now += 16
assert sub.latest() is None
def test_a_tick_refreshes_the_loop_and_keeps_the_state(self, hub):
clock = FakeClock()
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
sub._store(hub.snapshot(), full=True)
clock.now += 10
tick = {'version': 1, 'epoch': 'e1', 'served_at': 5.0,
'loop': {'heartbeat_age_seconds': 70.0, 'armed': True, 'stale_after': 60.0}}
sub._store(tick, full=False)
latest = sub.latest()
assert latest['state']['display']['mode'] == 'clock'
assert latest['state']['loop']['heartbeat_age_seconds'] == 70.0
assert latest['received_mono'] == clock.now
def test_a_tick_from_another_epoch_is_ignored(self, hub):
clock = FakeClock()
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
sub._store(hub.snapshot(), full=True)
clock.now += 10
sub._store({'version': 9, 'epoch': 'other', 'loop': {}}, full=False)
assert sub.latest()['received_mono'] == clock.now - 10
def test_loop_age_counts_the_time_since_it_arrived(self, hub):
snap = dict(hub.snapshot(), received_mono=100.0)
assert client.snapshot_loop_age(snap, now_mono=104.0) == 5.0
snap['loop'] = {'heartbeat_age_seconds': None}
assert client.snapshot_loop_age(snap, now_mono=104.0) is None
# --- a real socket ------------------------------------------------------------------
def _wait_until(predicate, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
time.sleep(0.01)
return predicate()
@needs_unix_sockets
class TestLiveStream:
@pytest.fixture
def path(self, tmp_path):
return str(tmp_path / 'control.sock')
@pytest.fixture
def live(self, path, hub):
server = ControlServer(path, state_hub=hub, keepalive=0.2, io_timeout=0.5,
max_clients=2, max_subscribers=3)
assert server.start()
yield server, hub
server.close()
def _subscribe(self, path):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(5)
sock.connect(path)
sock.sendall(c.encode_message({'v': 1, 'id': 's1', 'cmd': Command.STATE_SUBSCRIBE,
'args': {}}))
return sock, c.FrameReader()
def _next(self, sock, reader, pending):
while not pending:
data = sock.recv(65536)
assert data, 'the display hung up'
pending.extend(reader.feed(data))
return json.loads(pending.pop(0))
def test_state_get_over_the_socket(self, live, path):
snap = client.state_get(paths=[path])
assert snap['version'] == 1 and snap['state']['display']['mode'] == 'clock'
short = client.state_get(since=snap['version'], epoch=snap['epoch'], paths=[path])
assert short['changed'] is False
def test_subscribe_answers_then_pushes_changes_in_order(self, live, path):
_, hub = live
sock, reader = self._subscribe(path)
pending = []
first = self._next(sock, reader, pending)
assert first['ok'] is True and first['id'] == 's1' and first['result']['version'] == 1
hub.publish('display', _display(mode='weather'), volatile=('last_updated',))
versions = []
while True:
msg = self._next(sock, reader, pending)
assert c.is_event(msg) and msg['id'] == 's1'
if msg['event'] == 'state':
versions.append(msg['result']['version'])
assert msg['result']['state']['display']['mode'] == 'weather'
break
hub.publish('brightness', {'brightness': 30})
while True:
msg = self._next(sock, reader, pending)
if msg['event'] == 'state':
versions.append(msg['result']['version'])
break
assert versions == [2, 3]
sock.close()
def test_ticks_keep_a_quiet_subscription_alive(self, live, path):
sock, reader = self._subscribe(path)
pending = []
self._next(sock, reader, pending)
kinds = [self._next(sock, reader, pending)['event'] for _ in range(3)]
assert kinds == ['tick', 'tick', 'tick']
sock.close()
def test_a_burst_is_coalesced_to_the_latest(self, live, path):
_, hub = live
sock, reader = self._subscribe(path)
pending = []
self._next(sock, reader, pending)
for i in range(200):
hub.publish('display', _display(mode=f'm{i}'), volatile=('last_updated',))
seen = []
while not seen or seen[-1] != hub.version:
msg = self._next(sock, reader, pending)
if msg['event'] == 'state':
seen.append(msg['result']['version'])
assert seen == sorted(seen) and len(seen) < 200
assert msg['result']['state']['display']['mode'] == 'm199'
sock.close()
def test_several_subscribers_and_commands_still_get_a_slot(self, live, path):
server, hub = live
subs = [client.StateSubscription(paths=[path]).start() for _ in range(3)]
try:
assert _wait_until(lambda: all(s.latest() for s in subs))
assert hub.subscribers == 3
# max_clients is 2 and three streams are open: subscribers gave
# their request slots back.
for _ in range(4):
assert client.ping(paths=[path]) == {'pong': True}
hub.publish('display', _display(mode='weather'), volatile=('last_updated',))
assert _wait_until(lambda: all(
s.latest()['state']['display']['mode'] == 'weather' for s in subs))
# A fourth is over the subscriber bound.
sock, reader = self._subscribe(path)
refused = self._next(sock, reader, [])
assert refused['ok'] is False and refused['error']['code'] == ErrorCode.BUSY
sock.close()
finally:
for s in subs:
s.stop()
assert _wait_until(lambda: hub.subscribers == 0)
def test_a_subscriber_that_never_reads_blocks_nobody(self, live, path):
"""The render thread keeps publishing at full speed, a reading
subscriber keeps up, and the stuck one is dropped."""
_, hub = live
stuck = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
stuck.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4096)
stuck.connect(path)
stuck.sendall(c.encode_message({'v': 1, 'id': 'stuck',
'cmd': Command.STATE_SUBSCRIBE, 'args': {}}))
good = client.StateSubscription(paths=[path]).start()
try:
assert _wait_until(lambda: good.latest() is not None)
assert _wait_until(lambda: hub.subscribers == 2)
big = {f'p{i}': {'state': 'enabled', 'note': 'x' * 100} for i in range(100)}
worst = 0.0
deadline = time.monotonic() + 3.0
i = 0
while time.monotonic() < deadline:
i += 1
start = time.perf_counter()
hub.publish('plugins', {'schema': 1, 'n': i, 'plugins': big})
worst = max(worst, time.perf_counter() - start)
time.sleep(0.005)
assert worst < 0.05, f'a publish took {worst:.3f}s'
# io_timeout is 0.5 s: the stuck one is gone, the good one is not.
assert _wait_until(lambda: hub.subscribers == 1)
assert _wait_until(lambda: (good.latest() or {}).get('state', {})
.get('plugins', {}).get('n') == i)
finally:
stuck.close()
good.stop()
def test_close_ends_the_streams(self, path, hub):
server = ControlServer(path, state_hub=hub, keepalive=30.0)
assert server.start()
sub = client.StateSubscription(paths=[path]).start()
try:
assert _wait_until(lambda: sub.latest() is not None)
start = time.monotonic()
server.close()
assert _wait_until(lambda: hub.subscribers == 0, timeout=3.0)
assert time.monotonic() - start < 3.0
assert _wait_until(lambda: sub.latest() is None, timeout=3.0)
finally:
sub.stop()
def test_the_subscription_follows_a_restarted_display(self, path, hub):
server = ControlServer(path, state_hub=hub, keepalive=0.2)
assert server.start()
sub = client.StateSubscription(paths=[path]).start()
try:
assert _wait_until(lambda: sub.latest() is not None)
server.close()
assert _wait_until(lambda: sub.latest() is None)
hub2 = StateHub(loop_probe=_loop(), epoch='e2')
hub2.publish('display', _display(mode='restarted'), volatile=('last_updated',))
server2 = ControlServer(path, state_hub=hub2, keepalive=0.2)
assert server2.start()
try:
assert _wait_until(lambda: (sub.latest() or {}).get('epoch') == 'e2',
timeout=8.0)
assert sub.latest()['state']['display']['mode'] == 'restarted'
finally:
server2.close()
finally:
sub.stop()
def test_a_server_without_state_answers_an_error(self, path):
server = ControlServer(path) # no hub
assert server.start()
try:
with pytest.raises(client.ControlError) as err:
client.state_get(paths=[path])
assert err.value.reason == ErrorCode.INTERNAL
finally:
server.close()
def test_no_display_is_no_socket(self, path):
with pytest.raises(client.ControlError) as err:
client.state_get(paths=[path])
assert err.value.reason == 'no_socket'