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>
This commit is contained in:
Chuck
2026-10-03 12:56:47 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6bf7c3fa51
commit 515248b34e
16 changed files with 2487 additions and 97 deletions
+4 -2
View File
@@ -998,12 +998,14 @@ def _run_startup_reconciliation() -> None:
try:
from src.plugin_system.state_reconciliation import StateReconciliation
from src.plugin_system.plugin_runtime import read_plugin_runtime
# The display's runtime view: its control socket's state stream when
# it serves one, else the cache snapshot (both judged the same way).
from web_interface.blueprints.api_v3 import _plugin_runtime_view
reconciler = StateReconciliation(
config_manager=config_manager,
plugins_dir=plugins_dir,
store_manager=plugin_store_manager,
runtime_source=lambda: read_plugin_runtime(api_v3.cache_manager),
runtime_source=_plugin_runtime_view,
)
result = reconciler.reconcile_state()
if result.inconsistencies_found:
+9 -1
View File
@@ -735,8 +735,16 @@ def _plugin_runtime_view():
Only a ``live`` view reports those facts; a stale, stopped or missing
snapshot answers None for them (see src/plugin_system/plugin_runtime.py).
The display's state stream over the control socket comes first
(``source: "socket"``); without it, the cache snapshot and the heartbeat
file (``source: "cache"``). Both are judged by the same rules.
"""
from src.plugin_system.plugin_runtime import read_plugin_runtime
from src.plugin_system.plugin_runtime import read_plugin_runtime, view_from_socket_state
from web_interface import display_state
view = view_from_socket_state(display_state.read_state())
if view is not None:
return view
return read_plugin_runtime(getattr(api_v3, 'cache_manager', None))
+31 -17
View File
@@ -9,7 +9,7 @@ from web_interface.blueprints.api_v3 import (
_get_display_service_status, _socket_reason_code, _stop_display_service, api_v3,
jsonify, logger, request, uuid,
)
from web_interface import display_preview
from web_interface import display_preview, display_state
import web_interface.blueprints.api_v3 as _pkg
from src.ipc import client as control_client
# Read through the module rather than bound by value: tests patch these
@@ -163,13 +163,22 @@ def get_display_modes():
return jsonify({'status': 'success', 'data': {'modes': modes}})
@api_v3.route('/display/on-demand/status', methods=['GET'])
def get_on_demand_status():
"""Return the current on-demand display state."""
cache = _cache_manager()
# memory_ttl=0: the display service writes this key, so only the file
# is current. This process's memory tier would keep serving the first
# copy it read for the full max_age -- "active" for two minutes after
# the display had already stopped.
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
"""Return the current on-demand display state.
From the display's state stream over the control socket when it is
available (``source: "socket"``), else the cache key it also writes
(``source: "cache"``).
"""
state = display_state.on_demand_state(display_state.read_state())
source = 'socket'
if state is None:
source = 'cache'
cache = _cache_manager()
# memory_ttl=0: the display service writes this key, so only the file
# is current. This process's memory tier would keep serving the first
# copy it read for the full max_age -- "active" for two minutes after
# the display had already stopped.
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
if state is None:
state = {
'active': False,
@@ -181,7 +190,8 @@ def get_on_demand_status():
'status': 'success',
'data': {
'state': state,
'service': service_status
'service': service_status,
'source': source,
}
})
@api_v3.route('/display/on-demand/start', methods=['POST'])
@@ -327,18 +337,22 @@ def stop_on_demand_display():
def get_current_display_status():
"""Return the display mode/plugin currently intended to be shown.
Published by the display process (display_controller._publish_current_mode_state)
to the shared cache whenever the active mode changes, so the web UI (e.g. the
System Logs page) can show what's on screen without querying the display
process directly.
Read from the display's state stream over the control socket when it is
available (``source: "socket"``). Otherwise from what the display
publishes to the shared cache (display_controller._publish_current_mode_state)
when the active mode changes (``source: "cache"``).
"""
cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status.
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
state = display_state.current_status(display_state.read_state())
source = 'socket'
if state is None:
source = 'cache'
cache = _cache_manager()
# memory_ttl=0: written by the display service; see get_on_demand_status.
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
if state is None:
state = {
'mode': None,
'plugin_id': None,
'last_updated': None,
}
return jsonify({'status': 'success', 'data': state})
return jsonify({'status': 'success', 'data': dict(state, source=source)})
+13 -3
View File
@@ -17,7 +17,7 @@ from src.common.path_safety import safe_path_component
from src import display_watchdog
from src.common import sync_manager as _sync
from src import error_aggregator as _errors
from web_interface import display_preview
from web_interface import display_preview, display_state
from web_interface.auth import request_is_authenticated
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
@@ -100,20 +100,30 @@ def get_health():
# service is "active". No heartbeat at all (the dev server, an older
# display) is not a failure: the preview-frame check below is then
# the only signal, as it always was.
# The display reports the same beat's age over the control socket's
# state stream, measured in memory; the file is the fallback.
try:
heartbeat = display_watchdog.read_heartbeat(display_watchdog.HEARTBEAT_PATH)
age = display_watchdog.heartbeat_age(heartbeat) if heartbeat else None
snapshot = display_state.read_state()
if snapshot is not None:
source = 'socket'
age = display_state.loop_heartbeat_age(snapshot)
else:
source = 'heartbeat_file'
heartbeat = display_watchdog.read_heartbeat(display_watchdog.HEARTBEAT_PATH)
age = display_watchdog.heartbeat_age(heartbeat) if heartbeat else None
if age is None:
health_status['checks']['display_loop'] = {
'status': 'not_reported',
'note': 'The display is not writing a heartbeat (not started yet, '
'or a version or setup without one)',
'source': source,
}
else:
fresh = age < display_watchdog.HEARTBEAT_STALE_SECONDS
health_status['checks']['display_loop'] = {
'status': 'running' if fresh else 'stalled',
'heartbeat_age_seconds': round(age, 1),
'source': source,
}
except Exception:
logger.warning("Health check could not read the display heartbeat", exc_info=True)
+145
View File
@@ -0,0 +1,145 @@
"""The display's live state, as the web interface reads it.
The display process serves its state over the control socket (stage 3, see
docs/IPC_CONTROL_SOCKET.md): what it is showing, the on-demand session, the
brightness, its plugin runtime snapshot and whether its render loop is still
going round. This module holds one ``state.subscribe`` connection for the
web process (:class:`src.ipc.client.StateSubscription`, started on first
use), so a route answers from memory rather than reading a file the display
had to write to the SD card.
Every reader here returns None when the socket cannot vouch for the answer
-- the socket is off or missing (a stopped display, an older one, Windows,
the test suite), the subscription went quiet, or the display's copy is too
old by the same rules the cache readers apply -- and the route then reads
the cache keys and the heartbeat file exactly as it did before.
"""
from __future__ import annotations
import logging
import threading
import time
from typing import Any, Dict, Optional
from src.ipc import client as control_client
from src.ipc.contract import client_socket_paths, socket_supported
logger = logging.getLogger(__name__)
#: The cache readers' max_age for display_current_state: a ``display``
#: section the render thread has not refreshed for this long is unknown, as
#: the cache key would be.
CURRENT_STATE_MAX_AGE_SECONDS = 120.0
#: A one-shot ``state.get``, for a request that arrives before the
#: subscription has its first snapshot. Short: the display answers it from
#: its socket thread, never the render thread.
ONE_SHOT_TIMEOUT_SECONDS = 0.5
_feed: Optional[control_client.StateSubscription] = None
_feed_lock = threading.Lock()
def _subscription() -> Optional[control_client.StateSubscription]:
"""This process's subscription, started on first use; None without a socket."""
global _feed
if not socket_supported() or not client_socket_paths():
return None
with _feed_lock:
if _feed is None:
_feed = control_client.StateSubscription().start()
return _feed
def stop_subscription() -> None:
"""Stop the subscription (tests; a process that is shutting down)."""
global _feed
with _feed_lock:
feed, _feed = _feed, None
if feed is not None:
feed.stop()
def read_state() -> Optional[Dict[str, Any]]:
"""The display's latest state snapshot, or None (read the cache instead).
From the subscription when it is live; otherwise one ``state.get``.
"""
feed = _subscription()
if feed is None:
return None
snapshot = feed.latest()
if snapshot is not None:
return snapshot
if feed.last_error == 'unknown_command':
# A display older than stage 3: a one-shot would fail the same way
# on every request. The subscription retries every 30 s.
return None
try:
snapshot = control_client.state_get(timeout=ONE_SHOT_TIMEOUT_SECONDS)
except control_client.ControlError as e:
logger.debug("Display state not available over the control socket: %s", e)
return None
if not isinstance(snapshot.get('state'), dict):
return None
snapshot['received_mono'] = time.monotonic()
return snapshot
def _section(snapshot: Optional[Dict[str, Any]], name: str) -> Optional[Dict[str, Any]]:
if not isinstance(snapshot, dict):
return None
state = snapshot.get('state')
value = state.get(name) if isinstance(state, dict) else None
return dict(value) if isinstance(value, dict) else None
def current_status(snapshot: Optional[Dict[str, Any]],
now: Optional[float] = None) -> Optional[Dict[str, Any]]:
"""``/display/current-status``'s data from a snapshot.
None when the snapshot has no ``display`` section (fall back to the
cache). A section the render thread last refreshed more than
CURRENT_STATE_MAX_AGE_SECONDS ago -- the loop is stuck -- is reported
as unknown, the same answer the cache key gives once it ages out.
"""
display = _section(snapshot, 'display')
if display is None:
return None
now = time.time() if now is None else now
updated = display.get('last_updated')
if (not isinstance(updated, (int, float)) or isinstance(updated, bool)
or now - updated > CURRENT_STATE_MAX_AGE_SECONDS):
return {'mode': None, 'plugin_id': None, 'last_updated': None}
return display
def on_demand_state(snapshot: Optional[Dict[str, Any]],
now: Optional[float] = None) -> Optional[Dict[str, Any]]:
"""``/display/on-demand/status``'s state from a snapshot; None to fall back.
``remaining`` is worked out again from ``expires_at``: the display set it
when it last published, which may have been minutes ago.
"""
state = _section(snapshot, 'on_demand')
if state is None:
return None
expires_at = state.get('expires_at')
if (state.get('active') and isinstance(expires_at, (int, float))
and not isinstance(expires_at, bool)):
now = time.time() if now is None else now
state['remaining'] = max(0.0, expires_at - now)
return state
def loop_heartbeat_age(snapshot: Optional[Dict[str, Any]]) -> Optional[float]:
"""The render loop's heartbeat age now; None when the display has no
beat to report yet (or there is no snapshot)."""
if not isinstance(snapshot, dict):
return None
return control_client.snapshot_loop_age(snapshot)
__all__ = ['current_status', 'loop_heartbeat_age', 'on_demand_state', 'read_state',
'stop_subscription']