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:
Chuck
2026-10-02 17:47:02 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8136a2d525
commit c14002edc3
7 changed files with 311 additions and 15 deletions
+16
View File
@@ -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
+11 -5
View File
@@ -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}
} }
``` ```
+14
View File
@@ -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()
+104 -9
View File
@@ -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
+101
View File
@@ -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):
+2 -1
View File
@@ -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.
""" """