mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
+30
-2
@@ -283,6 +283,29 @@ def snapshot_loop_age(snapshot: Mapping[str, Any],
|
||||
return max(float(age), 0.0) + snapshot_age(snapshot, now_mono)
|
||||
|
||||
|
||||
def _merge_volatile(state: Dict[str, Any], volatile: Any) -> None:
|
||||
"""Fold a tick's ``volatile`` values (``{section: {key: value}}``) into
|
||||
``state``, copying each section it touches.
|
||||
|
||||
These are the timestamps the hub leaves out of its version --
|
||||
``display.last_updated``, ``on_demand.last_updated``/``remaining``,
|
||||
``plugins.published_at`` -- and the readers judge freshness by them, so
|
||||
a copy that only full ``state`` events updated would go stale while the
|
||||
same mode stayed on screen. Only keys the section already has are taken:
|
||||
a tick never adds a section or a key the last snapshot did not carry
|
||||
(a section left out of a truncated snapshot stays out).
|
||||
"""
|
||||
if not isinstance(volatile, dict):
|
||||
return # a display from before ticks carried them
|
||||
for name, values in volatile.items():
|
||||
section = state.get(name)
|
||||
if not isinstance(section, dict) or not isinstance(values, dict):
|
||||
continue
|
||||
fresh = {k: v for k, v in values.items() if k in section}
|
||||
if fresh:
|
||||
state[name] = dict(section, **fresh)
|
||||
|
||||
|
||||
#: A subscription that has heard nothing for this long is not trusted: the
|
||||
#: display sends a tick at least every SUBSCRIBE_KEEPALIVE_SECONDS.
|
||||
SUBSCRIPTION_SILENCE_SECONDS = 3 * SUBSCRIBE_KEEPALIVE_SECONDS
|
||||
@@ -449,12 +472,17 @@ class StateSubscription:
|
||||
self.snapshots += 1
|
||||
elif (self._snapshot is not None
|
||||
and result.get('epoch') == self._snapshot.get('epoch')):
|
||||
# A tick: nothing changed but the render loop's liveness.
|
||||
# A tick: nothing changed but the render loop's liveness and
|
||||
# the volatile keys (timestamps) the writers keep refreshing.
|
||||
snap = dict(self._snapshot)
|
||||
state = dict(snap.get('state') or {})
|
||||
if result.get('version') == snap.get('version'):
|
||||
_merge_volatile(state, result.get('volatile'))
|
||||
loop = result.get('loop')
|
||||
if isinstance(loop, dict):
|
||||
snap['state'] = dict(snap.get('state') or {}, loop=loop)
|
||||
state['loop'] = loop
|
||||
snap['loop'] = loop
|
||||
snap['state'] = state
|
||||
snap['served_at'] = result.get('served_at', snap.get('served_at'))
|
||||
self._snapshot = snap
|
||||
else:
|
||||
|
||||
+14
-7
@@ -191,7 +191,7 @@ MAX_SUBSCRIBERS = 4
|
||||
|
||||
#: A subscriber hears from the display at least this often: a ``state``
|
||||
#: event when something changed, else a ``tick`` carrying the render loop's
|
||||
#: liveness. A client that has heard nothing for a few of these treats its
|
||||
#: liveness and the latest volatile timestamps. A client that has heard nothing for a few of these treats its
|
||||
#: copy as unknown.
|
||||
SUBSCRIBE_KEEPALIVE_SECONDS = 5.0
|
||||
|
||||
@@ -530,8 +530,10 @@ class StateGetArgs:
|
||||
"""``state.get``: the display's state, as a versioned snapshot.
|
||||
|
||||
With ``since`` and the ``epoch`` it came from, the answer is only
|
||||
``{changed: false, version, epoch, served_at, loop}`` while the state is
|
||||
still at that version, so a poller that already has it is sent no state.
|
||||
``{changed: false, version, epoch, served_at, loop, volatile}`` while the
|
||||
state is still at that version, so a poller that already has it is sent
|
||||
no state -- only the latest values of the keys that do not count as a
|
||||
change (``volatile``, see :class:`StateSnapshot`).
|
||||
"""
|
||||
since: Optional[int] = None
|
||||
epoch: Optional[str] = None
|
||||
@@ -672,9 +674,13 @@ class StateSnapshot(TypedDict, total=False):
|
||||
``version`` counts changes to the state within one ``epoch`` (one run of
|
||||
the display process): a reader that sees a new epoch starts over.
|
||||
``changed`` is False only for a ``state.get`` whose ``since`` is still
|
||||
current, and then ``state`` is absent. ``served_at`` is the display's
|
||||
wall clock when it answered. ``loop`` is measured at that moment, so it
|
||||
is also inside ``state``.
|
||||
current, and then ``state`` is absent and ``volatile`` is there instead:
|
||||
``{section: {key: value}}``, the current values of the keys the version
|
||||
ignores (``display.last_updated``, ``on_demand.last_updated`` and
|
||||
``remaining``, ``plugins.published_at``). A reader merges them into the
|
||||
copy it has; they are how it can tell the writers are still publishing.
|
||||
``served_at`` is the display's wall clock when it answered. ``loop`` is
|
||||
measured at that moment, so it is also inside ``state``.
|
||||
|
||||
``state`` holds the sections in :data:`STATE_SECTIONS`:
|
||||
|
||||
@@ -696,12 +702,13 @@ class StateSnapshot(TypedDict, total=False):
|
||||
served_at: float
|
||||
changed: bool
|
||||
state: Dict[str, Any]
|
||||
volatile: Dict[str, Dict[str, Any]]
|
||||
loop: LoopState
|
||||
|
||||
|
||||
class StateEventKind:
|
||||
STATE = 'state' # result: a full StateSnapshot, the latest version
|
||||
TICK = 'tick' # result: {version, epoch, pid, served_at, loop}; nothing changed
|
||||
TICK = 'tick' # result: {version, epoch, pid, served_at, loop, volatile}; nothing changed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
+30
-3
@@ -222,6 +222,22 @@ def _fingerprint(value: Optional[Mapping[str, Any]], volatile: Iterable[str]) ->
|
||||
return {k: v for k, v in value.items() if k not in skip} if skip else dict(value)
|
||||
|
||||
|
||||
def _volatile_values(sections: Mapping[str, Optional[Dict[str, Any]]],
|
||||
volatile: Mapping[str, FrozenSet[str]]) -> Dict[str, Dict[str, Any]]:
|
||||
"""``{section: {key: value}}``: the volatile keys each published section
|
||||
has now. The sections are never mutated after publish (a publish swaps
|
||||
in a new dict), so reading them outside the lock is safe."""
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
for name, keys in volatile.items():
|
||||
value = sections.get(name)
|
||||
if not keys or not isinstance(value, dict):
|
||||
continue
|
||||
present = {k: value[k] for k in keys if k in value}
|
||||
if present:
|
||||
values[name] = present
|
||||
return values
|
||||
|
||||
|
||||
def _unknown_loop() -> Dict[str, Any]:
|
||||
return {'heartbeat_age_seconds': None, 'armed': False, 'stale_after': None}
|
||||
|
||||
@@ -259,7 +275,11 @@ class StateHub:
|
||||
(``loop_probe``), so it keeps ageing while the render thread is stuck.
|
||||
|
||||
The version goes up when a section's value changes, ignoring the keys
|
||||
the publisher names as volatile (timestamps). Publishing never blocks on
|
||||
the publisher names as volatile (timestamps). Those keys still carry
|
||||
news -- ``display.last_updated`` is the render thread's proof of life --
|
||||
so the short ``changed: false`` answer, which is what a subscriber's
|
||||
tick carries, has their current values in ``volatile``; a reader merges
|
||||
them into its copy. Publishing never blocks on
|
||||
a reader: the lock is held only to swap a dict reference and compare it,
|
||||
and every socket write happens on the reader's own thread, outside it.
|
||||
A reader that is slow gets the latest version when it next asks, not
|
||||
@@ -274,6 +294,7 @@ class StateHub:
|
||||
self._cond = threading.Condition(threading.Lock())
|
||||
self._sections: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
self._fingerprints: Dict[str, Any] = {}
|
||||
self._volatile: Dict[str, FrozenSet[str]] = {}
|
||||
self._version = 0
|
||||
self.epoch = epoch or uuid.uuid4().hex[:16]
|
||||
self.pid = os.getpid() if pid is None else pid
|
||||
@@ -301,9 +322,11 @@ class StateHub:
|
||||
The value is copied (one level), so the caller may reuse its dict.
|
||||
"""
|
||||
stored = None if value is None else dict(value)
|
||||
fingerprint = _fingerprint(stored, volatile)
|
||||
skip = frozenset(volatile)
|
||||
fingerprint = _fingerprint(stored, skip)
|
||||
with self._cond:
|
||||
self._sections[section] = stored
|
||||
self._volatile[section] = skip
|
||||
if self._fingerprints.get(section, _MISSING) == fingerprint:
|
||||
return False
|
||||
self._fingerprints[section] = fingerprint
|
||||
@@ -333,11 +356,14 @@ class StateHub:
|
||||
"""The :class:`~src.ipc.contract.StateSnapshot` now.
|
||||
|
||||
``since`` with this hub's ``epoch``, still the current version, gives
|
||||
the short ``changed: false`` form.
|
||||
the short ``changed: false`` form, with ``volatile``: each section's
|
||||
volatile keys at their latest values (the rest of the section is
|
||||
what the reader already has).
|
||||
"""
|
||||
with self._cond:
|
||||
version = self._version
|
||||
sections = dict(self._sections)
|
||||
volatile = dict(self._volatile)
|
||||
loop = self.loop()
|
||||
result: Dict[str, Any] = {
|
||||
'schema': STATE_SCHEMA,
|
||||
@@ -349,6 +375,7 @@ class StateHub:
|
||||
}
|
||||
if since is not None and epoch == self.epoch and since == version:
|
||||
result['changed'] = False
|
||||
result['volatile'] = _volatile_values(sections, volatile)
|
||||
return result
|
||||
state: Dict[str, Any] = {name: sections.get(name) for name in STATE_SECTIONS
|
||||
if name != 'loop'}
|
||||
|
||||
Reference in New Issue
Block a user