fix(ipc): ticks carry the volatile timestamps, so current-status stays known (#737)

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>
This commit is contained in:
Chuck
2026-10-03 22:00:07 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent ef69201770
commit 41192b9588
7 changed files with 322 additions and 17 deletions
+82
View File
@@ -96,6 +96,27 @@ class TestStateHub:
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
@@ -276,6 +297,40 @@ class TestSubscriptionStore:
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)
@@ -369,6 +424,33 @@ class TestLiveStream:
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)
+134
View File
@@ -468,6 +468,140 @@ class TestRoutes:
assert pkg._plugin_runtime_view().source == 'cache'
# --- freshness over a long-lived subscription ---------------------------------------------
class _Rig:
"""A display and one web subscription on fake clocks, with no socket.
Each second the render thread runs its publish point (unless stopped);
every 5 s the plugin runtime publisher ticks and the subscription gets
what the server would push it: a ``state`` event when the version moved,
else a tick (the short ``changed: false`` answer). The loop's heartbeat
is the time since the render thread last went round.
"""
MODE = 'ncaa_fb_live'
def __init__(self):
from src.ipc import client as control_client
self.wall = FakeClock(1_800_000_000.0)
self.mono = FakeClock(5000.0)
self.last_render = self.mono.now
self.hub = StateHub(loop_probe=lambda: {
'heartbeat_age_seconds': self.mono.now - self.last_render, 'armed': True,
'stale_after': display_watchdog.HEARTBEAT_STALE_SECONDS},
epoch='e1', clock=self.mono, wall_clock=self.wall)
self.dc = _controller(self.hub)
self.dc.current_display_mode = self.MODE
self.dc.mode_to_plugin_id[self.MODE] = 'football-scoreboard'
self.publisher = PluginRuntimePublisher(MagicMock(), _states(), clock=self.mono,
wall_clock=self.wall)
self.publisher.attach_hub(self.hub)
self.sub = control_client.StateSubscription(paths=['/nowhere'], clock=self.mono)
self.render = self.plugins = self.ticks = True
self.sent_version = None
self._publish()
self._push()
def _publish(self):
from src import display_controller as dc_module
with patch.object(dc_module.time, 'monotonic', self.mono), \
patch.object(dc_module.time, 'time', self.wall):
self.dc._publish_current_mode_state_if_changed()
self.last_render = self.mono.now
def _push(self):
snap = self.hub.snapshot(since=self.sent_version, epoch='e1')
self.sub._store(snap, full=bool(snap.get('changed')))
self.sent_version = snap['version']
def run(self, seconds):
for _ in range(int(seconds)):
self.wall.now += 1
self.mono.now += 1
if self.render:
self._publish()
if int(self.mono.now) % 5 == 0:
if self.plugins:
self.publisher.tick()
if self.ticks:
self._push()
def status(self):
return display_state.current_status(self.sub.latest(), now=self.wall.now)
def runtime(self):
return view_from_socket_state(self.sub.latest(), now=self.wall.now,
now_mono=self.mono.now)
class TestFreshnessOverTicks:
"""#735 on the ledpi rig: with one mode on screen for more than two
minutes (a live game, Vegas, a single plugin) current-status read
``mode: null`` from the socket. The version stayed put, so the
subscription's copy kept the ``last_updated`` of the last real change."""
def test_a_mode_on_screen_for_fifteen_minutes_stays_known(self):
rig = _Rig()
for _ in range(15):
rig.run(60)
status = rig.status()
assert status['mode'] == rig.MODE, status
assert rig.wall.now - status['last_updated'] <= 5
# The plugin section, unchanged as long, stays live too.
assert rig.runtime().status == rt.LIVE
assert rig.sub.snapshots == 1 # all of it from ticks
def test_the_route_keeps_answering_from_the_socket(self, web, monkeypatch):
client, cached = web
cached['display_current_state'] = {'mode': 'from-cache', 'last_updated': 1}
rig = _Rig()
rig.run(10 * 60)
monkeypatch.setattr(display_state, 'read_state', rig.sub.latest)
with patch.object(display_state.time, 'time', rig.wall):
data = _data(client, '/api/v3/display/current-status')
assert (data['mode'], data['source']) == (rig.MODE, 'socket')
def test_a_render_thread_that_stops_still_reads_stalled_then_unknown(self):
"""#726's verdicts are unchanged: the socket thread keeps ticking,
but nothing refreshes last_updated and the heartbeat ages."""
rig = _Rig()
rig.run(5 * 60)
rig.render = False
rig.run(display_watchdog.HEARTBEAT_STALE_SECONDS + 5)
assert rig.runtime().status == rt.STALLED
assert display_state.loop_heartbeat_age(rig.sub.latest()) >= \
display_watchdog.HEARTBEAT_STALE_SECONDS
assert rig.status()['mode'] == rig.MODE # not 120 s yet
rig.run(display_state.CURRENT_STATE_MAX_AGE_SECONDS)
assert rig.status() == {'mode': None, 'plugin_id': None, 'last_updated': None}
def test_a_runtime_publisher_that_stops_goes_stale(self):
rig = _Rig()
rig.run(60)
rig.plugins = False
rig.run(rt.STALE_AFTER + 10)
assert rig.runtime().status == rt.STALE
assert rig.status()['mode'] == rig.MODE
def test_when_the_ticks_stop_the_route_falls_back_to_the_cache(self, web, monkeypatch):
from src.ipc import client as control_client
client, cached = web
cached['display_current_state'] = {'mode': 'from-cache', 'last_updated': 1}
rig = _Rig()
rig.run(5 * 60)
rig.ticks = False
rig.run(control_client.SUBSCRIPTION_SILENCE_SECONDS + 1)
assert rig.sub.latest() is None
def no_socket(*args, **kwargs):
raise control_client.ControlError('no_socket')
monkeypatch.setattr(display_state, '_subscription', lambda: rig.sub)
monkeypatch.setattr(control_client, 'state_get', no_socket)
data = _data(client, '/api/v3/display/current-status')
assert (data['mode'], data['source']) == ('from-cache', 'cache')
# --- end to end over a real socket -------------------------------------------------------
@needs_unix_sockets