feat(ipc): control socket stage 2 - wake the render thread, brightness.set, plugin.reload (#720)

Control socket stage 2: the render thread wakes for queued commands (static screens ~1 ms, Vegas within one frame), brightness.set, and plugin.reload after a store update, with mailbox/restart fallbacks. Rig checks listed in the PR body are still to run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 20:37:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f72d69c2b0
commit 746dcfcadb
22 changed files with 1998 additions and 119 deletions
+233 -25
View File
@@ -44,7 +44,15 @@ from src.logging_config import get_logger
from src.exceptions import PluginError
from src.common.frame_timing import HANDOVER_OP
from src.common.sync_manager import DisplaySyncManager, SyncRole
from src.ipc.server import ControlServer, start_control_server
from src.ipc.contract import (
BrightnessResult,
BrightnessSetArgs,
Command as ControlCommand,
ErrorCode as ControlErrorCode,
PluginReloadArgs,
PluginReloadResult,
)
from src.ipc.server import ControlServer, QueuedCommand, start_control_server
from src.vegas_mode.render_pipeline import SYNC_SEND_INTERVAL
# Get logger with consistent configuration
@@ -579,9 +587,13 @@ class DisplayController:
# Set up interrupt checker for on-demand/wifi status and follower mode
def _vegas_interrupt():
return self._check_vegas_interrupt() or self.sync_manager.is_follower_active()
# Every 10 frames (~80ms at 125 FPS, ~0.4 s at the 24 a Pi 4
# often manages), or at the next frame when a control socket
# command is queued: that check is one Event read per frame.
self.vegas_coordinator.set_interrupt_checker(
_vegas_interrupt,
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
check_interval=10,
urgent=self._control_command_pending,
)
# Run plugin updates inside the Vegas loop so the inter-iteration
@@ -714,6 +726,10 @@ class DisplayController:
if self._check_wifi_status_message():
return True
# A plugin reload waits for the top of the loop, outside the iteration.
if self._plugin_reload_pending:
return True
return False
def _timezone(self):
@@ -1154,12 +1170,15 @@ class DisplayController:
Also services pending changes (see _service_pending_changes), and
returns early when one of them changes what the panel should show --
an on-demand start or stop, the display schedule turning the panel
on or off, a WiFi notice arriving, or a live game taking over
(_check_live_takeover) -- so the caller can act on it instead of
finishing a dwell that could be a minute long (sixty seconds while
scheduled off).
on or off, a WiFi notice arriving, a live game taking over
(_check_live_takeover), or a plugin reload waiting for the top of
the loop -- so the caller can act on it instead of finishing a dwell
that could be a minute long (sixty seconds while scheduled off).
The waits between checks wake for a control socket command, so one
is applied within milliseconds rather than at the next 0.25 s tick.
"""
if duration <= 0:
if duration <= 0 or self._plugin_reload_pending:
return
end_time = time.time() + duration
@@ -1177,7 +1196,9 @@ class DisplayController:
break
sleep_time = min(tick_interval, remaining)
time.sleep(sleep_time)
# Woken early by a control socket command, which
# _service_pending_changes then applies without its floor.
self._wait_for_control(sleep_time)
# A dwell can be a minute long (sixty seconds while scheduled
# off); the watchdog must hear from this thread throughout.
display_watchdog.watchdog.beat()
@@ -1188,7 +1209,8 @@ class DisplayController:
or self.is_display_active != display_active
or self.on_demand_active != on_demand
or (not wifi_pending and self.is_display_active
and self._wifi_notice_pending())):
and self._wifi_notice_pending())
or self._plugin_reload_pending):
break
def _note_empty_pass(self) -> None:
@@ -1657,22 +1679,192 @@ class DisplayController:
'display_active': self.is_display_active}
def _drain_control_commands(self) -> None:
"""Apply on-demand commands that arrived over the control socket.
"""Apply the commands that arrived over the control socket.
Each goes through _handle_on_demand_request, the mailbox's own
handler, so both ways in behave the same, and a request that came
both ways (a client that timed out and fell back) has one request
id and is processed once.
On-demand commands go through _handle_on_demand_request, the
mailbox's own handler, so both ways in behave the same, and a
request that came both ways (a client that timed out and fell back)
has one request id and is processed once. A brightness is applied
here. A plugin reload waits for the top of the next loop pass, where
no plugin is on the stack (_apply_pending_plugin_reloads); until
then the current screen ends early (_plugin_reload_pending).
"""
server = self._control_server
if server is None or not server.has_pending:
return
for command in server.drain():
try:
self._handle_on_demand_request(command.as_on_demand_request())
if command.cmd == ControlCommand.BRIGHTNESS_SET:
self._apply_control_brightness(command)
elif command.cmd == ControlCommand.PLUGIN_RELOAD:
self._pending_plugin_reloads = self._pending_plugin_reloads + (command,)
else:
self._handle_on_demand_request(command.as_on_demand_request())
except Exception: # pylint: disable=broad-except
logger.exception("Failed to apply control socket command %s",
command.request_id)
command.fail(ControlErrorCode.INTERNAL, 'the display failed to apply it')
def _wait_for_control(self, timeout: float) -> bool:
"""Sleep up to ``timeout``, waking early for a control socket command.
True when a command is waiting. Without a socket (Windows, switched
off, tests) this is the plain sleep it replaces.
"""
server = self._control_server
wait = getattr(server, 'wait_for_command', None) if server is not None else None
if wait is None:
time.sleep(timeout)
return False
return bool(wait(timeout))
def _control_command_pending(self) -> bool:
"""A socket command is queued: Vegas checks this every frame."""
server = self._control_server
return bool(server is not None and server.has_pending)
def _screen_preempted(self, active_mode: Optional[str]) -> bool:
"""What ends a screen mid-way: the checks the frame loops make."""
return (self.current_display_mode != active_mode
or not self.is_display_active
or self._wifi_notice_pending()
or self._plugin_reload_pending)
def _wait_frame_interval(self, interval: float, active_mode: Optional[str]) -> bool:
"""The static screen's sleep between frames, woken by socket commands.
Each command that arrives is applied at once (_service_pending_changes
skips its floor while one is queued). True when that ended the
screen; otherwise the wait carries on to the end of the interval, so
the frame cadence is unchanged by a command that does not change the
screen (a brightness, say).
"""
if self._control_server is None:
time.sleep(interval)
return False
deadline = time.monotonic() + interval
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
if not self._wait_for_control(remaining):
return False
self._service_pending_changes()
if self._screen_preempted(active_mode):
return True
def _apply_control_brightness(self, command: QueuedCommand) -> None:
"""``brightness.set``: the new normal brightness, on the panel now.
It replaces the configured value in memory only, the way a saved
config would once the config watcher saw it, so the dim schedule and
the scheduled-off rules treat it exactly like the setting; the next
config the watcher loads replaces it again.
"""
args = command.args
if not isinstance(args, BrightnessSetArgs):
command.fail(ControlErrorCode.INTERNAL, 'not a brightness command')
return
self._normal_brightness = args.brightness
# The dim schedule's per-minute cache holds the old normal level.
self._dim_checked_minute = None
# An explicit request retries a level the panel refused before.
self._failed_brightness_target = None
self._apply_brightness_target(repaint=True)
if self.is_display_active:
target = self._check_dim_schedule() # cached for the minute: no new work
if self.current_brightness != target:
command.fail(ControlErrorCode.FAILED,
f'the panel did not take brightness {target}')
return
logger.info("Brightness set to %d%% over the control socket (panel %s%%)",
args.brightness, self.current_brightness)
result: BrightnessResult = {
'brightness': args.brightness,
'panel_brightness': int(self.current_brightness),
'dimmed': bool(self.is_dimmed),
'display_active': bool(self.is_display_active),
}
command.succeed(dict(result))
#: Plugin reloads from the control socket, waiting for the top of the
#: next loop pass. A tuple, replaced rather than mutated.
_pending_plugin_reloads: Tuple[QueuedCommand, ...] = ()
@property
def _plugin_reload_pending(self) -> bool:
return bool(self._pending_plugin_reloads)
def _apply_pending_plugin_reloads(self) -> None:
"""Reload the plugins the control socket asked for. Render thread,
top of the loop pass: no display() and no Vegas iteration on the stack.
The same steps as disabling and re-enabling the plugin live
(_unregister_plugin, then load and _register_loaded_plugin), with the
manifest re-read from disk, so the running set ends up as a restart
would build it. A plugin that fails to load stays out of the
rotation, as it would after a restart.
"""
commands, self._pending_plugin_reloads = self._pending_plugin_reloads, ()
for command in commands:
try:
self._reload_plugin_for_command(command)
except Exception: # pylint: disable=broad-except
logger.exception("Plugin reload over the control socket failed")
command.fail(ControlErrorCode.INTERNAL, 'the display failed to reload it')
def _reload_plugin_for_command(self, command: QueuedCommand) -> None:
args = command.args
if not isinstance(args, PluginReloadArgs):
command.fail(ControlErrorCode.INTERNAL, 'not a reload command')
return
plugin_id = args.plugin_id
if self.plugin_manager is None or plugin_id not in self.plugin_display_modes:
command.fail(ControlErrorCode.NOT_LOADED, f'{plugin_id} is not running')
return
if plugin_id in self._on_demand_loaded_plugins:
# Loaded only for an on-demand session (config says disabled);
# reloading would have to repeat that special load.
command.fail(ControlErrorCode.BUSY,
f'{plugin_id} is loaded only for on-demand; restart to reload it')
return
previous_mode = self.current_display_mode
previous_order = {mode: i for i, mode in enumerate(self.available_modes)}
logger.info("Reloading plugin %s over the control socket", plugin_id)
self._unregister_plugin(plugin_id, action='Unloaded')
loaded = bool(self.plugin_manager.reload_plugin(plugin_id))
modes: List[str] = []
if loaded:
modes = list(self._register_loaded_plugin(plugin_id))
# Registering appends; put its modes back where they were in the
# rotation (a mode the new version added goes last).
self.available_modes.sort(
key=lambda mode: previous_order.get(mode, len(previous_order)))
self._apply_plugin_rotation_order()
self._resync_mode_index_after_change(previous_mode)
if not loaded:
logger.error("Plugin %s did not load after its update; it is out of the "
"rotation until it loads", plugin_id)
command.fail(ControlErrorCode.FAILED,
f'{plugin_id} did not load; see the display log')
return
vegas = getattr(self, 'vegas_coordinator', None)
if vegas is not None:
try:
# Fetch its content again rather than scroll the old copy.
vegas.mark_plugin_updated(plugin_id)
except Exception: # pylint: disable=broad-except
logger.debug("Vegas did not take the reload of %s", plugin_id, exc_info=True)
manifest = (getattr(self.plugin_manager, 'plugin_manifests', None) or {}).get(plugin_id)
version = manifest.get('version') if isinstance(manifest, dict) else None
logger.info("Reloaded plugin %s (version %s, modes %s)", plugin_id, version, modes)
result: PluginReloadResult = {
'plugin_id': plugin_id, 'reloaded': True,
'version': version if isinstance(version, str) else None,
'modes': modes,
}
command.succeed(dict(result))
def _poll_on_demand_requests(self) -> None:
"""Poll cache for new on-demand requests from external controllers."""
@@ -3076,6 +3268,12 @@ class DisplayController:
if self._pending_plugin_reconcile and not self.on_demand_active:
self._service_pending_reconcile()
# Plugin reloads from the control socket (a store update),
# here for the same reason: nothing of the plugin's is on the
# stack. The screen that was showing ended early for them.
if self._pending_plugin_reloads:
self._apply_pending_plugin_reloads()
if not self.available_modes:
# Nothing to render yet. Re-check _pending_plugin_reconcile
# every ~1s (rather than a long sleep) so enabling a plugin
@@ -3177,6 +3375,10 @@ class DisplayController:
# Scheduled off mid-iteration: blank the
# panel now rather than render a screen.
continue
if self._plugin_reload_pending:
# Reload first (top of the loop), then
# the ticker carries on.
continue
if self._wifi_notice_pending():
# It yielded for a WiFi notice: the next
# pass shows it, not a rotation screen
@@ -3367,9 +3569,7 @@ class DisplayController:
# update threads and the web UI are not starved of the GIL.
time.sleep(_remaining if _remaining > 0 else 0.001)
if (self.current_display_mode != active_mode
or not self.is_display_active
or self._wifi_notice_pending()):
if self._screen_preempted(active_mode):
logger.debug("Mode changed during high-FPS loop, breaking early")
break
@@ -3400,7 +3600,13 @@ class DisplayController:
)
while True:
time.sleep(display_interval)
# Wakes for a control socket command and applies
# it at once, instead of up to a second later.
if self._wait_frame_interval(display_interval, active_mode):
logger.info("Mode changed during display loop from %s to %s, "
"breaking early", active_mode,
self.current_display_mode)
break
self._tick_plugin_updates()
elapsed = time.time() - start_time
@@ -3432,9 +3638,7 @@ class DisplayController:
self._service_pending_changes()
self._check_live_takeover()
if (self.current_display_mode != active_mode
or not self.is_display_active
or self._wifi_notice_pending()):
if self._screen_preempted(active_mode):
logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode)
break
@@ -3464,6 +3668,9 @@ class DisplayController:
or not self.is_display_active
or (not loop_completed and self._wifi_notice_pending())):
continue
# A screen cut short for a plugin reload is over: the
# make-up dwell below returns at once, the rotation
# advances, and the next pass reloads before it draws.
# Ensure we honour minimum duration when not dynamic and loop ended early
if (
@@ -3792,9 +3999,10 @@ class DisplayController:
self._plugin_accepts_display_mode.pop(plugin_id, None)
return display_modes
def _unregister_plugin(self, plugin_id: str) -> None:
def _unregister_plugin(self, plugin_id: str, action: str = 'Disabled') -> None:
"""Remove a plugin's modes, config subscription and instance, then
unload it. Used by live disable hot-reload."""
unload it. Used by live disable hot-reload, and by a reload
(``action`` names which in the log line)."""
with self._plugin_modes_lock:
modes = self.plugin_display_modes.pop(plugin_id, [])
for mode in modes:
@@ -3826,7 +4034,7 @@ class DisplayController:
except Exception as e:
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
logger.info("%s plugin %s live (removed modes: %s)", action, plugin_id, modes)
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
"""True if any top-level section's ``enabled`` flag differs between two
+35
View File
@@ -15,6 +15,7 @@ import uuid
from typing import Any, Dict, List, Mapping, Optional, Sequence
from src.ipc.contract import (
AWAIT_SECONDS,
MAX_MESSAGE_BYTES,
PROTOCOL_VERSION,
SUPPORTED_VERSIONS,
@@ -188,6 +189,40 @@ def on_demand_status(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
return request(Command.ON_DEMAND_STATUS, {}, timeout=timeout, paths=paths)
#: Headroom over the display's own wait for an awaited command, so its
#: ``pending`` answer arrives before the client gives up.
_AWAIT_MARGIN_SECONDS = 1.0
def _awaited_timeout(cmd: str) -> float:
return AWAIT_SECONDS[cmd] + _AWAIT_MARGIN_SECONDS
def brightness_set(brightness: int, *, timeout: Optional[float] = None,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Set the panel's normal brightness now (transient: config.json is not
written). Returns the applied :class:`~src.ipc.contract.BrightnessResult`;
raises :class:`ControlError`.
"""
return request(Command.BRIGHTNESS_SET, {'brightness': brightness},
timeout=_awaited_timeout(Command.BRIGHTNESS_SET) if timeout is None
else timeout, paths=paths)
def plugin_reload(plugin_id: str, *, timeout: Optional[float] = None,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
"""Have the display reload a running plugin from disk.
Returns :class:`~src.ipc.contract.PluginReloadResult` once the new code is
running. Raises :class:`ControlError`: ``not_loaded`` (not running it),
``failed`` (the new version did not load), ``pending`` (not done in
time; it will still happen), or a transport reason.
"""
return request(Command.PLUGIN_RELOAD, {'plugin_id': plugin_id},
timeout=_awaited_timeout(Command.PLUGIN_RELOAD) if timeout is None
else timeout, paths=paths)
def ping(*, timeout: float = DEFAULT_TIMEOUT_SECONDS,
paths: Optional[Sequence[str]] = None) -> Dict[str, Any]:
return request(Command.PING, {}, timeout=timeout, paths=paths)
+110 -4
View File
@@ -26,7 +26,14 @@ order. Commands that change what the panel shows are *acknowledged*, not
completed: ``{"accepted": true, "request_id": ...}`` means the render thread
has the command queued and will apply it at its next on-demand check. Its
outcome is published the way it always was (``display_on_demand_state``,
later the state stream).
later the state stream). A few commands (:data:`AWAITED_COMMANDS`) are
answered only once the render thread has applied them, or with ``pending``
when it has not within :data:`AWAIT_SECONDS`.
New commands are added within a protocol version: a display that does not
know one answers ``unknown_command``, the client falls back, and ``hello``
lists the commands a display knows. The version changes only when the
envelope or the meaning of an existing command changes.
See docs/IPC_CONTROL_SOCKET.md for the full description.
"""
@@ -133,19 +140,42 @@ class Command:
ON_DEMAND_START = 'on_demand.start'
ON_DEMAND_STOP = 'on_demand.stop'
ON_DEMAND_STATUS = 'on_demand.status'
BRIGHTNESS_SET = 'brightness.set'
PLUGIN_RELOAD = 'plugin.reload'
#: Every command version 1 defines, in the order ``hello`` reports them.
#: ``brightness.set`` and ``plugin.reload`` came in stage 2, within version 1
#: (see the module docstring on adding commands).
COMMANDS: Tuple[str, ...] = (
Command.HELLO,
Command.PING,
Command.ON_DEMAND_START,
Command.ON_DEMAND_STOP,
Command.ON_DEMAND_STATUS,
Command.BRIGHTNESS_SET,
Command.PLUGIN_RELOAD,
)
#: Commands that are queued for the render thread and answered with an ack.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP})
#: Commands that are queued for the render thread.
QUEUED_COMMANDS = frozenset({Command.ON_DEMAND_START, Command.ON_DEMAND_STOP,
Command.BRIGHTNESS_SET, Command.PLUGIN_RELOAD})
#: Queued commands whose answer waits for the render thread's outcome
#: instead of being an ack. The value is how long the display waits before
#: answering ``pending``; the command stays queued and is still applied.
#: A plugin reload first lets the current screen end (within a frame on a
#: scrolling screen, at once on a static one) and then imports the plugin,
#: which can take a few seconds on a slow board.
AWAIT_SECONDS: Dict[str, float] = {
Command.BRIGHTNESS_SET: 2.0,
Command.PLUGIN_RELOAD: 10.0,
}
AWAITED_COMMANDS = frozenset(AWAIT_SECONDS)
#: Brightness, in percent, as the display's hardware setting takes it.
MIN_BRIGHTNESS = 0
MAX_BRIGHTNESS = 100
class ErrorCode:
@@ -159,6 +189,10 @@ class ErrorCode:
BUSY = 'busy' # queue full / too many clients
FORBIDDEN = 'forbidden' # peer credentials refused
INTERNAL = 'internal' # a bug on the display side
# From the awaited commands (stage 2):
PENDING = 'pending' # accepted, not applied within AWAIT_SECONDS; still queued
NOT_LOADED = 'not_loaded' # plugin.reload: the display is not running that plugin
FAILED = 'failed' # the render thread tried, and it did not work
class ProtocolError(Exception):
@@ -398,7 +432,56 @@ class NoArgs:
return cls()
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs]
@dataclass(frozen=True)
class BrightnessSetArgs:
"""``brightness.set``: the panel's normal brightness, in percent, now.
Transient: nothing is written to config.json, and the next config change
the display picks up (or a restart) goes back to the configured value.
The web interface sends it after saving the setting, so the two agree.
The dim schedule still applies on top, as it does to the saved value.
"""
brightness: int
def to_dict(self) -> Dict[str, Any]:
return {'brightness': self.brightness}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'BrightnessSetArgs':
value = args.get('brightness')
if not _is_int(value) or not MIN_BRIGHTNESS <= value <= MAX_BRIGHTNESS:
raise ProtocolError(ErrorCode.INVALID_ARGS,
f'brightness must be an integer from {MIN_BRIGHTNESS} '
f'to {MAX_BRIGHTNESS}')
return cls(brightness=value)
@dataclass(frozen=True)
class PluginReloadArgs:
"""``plugin.reload``: load a running plugin again from disk.
For a plugin the store has just updated. Only a plugin the display is
running can be reloaded (``not_loaded`` otherwise), so the id never
makes the display import anything it was not already running.
"""
plugin_id: str
def to_dict(self) -> Dict[str, Any]:
return {'plugin_id': self.plugin_id}
@classmethod
def from_dict(cls, args: Mapping[str, Any]) -> 'PluginReloadArgs':
plugin_id = _optional_name(args, 'plugin_id')
if plugin_id is None:
raise ProtocolError(ErrorCode.INVALID_ARGS, 'plugin_id is required')
return cls(plugin_id=plugin_id)
CommandArgs = Union[HelloArgs, OnDemandStartArgs, OnDemandStopArgs, NoArgs,
BrightnessSetArgs, PluginReloadArgs]
#: The arguments of a command that goes on the render thread's queue.
QueuedArgs = Union[OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs]
_ARG_TYPES: Dict[str, Any] = {
Command.HELLO: HelloArgs,
@@ -406,6 +489,8 @@ _ARG_TYPES: Dict[str, Any] = {
Command.ON_DEMAND_START: OnDemandStartArgs,
Command.ON_DEMAND_STOP: OnDemandStopArgs,
Command.ON_DEMAND_STATUS: NoArgs,
Command.BRIGHTNESS_SET: BrightnessSetArgs,
Command.PLUGIN_RELOAD: PluginReloadArgs,
}
@@ -457,6 +542,27 @@ class AckResult(TypedDict):
queued: int
class BrightnessResult(TypedDict):
"""``brightness.set``, once applied.
``panel_brightness`` is what the panel shows now: the dim schedule's
level while it dims, and unchanged while the schedule has the display
off (the new level applies when it comes back on).
"""
brightness: int
panel_brightness: int
dimmed: bool
display_active: bool
class PluginReloadResult(TypedDict):
"""``plugin.reload``, once the plugin is running again."""
plugin_id: str
reloaded: bool
version: Optional[str]
modes: List[str]
def negotiate_version(client_versions: Tuple[int, ...]) -> Optional[int]:
"""The highest version both sides speak, or None."""
common = set(client_versions) & set(SUPPORTED_VERSIONS)
+116 -10
View File
@@ -8,6 +8,12 @@ where it reads the file mailbox (``DisplayController._poll_on_demand_requests``)
handing each command to the same code. Queries (``on_demand.status``) are
answered from a snapshot callable the display provides.
The queue also wakes the render thread: :meth:`ControlServer.wait_for_command`
is what it waits on in place of a sleep, so a command lands within a frame on
every kind of screen. An awaited command (``brightness.set``,
``plugin.reload``) carries a :class:`CommandOutcome` that the render thread
fills in; its connection thread waits for that, bounded, before answering.
Robustness rules, because this runs inside the display process:
* every connection has its own daemon thread, at most :data:`MAX_CLIENTS` at
@@ -39,10 +45,12 @@ import stat
import struct
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional, Union
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, FrozenSet, List, Mapping, Optional
from src.ipc.contract import (
AWAIT_SECONDS,
AWAITED_COMMANDS,
COMMANDS,
DEFAULT_SOCKET_DIR,
DEFAULT_SOCKET_PATH,
@@ -51,6 +59,7 @@ from src.ipc.contract import (
QUEUED_COMMANDS,
SUPPORTED_VERSIONS,
AckResult,
BrightnessSetArgs,
Command,
ErrorCode,
FrameReader,
@@ -58,7 +67,9 @@ from src.ipc.contract import (
HelloResult,
OnDemandStartArgs,
OnDemandStopArgs,
PluginReloadArgs,
ProtocolError,
QueuedArgs,
Request,
Response,
configured_socket_path,
@@ -100,19 +111,75 @@ _LISTEN_BACKLOG = 64
# -- queued work ---------------------------------------------------------------------
class CommandOutcome:
"""How an awaited command turned out, handed from the render thread back
to the connection thread that is waiting to answer.
The render thread calls :meth:`succeed` or :meth:`fail` once; the first
call wins. The connection thread may have stopped waiting already (it
answered ``pending``), and then nobody reads it.
"""
def __init__(self) -> None:
self._done = threading.Event()
self._lock = threading.Lock()
self.result: Optional[Dict[str, Any]] = None
self.error_code: Optional[str] = None
self.error_message = ''
@property
def done(self) -> bool:
return self._done.is_set()
def succeed(self, result: Mapping[str, Any]) -> None:
with self._lock:
if self._done.is_set():
return
self.result = dict(result)
self._done.set()
def fail(self, code: str, message: str) -> None:
with self._lock:
if self._done.is_set():
return
self.error_code = code
self.error_message = message
self._done.set()
def wait(self, timeout: float) -> bool:
return self._done.wait(timeout)
@dataclass(frozen=True)
class QueuedCommand:
"""A command waiting for the render thread."""
"""A command waiting for the render thread.
``outcome`` is set for an awaited command (``AWAITED_COMMANDS``): the
render thread reports through it, and the client's answer waits for it.
"""
request_id: str
cmd: str
args: Union[OnDemandStartArgs, OnDemandStopArgs]
args: QueuedArgs
received_at: float # time.time() when it was accepted
peer_uid: Optional[int] = None
outcome: Optional[CommandOutcome] = field(default=None, compare=False, repr=False)
def as_on_demand_request(self) -> Dict[str, Any]:
"""The mailbox-shaped payload the display's on-demand handler takes."""
if not isinstance(self.args, (OnDemandStartArgs, OnDemandStopArgs)):
raise TypeError(f'{self.cmd} is not an on-demand command')
return on_demand_request(self.request_id, self.args, self.received_at)
def succeed(self, result: Mapping[str, Any]) -> None:
"""Report success to a waiting client (a no-op for an acked command)."""
if self.outcome is not None:
self.outcome.succeed(result)
def fail(self, code: str, message: str) -> None:
"""Report failure to a waiting client (a no-op for an acked command)."""
if self.outcome is not None:
self.outcome.fail(code, message)
# -- peer credentials ------------------------------------------------------------------
@@ -247,8 +314,12 @@ class ControlServer:
max_clients: int = MAX_CLIENTS, io_timeout: float = IO_TIMEOUT_SECONDS,
message_timeout: float = MESSAGE_TIMEOUT_SECONDS,
idle_timeout: float = IDLE_TIMEOUT_SECONDS,
check_peer: bool = True):
check_peer: bool = True,
await_seconds: Optional[Mapping[str, float]] = None):
self.path = path
self._await_seconds: Dict[str, float] = dict(AWAIT_SECONDS)
if await_seconds:
self._await_seconds.update(await_seconds)
self._status_provider = status_provider
self._group = group
self._queue: 'queue.Queue[QueuedCommand]' = queue.Queue(maxsize=queue_size)
@@ -418,6 +489,17 @@ class ControlServer:
"""Cheap check for queued commands, for the render thread's fast path."""
return self._pending.is_set()
def wait_for_command(self, timeout: float) -> bool:
"""Block up to ``timeout`` seconds for a queued command; True if one is.
The render thread waits here instead of sleeping, in the dwell and
on a static screen, so a command wakes it at once. It is a timed
wait on an Event: no polling, and nothing more than the sleep it
replaces when no command comes. The flag stays set until drain(),
so a caller that does not drain would return at once every time.
"""
return self._pending.wait(timeout)
def drain(self) -> List[QueuedCommand]:
"""Every queued command, oldest first. Called from the render thread."""
commands: List[QueuedCommand] = []
@@ -596,11 +678,13 @@ class ControlServer:
v=request.v)
return Response.success(request.id, self._status_provider(), v=request.v)
if request.cmd in QUEUED_COMMANDS and isinstance(args, (OnDemandStartArgs,
OnDemandStopArgs)):
if request.cmd in QUEUED_COMMANDS and isinstance(args, (
OnDemandStartArgs, OnDemandStopArgs, BrightnessSetArgs, PluginReloadArgs)):
awaited = request.cmd in AWAITED_COMMANDS
command = QueuedCommand(request_id=request.id, cmd=request.cmd, args=args,
received_at=time.time(),
peer_uid=peer.uid if peer is not None else None)
peer_uid=peer.uid if peer is not None else None,
outcome=CommandOutcome() if awaited else None)
try:
self._queue.put_nowait(command)
except queue.Full:
@@ -610,15 +694,37 @@ class ControlServer:
'the display is not taking commands right now',
v=request.v)
self._pending.set()
logger.info("Control socket accepted %s %s", request.cmd, request.id)
if command.outcome is not None:
return self._await_outcome(request, command.outcome)
ack: AckResult = {'accepted': True, 'request_id': request.id,
'queued': self._queue.qsize()}
logger.info("Control socket accepted %s %s", request.cmd, request.id)
return Response.success(request.id, dict(ack), v=request.v)
# A command in COMMANDS with no handler here is a bug in this module.
return Response.failure(request.id, ErrorCode.INTERNAL,
f'{request.cmd} is not implemented', v=request.v)
def _await_outcome(self, request: Request, outcome: CommandOutcome) -> Response:
"""Answer an awaited command once the render thread has applied it.
Waits on this connection's thread, never the render thread's. If the
render thread does not get to it in time, the answer is ``pending``:
the command stays queued and is still applied, so a client treats
that as "not known to be done" rather than as a refusal.
"""
timeout = self._await_seconds.get(request.cmd, 0.0)
if not outcome.wait(timeout):
logger.warning("Control socket: %s %s not applied within %.1fs; answering pending",
request.cmd, request.id, timeout)
return Response.failure(request.id, ErrorCode.PENDING,
f'accepted, but not applied within {timeout:g}s; '
'the display will still apply it', v=request.v)
if outcome.error_code is not None:
return Response.failure(request.id, outcome.error_code, outcome.error_message,
v=request.v)
return Response.success(request.id, outcome.result or {}, v=request.v)
def start_control_server(status_provider: Optional[StatusProvider] = None,
cache_dir: Optional[str] = None,
@@ -637,7 +743,7 @@ def start_control_server(status_provider: Optional[StatusProvider] = None,
__all__ = [
'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
'CommandOutcome', 'ControlServer', 'PeerCredentials', 'QueuedCommand', 'StatusProvider',
'peer_allowed', 'peer_credentials', 'process_groups', 'resolve_socket_group',
'server_socket_path', 'start_control_server', 'PROTOCOL_VERSION',
]
+3 -1
View File
@@ -238,7 +238,9 @@ def display_restart_required(action: str, plugin_enabled: bool, *,
config carried over) is not picked up until a restart.
- ``update``: the display keeps running the code it loaded until it
restarts, if it runs the plugin at all -- only when it is enabled.
``changed=False`` (already up to date) needs nothing.
``changed=False`` (already up to date) needs nothing. The update route
first asks the display to reload it over the control socket
(``_reload_after_store_update``); this answer stands when it cannot.
- ``uninstall``: removing the plugin's config section flips its enabled
flag, and the reconcile unloads it. With ``preserve_config`` the flag
stays, and an enabled plugin keeps running until a restart.
+22 -2
View File
@@ -152,6 +152,8 @@ class VegasModeCoordinator:
# Interrupt checker for yielding control back to display controller
self._interrupt_check: Optional[Callable[[], bool]] = None
self._interrupt_check_interval: int = 10 # Check every N frames
# Checked every frame; True runs the interrupt check at once.
self._interrupt_urgent: Optional[Callable[[], bool]] = None
# Plugin update callback — fired from a background thread inside the loop
# so the main loop's _tick_plugin_updates() finds nothing due when Vegas
@@ -226,7 +228,8 @@ class VegasModeCoordinator:
def set_interrupt_checker(
self,
checker: Callable[[], bool],
check_interval: int = 10
check_interval: int = 10,
urgent: Optional[Callable[[], bool]] = None,
) -> None:
"""
Set the callback for checking if Vegas should yield control.
@@ -237,9 +240,25 @@ class VegasModeCoordinator:
Args:
checker: Callable that returns True if Vegas should yield
check_interval: Check every N frames (default 10)
urgent: A cheap per-frame test; when it is True the checker
runs at this frame instead of waiting for the interval (the
display controller passes "a control socket command is
queued", so a command waits one frame, not ten)
"""
self._interrupt_check = checker
self._interrupt_check_interval = max(1, check_interval)
self._interrupt_urgent = urgent
def _interrupt_is_urgent(self) -> bool:
"""The per-frame test set with ``urgent``; never raises."""
urgent = getattr(self, '_interrupt_urgent', None)
if urgent is None:
return False
try:
return bool(urgent()) # pylint: disable=not-callable
except Exception: # pylint: disable=broad-except
logger.debug("Urgent interrupt test failed", exc_info=True)
return False
def set_update_callback(self, callback: Callable[[], None]) -> None:
"""
@@ -709,7 +728,8 @@ class VegasModeCoordinator:
frame_times.clear()
if (self._interrupt_check and
frame_count % self._interrupt_check_interval == 0):
(frame_count % self._interrupt_check_interval == 0
or self._interrupt_is_urgent())):
try:
if self._interrupt_check():
logger.debug(