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
+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)