mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(status): runtime status agrees with the heartbeat; current-status republishes on wake (#726)
The runtime status snapshot now agrees with the display heartbeat, and current-status is republished when the display wakes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -223,6 +223,22 @@ policies are unchanged.
|
|||||||
being stopped, blanks the panel within about a second. It used to stay on
|
being stopped, blanks the panel within about a second. It used to stay on
|
||||||
until the next minute, because the once-a-minute schedule check had
|
until the next minute, because the once-a-minute schedule check had
|
||||||
already run that minute and the session had overridden its answer.
|
already run that minute and the session had overridden its answer.
|
||||||
|
- `/api/v3/plugins/installed` no longer reports the display's plugins as
|
||||||
|
`live` while `/api/v3/health` says `display_loop: stalled`. The runtime
|
||||||
|
snapshot is written from its own thread, which kept going while the render
|
||||||
|
loop was hung. The web interface now also reads the render loop's
|
||||||
|
heartbeat: a fresh snapshot whose process's heartbeat is 60 s or older is
|
||||||
|
`data.runtime.status: "stalled"`, with the per-plugin fields null, and
|
||||||
|
`data.runtime` gains `heartbeat_age_seconds`. A snapshot from a process
|
||||||
|
that no longer exists, as after a watchdog kill (systemd removes the
|
||||||
|
heartbeat when the service stops), is `stale` at once instead of `live`
|
||||||
|
for up to 180 s. No new files or writes: both checks are on the reading
|
||||||
|
side.
|
||||||
|
- `/api/v3/display/current-status` reflects a wake from scheduled-off, a
|
||||||
|
schedule-off blank, or an on-demand session starting or ending at once,
|
||||||
|
even when the mode name stays the same. The display republished its
|
||||||
|
current state only on a mode change or every 30 s, so `is_display_active`
|
||||||
|
and `on_demand_active` could be up to 30 s out of date.
|
||||||
|
|
||||||
### Scrolling
|
### Scrolling
|
||||||
|
|
||||||
|
|||||||
@@ -551,7 +551,8 @@ List all installed plugins with their status and metadata.
|
|||||||
"status": "live",
|
"status": "live",
|
||||||
"published_at": 1790000030.0,
|
"published_at": 1790000030.0,
|
||||||
"age_seconds": 12.4,
|
"age_seconds": 12.4,
|
||||||
"stale_after": 180.0
|
"stale_after": 180.0,
|
||||||
|
"heartbeat_age_seconds": 2.1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,10 +573,15 @@ until the display restarts. A plugin a live snapshot does not list is
|
|||||||
`loaded: false`, `state: "unloaded"`.
|
`loaded: false`, `state: "unloaded"`.
|
||||||
|
|
||||||
`runtime.status` says whether to believe them: `live` (fresh snapshot from
|
`runtime.status` says whether to believe them: `live` (fresh snapshot from
|
||||||
a running display), `stale` (not refreshed within `stale_after` seconds: the
|
a running display), `stalled` (fresh snapshot, but the same process's
|
||||||
display is hung or died), `stopped` (the display shut down) or `unknown`
|
render-loop heartbeat is 60 s or older -- the render loop is hung, as
|
||||||
|
[`/health`](#health-check)'s `display_loop: stalled` says), `stale` (not refreshed
|
||||||
|
within `stale_after` seconds, or the process that wrote it no longer exists:
|
||||||
|
the display is hung or died), `stopped` (the display shut down) or `unknown`
|
||||||
(nothing published yet). Unless it is `live`, every one of those fields is
|
(nothing published yet). Unless it is `live`, every one of those fields is
|
||||||
`null`. Health and metrics are at [`/plugins/health`](#get-plugin-health)
|
`null`. `heartbeat_age_seconds` is the heartbeat's age when it was taken into
|
||||||
|
account, `null` otherwise (no heartbeat, as on the dev server, or one from
|
||||||
|
another process). Health and metrics are at [`/plugins/health`](#get-plugin-health)
|
||||||
and `/plugins/metrics`.
|
and `/plugins/metrics`.
|
||||||
|
|
||||||
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
|
`vegas_participation` is what Vegas mode does with the plugin: `"scroll"`,
|
||||||
@@ -1138,7 +1144,7 @@ it is neither installed nor configured).
|
|||||||
"last_updated": "2025-01-15T10:30:00"
|
"last_updated": "2025-01-15T10:30:00"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runtime": {"status": "live", "published_at": 1790000030.0, "age_seconds": 12.4, "stale_after": 180.0}
|
"runtime": {"status": "live", "published_at": 1790000030.0, "age_seconds": 12.4, "stale_after": 180.0, "heartbeat_age_seconds": 2.1}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -552,6 +552,9 @@ class DisplayController:
|
|||||||
# Last mode written to the display_current_state cache key, and when.
|
# Last mode written to the display_current_state cache key, and when.
|
||||||
self._last_published_mode: Optional[str] = None
|
self._last_published_mode: Optional[str] = None
|
||||||
self._last_published_at = 0.0
|
self._last_published_at = 0.0
|
||||||
|
# (is_display_active, on_demand_active) as last published: a change
|
||||||
|
# to either is republished at once, like a mode change.
|
||||||
|
self._last_published_flags: Optional[Tuple[bool, bool]] = None
|
||||||
self.global_dynamic_config = (
|
self.global_dynamic_config = (
|
||||||
self.config.get("display", {}).get("dynamic_duration", {}) or {}
|
self.config.get("display", {}).get("dynamic_duration", {}) or {}
|
||||||
)
|
)
|
||||||
@@ -1438,14 +1441,24 @@ class DisplayController:
|
|||||||
}
|
}
|
||||||
self.cache_manager.set('display_current_state', state)
|
self.cache_manager.set('display_current_state', state)
|
||||||
self._last_published_mode = self.current_display_mode
|
self._last_published_mode = self.current_display_mode
|
||||||
|
self._last_published_flags = self._current_state_flags()
|
||||||
self._last_published_at = time.monotonic()
|
self._last_published_at = time.monotonic()
|
||||||
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||||
logger.error("Failed to publish current display state: %s", err, exc_info=True)
|
logger.error("Failed to publish current display state: %s", err, exc_info=True)
|
||||||
|
|
||||||
|
def _current_state_flags(self) -> Tuple[bool, bool]:
|
||||||
|
"""The published flags besides the mode that a reader acts on."""
|
||||||
|
return (bool(self.is_display_active), bool(self.on_demand_active))
|
||||||
|
|
||||||
def _publish_current_mode_state_if_changed(self) -> None:
|
def _publish_current_mode_state_if_changed(self) -> None:
|
||||||
"""Publish the current mode state when it changed, or when the last
|
"""Publish the current mode state when it changed, or when the last
|
||||||
publish is older than CURRENT_STATE_REFRESH_SECONDS.
|
publish is older than CURRENT_STATE_REFRESH_SECONDS.
|
||||||
|
|
||||||
|
A change is the mode, or ``is_display_active`` / ``on_demand_active``:
|
||||||
|
waking from scheduled-off, or an on-demand session starting or ending,
|
||||||
|
often leaves the mode name as it was, and the web UI would otherwise
|
||||||
|
show the old flag until the next refresh.
|
||||||
|
|
||||||
The web UI reads this key with a max_age (api_v3/display.py), so a mode
|
The web UI reads this key with a max_age (api_v3/display.py), so a mode
|
||||||
that stays on screen longer than that -- a live game under live
|
that stays on screen longer than that -- a live game under live
|
||||||
priority, a single enabled plugin -- has to be republished or the UI
|
priority, a single enabled plugin -- has to be republished or the UI
|
||||||
@@ -1453,6 +1466,7 @@ class DisplayController:
|
|||||||
every render tick.
|
every render tick.
|
||||||
"""
|
"""
|
||||||
if (self.current_display_mode != self._last_published_mode
|
if (self.current_display_mode != self._last_published_mode
|
||||||
|
or self._current_state_flags() != getattr(self, '_last_published_flags', None)
|
||||||
or time.monotonic() - self._last_published_at >= CURRENT_STATE_REFRESH_SECONDS):
|
or time.monotonic() - self._last_published_at >= CURRENT_STATE_REFRESH_SECONDS):
|
||||||
self._publish_current_mode_state()
|
self._publish_current_mode_state()
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,23 @@ snapshot behind. A display that stops cleanly publishes ``running: false``
|
|||||||
on the way out, so readers see "stopped" at once rather than after the
|
on the way out, so readers see "stopped" at once rather than after the
|
||||||
stale window. Nothing on the reading side reports a runtime fact from a
|
stale window. Nothing on the reading side reports a runtime fact from a
|
||||||
snapshot that is not live.
|
snapshot that is not live.
|
||||||
|
|
||||||
|
Render-loop liveness. The snapshot is written from its own thread, which
|
||||||
|
keeps going when the render loop hangs inside a plugin. So the reader also
|
||||||
|
checks the render loop's heartbeat (``src/display_watchdog.py``, the file
|
||||||
|
``/api/v3/health`` reports as ``checks.display_loop``): a live snapshot from
|
||||||
|
the process whose heartbeat has gone stale is ``stalled``, as the health
|
||||||
|
check says, not ``live``. No extra writes: the heartbeat already exists, on
|
||||||
|
tmpfs. A missing heartbeat (dev server, emulator, Windows, a display still
|
||||||
|
starting up) or one from another process (a display restarted after a
|
||||||
|
watchdog kill) says nothing, and the snapshot is judged on its own.
|
||||||
|
|
||||||
|
A dead publisher. systemd removes the heartbeat's directory when the
|
||||||
|
service stops, so after a watchdog kill there is no heartbeat to go stale.
|
||||||
|
The reader then asks whether the snapshot's ``pid`` still exists (POSIX
|
||||||
|
``kill(pid, 0)``, which sends nothing): a running snapshot from a process
|
||||||
|
that is gone is ``stale`` at once rather than ``live`` for the rest of its
|
||||||
|
``stale_after`` window.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@@ -34,6 +51,7 @@ import time
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Dict, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from src import display_watchdog
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.redaction import redact_credentials
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
@@ -70,6 +88,9 @@ _VERSION_CHARS = 40
|
|||||||
#: Reader statuses. Only LIVE carries runtime facts.
|
#: Reader statuses. Only LIVE carries runtime facts.
|
||||||
LIVE = "live"
|
LIVE = "live"
|
||||||
STALE = "stale"
|
STALE = "stale"
|
||||||
|
#: The snapshot is fresh but the render loop's heartbeat is not: the display
|
||||||
|
#: is hung (or was just killed by the watchdog), as /api/v3/health reports.
|
||||||
|
STALLED = "stalled"
|
||||||
STOPPED = "stopped"
|
STOPPED = "stopped"
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
@@ -281,7 +302,9 @@ class PluginRuntimeView:
|
|||||||
|
|
||||||
``status``: ``live`` (a fresh snapshot from a running display),
|
``status``: ``live`` (a fresh snapshot from a running display),
|
||||||
``stale`` (the last snapshot is older than its ``stale_after``: the
|
``stale`` (the last snapshot is older than its ``stale_after``: the
|
||||||
display is hung or died without cleaning up), ``stopped`` (the display
|
display is hung or died without cleaning up), ``stalled`` (the snapshot
|
||||||
|
is fresh but the same process's render-loop heartbeat is stale: the
|
||||||
|
render loop is hung), ``stopped`` (the display
|
||||||
said so on its way out) or ``unknown`` (no readable snapshot). Only a
|
said so on its way out) or ``unknown`` (no readable snapshot). Only a
|
||||||
live view reports per-plugin facts; every other status answers None for
|
live view reports per-plugin facts; every other status answers None for
|
||||||
them, so a caller cannot pass stale truth on by accident.
|
them, so a caller cannot pass stale truth on by accident.
|
||||||
@@ -292,6 +315,8 @@ class PluginRuntimeView:
|
|||||||
age_seconds: Optional[float] = None
|
age_seconds: Optional[float] = None
|
||||||
stale_after: float = STALE_AFTER
|
stale_after: float = STALE_AFTER
|
||||||
plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
plugins: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||||
|
#: Age of the render loop's heartbeat, when it was taken into account.
|
||||||
|
heartbeat_age_seconds: Optional[float] = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def live(self) -> bool:
|
def live(self) -> bool:
|
||||||
@@ -321,6 +346,8 @@ class PluginRuntimeView:
|
|||||||
"published_at": self.published_at,
|
"published_at": self.published_at,
|
||||||
"age_seconds": None if self.age_seconds is None else round(self.age_seconds, 1),
|
"age_seconds": None if self.age_seconds is None else round(self.age_seconds, 1),
|
||||||
"stale_after": self.stale_after,
|
"stale_after": self.stale_after,
|
||||||
|
"heartbeat_age_seconds": (None if self.heartbeat_age_seconds is None
|
||||||
|
else round(self.heartbeat_age_seconds, 1)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -334,8 +361,56 @@ def _stale_after_of(snapshot: Dict[str, Any]) -> float:
|
|||||||
return min(max(number, _STALE_AFTER_MIN), _STALE_AFTER_MAX)
|
return min(max(number, _STALE_AFTER_MIN), _STALE_AFTER_MAX)
|
||||||
|
|
||||||
|
|
||||||
def view_from_snapshot(snapshot: Any, now: Optional[float] = None) -> PluginRuntimeView:
|
def _heartbeat_age_for(snapshot: Dict[str, Any], heartbeat: Any,
|
||||||
"""Judge a snapshot read from the cache; never raises."""
|
now_mono: Optional[float]) -> Optional[float]:
|
||||||
|
"""Age of ``heartbeat`` if it comes from the process that published
|
||||||
|
``snapshot``; None when there is none, it has no time, or it belongs to
|
||||||
|
another process (a restarted display, or a heartbeat left by a killed one)."""
|
||||||
|
if not isinstance(heartbeat, dict):
|
||||||
|
return None
|
||||||
|
beat_pid = heartbeat.get("pid")
|
||||||
|
snap_pid = snapshot.get("pid")
|
||||||
|
if (isinstance(beat_pid, bool) or not isinstance(beat_pid, int)
|
||||||
|
or isinstance(snap_pid, bool) or not isinstance(snap_pid, int)
|
||||||
|
or beat_pid != snap_pid):
|
||||||
|
return None
|
||||||
|
return display_watchdog.heartbeat_age(heartbeat, now_mono=now_mono)
|
||||||
|
|
||||||
|
|
||||||
|
def process_exists(pid: int) -> Optional[bool]:
|
||||||
|
"""Whether process ``pid`` exists: True, False, or None when this
|
||||||
|
platform cannot tell. POSIX only -- on Windows ``os.kill`` terminates.
|
||||||
|
Signal 0 sends nothing; EPERM (the display runs as root, the web
|
||||||
|
interface does not) still means the process is there."""
|
||||||
|
if os.name != "posix" or pid <= 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def view_from_snapshot(snapshot: Any, now: Optional[float] = None,
|
||||||
|
heartbeat: Any = None,
|
||||||
|
now_mono: Optional[float] = None,
|
||||||
|
process_alive: Optional[Callable[[int], Optional[bool]]] = None,
|
||||||
|
) -> PluginRuntimeView:
|
||||||
|
"""Judge a snapshot read from the cache; never raises.
|
||||||
|
|
||||||
|
``heartbeat`` is the render loop's heartbeat
|
||||||
|
(``display_watchdog.read_heartbeat()``), or None when there is none. A
|
||||||
|
live snapshot whose process's heartbeat is at least
|
||||||
|
``display_watchdog.HEARTBEAT_STALE_SECONDS`` old is ``stalled``: the
|
||||||
|
threshold /api/v3/health uses for ``checks.display_loop``.
|
||||||
|
``process_alive`` (``process_exists`` when reading the real cache) says
|
||||||
|
whether the snapshot's publisher still exists; a running snapshot from
|
||||||
|
one that is gone is ``stale``.
|
||||||
|
"""
|
||||||
if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA:
|
if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_SCHEMA:
|
||||||
return PluginRuntimeView(status=UNKNOWN)
|
return PluginRuntimeView(status=UNKNOWN)
|
||||||
published_at = _epoch(snapshot.get("published_at"))
|
published_at = _epoch(snapshot.get("published_at"))
|
||||||
@@ -351,21 +426,34 @@ def view_from_snapshot(snapshot: Any, now: Optional[float] = None) -> PluginRunt
|
|||||||
if age > stale_after or age < -stale_after:
|
if age > stale_after or age < -stale_after:
|
||||||
return PluginRuntimeView(status=STALE, published_at=published_at,
|
return PluginRuntimeView(status=STALE, published_at=published_at,
|
||||||
age_seconds=age, stale_after=stale_after)
|
age_seconds=age, stale_after=stale_after)
|
||||||
|
pid = snapshot.get("pid")
|
||||||
|
if (process_alive is not None and isinstance(pid, int) and not isinstance(pid, bool)
|
||||||
|
and process_alive(pid) is False):
|
||||||
|
return PluginRuntimeView(status=STALE, published_at=published_at,
|
||||||
|
age_seconds=max(age, 0.0), stale_after=stale_after)
|
||||||
|
beat_age = _heartbeat_age_for(snapshot, heartbeat, now_mono)
|
||||||
|
if beat_age is not None and beat_age >= display_watchdog.HEARTBEAT_STALE_SECONDS:
|
||||||
|
return PluginRuntimeView(status=STALLED, published_at=published_at,
|
||||||
|
age_seconds=max(age, 0.0), stale_after=stale_after,
|
||||||
|
heartbeat_age_seconds=beat_age)
|
||||||
plugins = snapshot.get("plugins")
|
plugins = snapshot.get("plugins")
|
||||||
return PluginRuntimeView(
|
return PluginRuntimeView(
|
||||||
status=LIVE, published_at=published_at, age_seconds=max(age, 0.0),
|
status=LIVE, published_at=published_at, age_seconds=max(age, 0.0),
|
||||||
stale_after=stale_after,
|
stale_after=stale_after, heartbeat_age_seconds=beat_age,
|
||||||
plugins={k: v for k, v in plugins.items() if isinstance(v, dict)}
|
plugins={k: v for k, v in plugins.items() if isinstance(v, dict)}
|
||||||
if isinstance(plugins, dict) else {},
|
if isinstance(plugins, dict) else {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def read_plugin_runtime(cache_manager: Any, now: Optional[float] = None) -> PluginRuntimeView:
|
def read_plugin_runtime(cache_manager: Any, now: Optional[float] = None,
|
||||||
"""The display's latest snapshot, judged for staleness. Never raises; a
|
heartbeat_path: Optional[str] = None) -> PluginRuntimeView:
|
||||||
missing cache manager or an unreadable snapshot is ``unknown``.
|
"""The display's latest snapshot, judged for staleness and against the
|
||||||
|
render loop's heartbeat. Never raises; a missing cache manager or an
|
||||||
|
unreadable snapshot is ``unknown``.
|
||||||
|
|
||||||
memory_ttl=0: the key is written by the other process, so only the file
|
memory_ttl=0: the key is written by the other process, so only the file
|
||||||
is current.
|
is current. ``heartbeat_path`` defaults to
|
||||||
|
``display_watchdog.HEARTBEAT_PATH``.
|
||||||
"""
|
"""
|
||||||
if cache_manager is None:
|
if cache_manager is None:
|
||||||
return PluginRuntimeView(status=UNKNOWN)
|
return PluginRuntimeView(status=UNKNOWN)
|
||||||
@@ -374,4 +462,11 @@ def read_plugin_runtime(cache_manager: Any, now: Optional[float] = None) -> Plug
|
|||||||
except Exception as err:
|
except Exception as err:
|
||||||
logger.debug("Could not read the plugin runtime snapshot: %s", err, exc_info=True)
|
logger.debug("Could not read the plugin runtime snapshot: %s", err, exc_info=True)
|
||||||
return PluginRuntimeView(status=UNKNOWN)
|
return PluginRuntimeView(status=UNKNOWN)
|
||||||
return view_from_snapshot(snapshot, now=now)
|
try:
|
||||||
|
heartbeat = display_watchdog.read_heartbeat(
|
||||||
|
heartbeat_path or display_watchdog.HEARTBEAT_PATH)
|
||||||
|
except Exception as err: # read_heartbeat does not raise; belt and braces
|
||||||
|
logger.debug("Could not read the display heartbeat: %s", err, exc_info=True)
|
||||||
|
heartbeat = None
|
||||||
|
return view_from_snapshot(snapshot, now=now, heartbeat=heartbeat,
|
||||||
|
process_alive=process_exists)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
- systemd stops the service with SIGTERM; it must run the same cleanup as Ctrl-C.
|
- systemd stops the service with SIGTERM; it must run the same cleanup as Ctrl-C.
|
||||||
- The current mode is republished while it stays on screen, so the web UI's
|
- The current mode is republished while it stays on screen, so the web UI's
|
||||||
"Now showing" does not turn into "unknown" after its 120 s max_age.
|
"Now showing" does not turn into "unknown" after its 120 s max_age.
|
||||||
|
- Waking from scheduled-off, blanking, or on-demand starting or ending with
|
||||||
|
the mode name unchanged is republished at once, not up to 30 s later.
|
||||||
- Switching Vegas on in the web UI works when Vegas was off at startup.
|
- Switching Vegas on in the web UI works when Vegas was off at startup.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -78,6 +80,67 @@ def test_unchanged_mode_is_republished_after_the_refresh_interval():
|
|||||||
assert dc.cache_manager.set.call_count == 2
|
assert dc.cache_manager.set.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def _published(dc):
|
||||||
|
return dc.cache_manager.set.call_args[0][1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("attr,before,after", [
|
||||||
|
("is_display_active", False, True), # woke from scheduled-off
|
||||||
|
("is_display_active", True, False), # blanked for scheduled-off
|
||||||
|
("on_demand_active", False, True), # on-demand started on this mode
|
||||||
|
("on_demand_active", True, False), # ...and ended on it
|
||||||
|
])
|
||||||
|
def test_a_flag_change_with_the_same_mode_is_republished_at_once(attr, before, after):
|
||||||
|
"""ledpi: current-status showed is_display_active stale for up to 30 s
|
||||||
|
after a wake from scheduled-off, because the mode name had not changed."""
|
||||||
|
dc = _publisher()
|
||||||
|
setattr(dc, attr, before)
|
||||||
|
now = [1000.0]
|
||||||
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
||||||
|
dc._publish_current_mode_state() # e.g. the blank's publish
|
||||||
|
assert _published(dc)[attr] is before
|
||||||
|
now[0] += 1 # well inside the refresh
|
||||||
|
setattr(dc, attr, after)
|
||||||
|
dc._publish_current_mode_state_if_changed() # the next publish point
|
||||||
|
assert dc.cache_manager.set.call_count == 2
|
||||||
|
assert _published(dc)[attr] is after
|
||||||
|
assert _published(dc)["mode"] == "mlb_live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wake_inside_the_blank_sleep_is_published_by_the_service_helper():
|
||||||
|
"""The blank sleeps through _service_pending_changes; the schedule turning
|
||||||
|
the display back on there reaches the web UI on that same call."""
|
||||||
|
dc = _publisher()
|
||||||
|
dc.is_display_active = False
|
||||||
|
dc._last_pending_service = None
|
||||||
|
dc.PENDING_CHANGES_INTERVAL = 0.25
|
||||||
|
for name in ("_poll_on_demand_requests", "_check_on_demand_expiration",
|
||||||
|
"_apply_brightness_target"):
|
||||||
|
setattr(dc, name, MagicMock())
|
||||||
|
|
||||||
|
def wake():
|
||||||
|
dc.is_display_active = True
|
||||||
|
dc._evaluate_schedule = MagicMock(side_effect=wake)
|
||||||
|
now = [5000.0]
|
||||||
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
||||||
|
dc._publish_current_mode_state() # published while blank
|
||||||
|
now[0] += 2
|
||||||
|
dc._service_pending_changes()
|
||||||
|
assert dc.cache_manager.set.call_count == 2
|
||||||
|
assert _published(dc)["is_display_active"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_unchanged_flags_do_not_add_writes():
|
||||||
|
dc = _publisher()
|
||||||
|
now = [1000.0]
|
||||||
|
with patch.object(dc_module.time, "monotonic", lambda: now[0]):
|
||||||
|
dc._publish_current_mode_state_if_changed()
|
||||||
|
for _ in range(5):
|
||||||
|
now[0] += 1
|
||||||
|
dc._publish_current_mode_state_if_changed()
|
||||||
|
assert dc.cache_manager.set.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_refresh_interval_is_well_inside_the_web_max_age():
|
def test_refresh_interval_is_well_inside_the_web_max_age():
|
||||||
# api_v3/display.py reads display_current_state with max_age=120.
|
# api_v3/display.py reads display_current_state with max_age=120.
|
||||||
assert dc_module.CURRENT_STATE_REFRESH_SECONDS < 120 / 2
|
assert dc_module.CURRENT_STATE_REFRESH_SECONDS < 120 / 2
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ The cross-process tests use two CacheManagers over one temporary directory,
|
|||||||
the arrangement of the real services (which share /var/cache/ledmatrix).
|
the arrangement of the real services (which share /var/cache/ledmatrix).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from src import display_watchdog # noqa: E402
|
||||||
from src.cache_manager import CacheManager # noqa: E402
|
from src.cache_manager import CacheManager # noqa: E402
|
||||||
from src.plugin_system import plugin_runtime as rt # noqa: E402
|
from src.plugin_system import plugin_runtime as rt # noqa: E402
|
||||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||||
@@ -305,6 +308,74 @@ class TestReader:
|
|||||||
eager = _snapshot(self.NOW - 20, stale_after=0)
|
eager = _snapshot(self.NOW - 20, stale_after=0)
|
||||||
assert view_from_snapshot(eager, now=self.NOW).status == "live"
|
assert view_from_snapshot(eager, now=self.NOW).status == "live"
|
||||||
|
|
||||||
|
# -- render-loop liveness (the heartbeat /api/v3/health reads) --
|
||||||
|
|
||||||
|
MONO = 50_000.0
|
||||||
|
|
||||||
|
def _beat(self, age, pid=4242):
|
||||||
|
return {"pid": pid, "mono": self.MONO - age, "wall": self.NOW - age}
|
||||||
|
|
||||||
|
def test_a_stale_heartbeat_with_a_live_snapshot_is_stalled(self):
|
||||||
|
"""The publisher thread keeps writing while the render loop is
|
||||||
|
hung; the heartbeat says so, and the runtime status must agree with
|
||||||
|
/api/v3/health's display_loop: stalled."""
|
||||||
|
snapshot = _snapshot(self.NOW - 5, plugins={"clock": self.CLOCK}, pid=4242)
|
||||||
|
view = view_from_snapshot(
|
||||||
|
snapshot, now=self.NOW, now_mono=self.MONO,
|
||||||
|
heartbeat=self._beat(display_watchdog.HEARTBEAT_STALE_SECONDS + 1))
|
||||||
|
assert view.status == "stalled"
|
||||||
|
assert view.plugin("clock")["loaded"] is None # no frozen truth passed on
|
||||||
|
described = view.describe()
|
||||||
|
assert described["status"] == "stalled"
|
||||||
|
assert described["heartbeat_age_seconds"] == display_watchdog.HEARTBEAT_STALE_SECONDS + 1
|
||||||
|
|
||||||
|
def test_the_threshold_is_the_health_checks(self):
|
||||||
|
snapshot = _snapshot(self.NOW - 5, pid=4242)
|
||||||
|
limit = display_watchdog.HEARTBEAT_STALE_SECONDS
|
||||||
|
fresh = view_from_snapshot(snapshot, now=self.NOW, now_mono=self.MONO,
|
||||||
|
heartbeat=self._beat(limit - 0.5))
|
||||||
|
assert fresh.status == "live"
|
||||||
|
assert fresh.describe()["heartbeat_age_seconds"] == limit - 0.5
|
||||||
|
assert view_from_snapshot(snapshot, now=self.NOW, now_mono=self.MONO,
|
||||||
|
heartbeat=self._beat(limit)).status == "stalled"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("heartbeat", [
|
||||||
|
None, # dev server, Windows, starting up
|
||||||
|
{"pid": 9999, "mono": 0.0}, # another process: a restarted display
|
||||||
|
{"pid": "4242", "mono": 0.0}, # unparseable pid
|
||||||
|
{"pid": 4242}, # no time in it
|
||||||
|
])
|
||||||
|
def test_a_heartbeat_that_says_nothing_leaves_it_live(self, heartbeat):
|
||||||
|
snapshot = _snapshot(self.NOW - 5, plugins={"clock": self.CLOCK}, pid=4242)
|
||||||
|
view = view_from_snapshot(snapshot, now=self.NOW, now_mono=self.MONO,
|
||||||
|
heartbeat=heartbeat)
|
||||||
|
assert view.status == "live"
|
||||||
|
assert view.plugin("clock")["loaded"] is True
|
||||||
|
|
||||||
|
def test_a_stale_snapshot_stays_stale_whatever_the_heartbeat(self):
|
||||||
|
snapshot = _snapshot(self.NOW - 181, pid=4242)
|
||||||
|
assert view_from_snapshot(snapshot, now=self.NOW, now_mono=self.MONO,
|
||||||
|
heartbeat=self._beat(500)).status == "stale"
|
||||||
|
|
||||||
|
def test_a_snapshot_from_a_dead_process_is_stale_at_once(self):
|
||||||
|
"""After a watchdog kill systemd removes the heartbeat's directory, so
|
||||||
|
nothing goes stale; the publisher's pid being gone is the signal."""
|
||||||
|
snapshot = _snapshot(self.NOW - 5, plugins={"clock": self.CLOCK}, pid=4242)
|
||||||
|
dead = view_from_snapshot(snapshot, now=self.NOW,
|
||||||
|
process_alive=lambda pid: False)
|
||||||
|
assert dead.status == "stale"
|
||||||
|
assert dead.plugin("clock")["loaded"] is None
|
||||||
|
for answer in (True, None): # alive, or this platform cannot tell
|
||||||
|
assert view_from_snapshot(snapshot, now=self.NOW,
|
||||||
|
process_alive=lambda pid, a=answer: a).status == "live"
|
||||||
|
|
||||||
|
def test_process_exists(self):
|
||||||
|
if os.name != "posix":
|
||||||
|
assert rt.process_exists(os.getpid()) is None
|
||||||
|
else:
|
||||||
|
assert rt.process_exists(os.getpid()) is True
|
||||||
|
assert rt.process_exists(0) is None
|
||||||
|
|
||||||
def test_no_cache_manager_or_a_failing_one_is_unknown(self):
|
def test_no_cache_manager_or_a_failing_one_is_unknown(self):
|
||||||
assert read_plugin_runtime(None).status == "unknown"
|
assert read_plugin_runtime(None).status == "unknown"
|
||||||
cache = MagicMock()
|
cache = MagicMock()
|
||||||
@@ -424,6 +495,36 @@ class TestInstalledPluginsRoute:
|
|||||||
assert runtime["age_seconds"] > rt.STALE_AFTER
|
assert runtime["age_seconds"] > rt.STALE_AFTER
|
||||||
assert plugins["clock"]["loaded"] is None
|
assert plugins["clock"]["loaded"] is None
|
||||||
|
|
||||||
|
def _write_heartbeat(self, tmp_path, monkeypatch, age):
|
||||||
|
path = tmp_path / "display-heartbeat.json"
|
||||||
|
path.write_text(json.dumps({"pid": os.getpid(), "mono": time.monotonic() - age,
|
||||||
|
"wall": time.time() - age}), encoding="utf-8")
|
||||||
|
monkeypatch.setattr(display_watchdog, "HEARTBEAT_PATH", str(path))
|
||||||
|
|
||||||
|
def test_hung_render_loop_is_stalled_not_live(self, web_listing, tmp_path, monkeypatch):
|
||||||
|
"""The publisher's own thread still ticks while the render loop is
|
||||||
|
stuck; the heartbeat it shares a process with has gone stale."""
|
||||||
|
display_cache, get = web_listing
|
||||||
|
self._display(display_cache).tick() # a fresh snapshot, this pid
|
||||||
|
self._write_heartbeat(tmp_path, monkeypatch,
|
||||||
|
display_watchdog.HEARTBEAT_STALE_SECONDS + 30)
|
||||||
|
|
||||||
|
plugins, runtime = get()
|
||||||
|
|
||||||
|
assert runtime["status"] == "stalled"
|
||||||
|
assert runtime["heartbeat_age_seconds"] >= display_watchdog.HEARTBEAT_STALE_SECONDS
|
||||||
|
assert plugins["clock"]["loaded"] is None and plugins["clock"]["state"] is None
|
||||||
|
|
||||||
|
def test_fresh_heartbeat_keeps_it_live(self, web_listing, tmp_path, monkeypatch):
|
||||||
|
display_cache, get = web_listing
|
||||||
|
self._display(display_cache).tick()
|
||||||
|
self._write_heartbeat(tmp_path, monkeypatch, 1)
|
||||||
|
|
||||||
|
plugins, runtime = get()
|
||||||
|
|
||||||
|
assert runtime["status"] == "live"
|
||||||
|
assert plugins["clock"]["loaded"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestDisplayControllerStopsThePublisher:
|
class TestDisplayControllerStopsThePublisher:
|
||||||
def test_cleanup_publishes_stopped(self, monkeypatch):
|
def test_cleanup_publishes_stopped(self, monkeypatch):
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ def get_installed_plugins():
|
|||||||
display publishes (src/plugin_system/plugin_runtime.py), and only while
|
display publishes (src/plugin_system/plugin_runtime.py), and only while
|
||||||
that snapshot is live: when the display is stopped, hung or has never
|
that snapshot is live: when the display is stopped, hung or has never
|
||||||
published, they are null and ``data.runtime.status`` says why
|
published, they are null and ``data.runtime.status`` says why
|
||||||
(``stale``, ``stopped``, ``unknown``) instead of passing on old truth.
|
(``stalled``, ``stale``, ``stopped``, ``unknown``) instead of passing on
|
||||||
|
old truth.
|
||||||
Health, metrics and errors are served by /plugins/health,
|
Health, metrics and errors are served by /plugins/health,
|
||||||
/plugins/metrics and /errors.
|
/plugins/metrics and /errors.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user