mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
State stream ticks carry the volatile timestamps (display.last_updated, plugins.published_at), so current-status and the plugin runtime stay fresh while one mode stays on screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
25 KiB
Python
574 lines
25 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_the_short_answer_carries_the_latest_volatile_values(self, hub):
|
|
"""A version that stays put must not freeze the timestamps a reader
|
|
judges freshness by: the short answer (a tick) carries them."""
|
|
hub.publish('on_demand', {'active': False, 'last_updated': 2.0, 'remaining': 0.0},
|
|
volatile=('last_updated', 'remaining'))
|
|
hub.publish('brightness', {'brightness': 50})
|
|
version = hub.version
|
|
assert not hub.publish('display', _display(updated=1234.5), volatile=('last_updated',))
|
|
short = hub.snapshot(since=version, epoch='e1')
|
|
assert short['changed'] is False and 'state' not in short
|
|
assert short['volatile'] == {'display': {'last_updated': 1234.5},
|
|
'on_demand': {'last_updated': 2.0, 'remaining': 0.0}}
|
|
# The full answer has them in place already.
|
|
assert 'volatile' not in hub.snapshot()
|
|
|
|
def test_volatile_values_skip_sections_without_any(self):
|
|
hub = StateHub(epoch='e1')
|
|
hub.publish('brightness', {'brightness': 50})
|
|
hub.publish('plugins', None, volatile=('published_at',))
|
|
assert hub.snapshot(since=hub.version, epoch='e1')['volatile'] == {}
|
|
|
|
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_refreshes_the_volatile_timestamps(self, hub):
|
|
"""The bug from the ledpi rig: the same mode on screen for minutes
|
|
left the reader's display.last_updated at the last real change."""
|
|
clock = FakeClock()
|
|
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
|
|
sub._store(hub.snapshot(), full=True)
|
|
hub.publish('display', _display(updated=9999.0), volatile=('last_updated',))
|
|
sub._store(hub.snapshot(since=1, epoch='e1'), full=False)
|
|
latest = sub.latest()
|
|
assert latest['state']['display']['last_updated'] == 9999.0
|
|
assert latest['state']['display']['mode'] == 'clock'
|
|
assert latest['version'] == 1
|
|
|
|
def test_a_tick_adds_no_section_or_key_and_ignores_another_version(self, hub):
|
|
clock = FakeClock()
|
|
sub = client.StateSubscription(paths=['/nowhere'], clock=clock)
|
|
snap = hub.snapshot()
|
|
sub._store(snap, full=True)
|
|
updated = snap['state']['display']['last_updated']
|
|
sub._store({'version': 1, 'epoch': 'e1',
|
|
'volatile': {'display': {'new_key': 1}, 'plugins': {'published_at': 5.0},
|
|
'on_demand': 'junk'}}, full=False)
|
|
state = sub.latest()['state']
|
|
assert 'new_key' not in state['display'] and state['plugins'] is None
|
|
assert state['on_demand'] is None
|
|
# A tick for a version this copy is not at says nothing about it.
|
|
sub._store({'version': 7, 'epoch': 'e1',
|
|
'volatile': {'display': {'last_updated': 5.0}}}, full=False)
|
|
assert sub.latest()['state']['display']['last_updated'] == updated
|
|
# A display from before ticks carried them: the loop still refreshes.
|
|
sub._store({'version': 1, 'epoch': 'e1', 'loop': {'heartbeat_age_seconds': 3.0}},
|
|
full=False)
|
|
assert sub.latest()['loop'] == {'heartbeat_age_seconds': 3.0}
|
|
|
|
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_ticks_carry_the_timestamps_the_version_ignores(self, live, path):
|
|
_, hub = live
|
|
sock, reader = self._subscribe(path)
|
|
pending = []
|
|
self._next(sock, reader, pending)
|
|
hub.publish('display', _display(updated=4242.0), volatile=('last_updated',))
|
|
for _ in range(3): # a tick already on its way may predate the publish
|
|
tick = self._next(sock, reader, pending)
|
|
assert tick['event'] == 'tick'
|
|
if tick['result']['volatile'] == {'display': {'last_updated': 4242.0}}:
|
|
break
|
|
else:
|
|
pytest.fail(f'no tick carried the new last_updated: {tick}')
|
|
sock.close()
|
|
|
|
def test_a_subscription_keeps_last_updated_current(self, live, path):
|
|
_, hub = live
|
|
sub = client.StateSubscription(paths=[path]).start()
|
|
try:
|
|
assert _wait_until(lambda: sub.latest() is not None)
|
|
hub.publish('display', _display(updated=4242.0), volatile=('last_updated',))
|
|
assert _wait_until(lambda: (sub.latest() or {}).get('state', {})
|
|
.get('display', {}).get('last_updated') == 4242.0)
|
|
assert sub.snapshots == 1 # ticks, not new snapshots
|
|
finally:
|
|
sub.stop()
|
|
|
|
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'
|