Files
LEDMatrix/test/test_plugin_health.py
T
0c5b9c57d3 fix: keep low-memory boards reachable under load (#464)
* fix(service): survive corrupt health cache and clean exits

Three independent failure modes that each end with a dark panel and no
automatic recovery.

1. PluginHealthTracker._load_health_state returned the cached value
   verbatim. If that value is not a dict, every caller raises
   AttributeError: 'list' object has no attribute 'get' — during
   DisplayController.__init__, so the process dies before the display
   loop starts. systemd restarts it, the same bad entry is read back
   from disk, and it dies again: an unattended restart loop that
   survives reboots because the cause is persisted. Observed in the
   field with plugin_health:<id> holding an unrelated plugin's list
   payload. Now non-dict entries are discarded with a warning and the
   defaults are rebuilt.

2. ledmatrix.service used Restart=on-failure, so any exit with status 0
   left the unit stopped and the panel dark indefinitely — systemd
   treats it as success and never brings it back. Restart=always.

3. ledmatrix-wifi-monitor.service used StandardOutput=syslog, which
   systemd has marked obsolete; it warns and rewrites it to journal on
   every load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(memory): size the cache to the board and stop reinstalling deps

On a 1GB Pi 3B+ the display process settles around 600MB RSS of 905MB
total. When the remaining headroom runs out the failure is not a clean
crash: fork() starts returning ENOMEM, so sshd accepts connections and
closes them before its banner, timer jobs stop running, and the panel
goes dark, while already-resident processes keep serving normally. The
board looks healthy from outside and cannot be logged into. Only a power
cycle clears it.

Three contributing causes:

- MemoryCache had a fixed 1000-entry ceiling. Entries are parsed API
  payloads of tens of KB, so one ceiling cannot serve both a 512MB Zero
  2 W and an 8GB Pi 5. Now scaled from MemTotal (150 entries at <=1GB,
  1500 at >=8GB), overridable with LEDMATRIX_CACHE_MAX_ENTRIES.

- requirements_are_satisfied() returned False for any requirement with
  extras, so a plugin depending on python-socketio[client] re-ran pip on
  every single start: ~8s, a network dependency, and a 100-200MB spike
  at the least convenient moment. During a restart loop it repeats for
  each restart. Extras are now resolved one level deep against installed
  metadata, keeping the conservative "anything unverifiable falls
  through to pip" contract.

- ledmatrix.service had no memory ceiling. MemoryMax=85% expressed as a
  percentage so one unit file suits every board. Note this needs the
  memory cgroup controller, which Pi firmware disables by default;
  first_time_install.sh now adds cgroup_enable=memory to cmdline.txt,
  and the unit file documents how to verify it took effect.

first_time_install.sh also enables persistent journald storage (capped
at 64M). Default storage is volatile, so every reboot destroys the logs
that would explain why the board rebooted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: guidance for 512MB and 1GB boards

Documents the memory ceiling on small boards and, more usefully, what
running into it actually looks like: sshd accepting connections and
closing them before the banner, the web UI still responding normally,
clean ping, a dark panel, and a wrong clock after the next boot. None of
those read as "out of memory", which makes the failure hard to identify
from the symptoms.

Cross-referenced from SSH_UNAVAILABLE_AFTER_INSTALL.md, since "I can't
SSH in any more" is how most people will first meet this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address review findings on the low-memory work

Nine CodeRabbit findings, five in code.

**Health state (the one that matters).** The non-dict guard did not cover a
dict missing fields the callers index directly, which is the shape actually
seen in the wild: a record carrying only circuit_state produced
`plugin clock-simple operation failed: 'circuit_state'` about fifty times a
minute with the panel frozen. The record is now completed against the
defaults per field rather than trusted or discarded wholesale. Per field
matters: a first pass rejected any incomplete record outright, which reset a
tripped breaker and real failure counts to healthy because one optional
field was absent -- an existing test caught it. Values of the wrong type
(a counter persisted as a string, an unknown circuit_state) fall back
individually, valid neighbours survive, and newer fields the schema has
grown since (degraded, degraded_reason) are carried through untouched.

**Cache ceiling.** MemoryCache.set() accepted entries without bound between
cleanup sweeps, which run every 300s by default, so a burst could take the
cache far past max_size -- the unbounded growth the limit exists to stop.
Eviction now runs under the same lock on every write, sharing one helper
with the periodic sweep so the two cannot drift.

**Installer, cgroups.** Only cgroup_enable=memory was checked, so a board
carrying that without cgroup_memory=1 reported success and got no change,
leaving MemoryMax= inert. Each parameter is now checked and appended
independently; verified against all four combinations, single line preserved.

**Installer, journald.** Persistence was inferred from /var/log/journal being
non-empty, which proves neither Storage=persistent nor a size cap -- the
directory survives a switch back to volatile. The effective configuration is
read instead (systemd-analyze cat-config, falling back to the conf files),
and an explicitly configured SystemMaxUse is preserved rather than
overwritten. Verified across volatile, persistent-without-cap,
persistent-with-user-cap, cap-without-storage, and commented-only configs.

**Dependency extras.** _extras_are_satisfied stopped at one level, so a
gated dependency that itself requests an extra (requests[socks]) passed on
the base distribution's version while the extra's own dependency was
missing, and pip was skipped. It now recurses, with a visited
(distribution, extras) set so a cycle terminates.

Docs: both kernel command-line paths documented (the installer falls back to
/boot/cmdline.txt), daemon-reload and restart added after the systemd
override example, memory exhaustion added to the SSH summary with its
power-cycle-only recovery, and a language on the fenced block for MD040.

Tests: five for the health-state repair including the exact wild shape and
that record_failure/record_success no longer raise against it, and one for
the cache ceiling. Both mutation-checked. Full suite 2927 passed, with the
one pre-existing tmpfs failure that also fails on main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

* fix: harden the health-state repair and confirm journald took effect

Second review round; all three findings were valid and two were bugs in the
repair added last commit.

The repair could raise out of itself. An unhashable circuit_state (a list or
dict on disk) hit `value in {...}` and raised TypeError -- from the code
whose whole job is to stop a malformed record crashing the caller. It now
requires a str before the membership test.

bool is a subclass of int, so True passed the timestamp check and then
compared as 1.0: enough to expire a cooldown the instant the breaker opened,
while False would stop the elapsed check firing at all. Timestamps now
exclude bool explicitly.

The regression test for the original crash was seeded with a record that
*contained* circuit_state, so it passed against the old raw-return behaviour
too -- the counters are read with .get(), so circuit_state is the only field
whose absence used to raise. Reseeded to omit it, and it now fails against
raw-return as intended.

journald: drop-ins apply in lexical order, so a local file sorting after
ledmatrix-persistent.conf still wins and writing ours proves nothing. The
effective Storage is re-read afterwards and a warning naming the diagnostic
command is printed if persistence is still not active, rather than reporting
a success that was not verified.

Full suite 2934 passed, same single pre-existing tmpfs failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STMbQE4YctTacQXfbYqKuW

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 12:28:22 -04:00

192 lines
7.3 KiB
Python

"""
Tests for src/plugin_system/plugin_health.py
Focus on the additive ``set_degraded`` mechanism used by the warn-only schema
validation path: it must surface a degraded reason without touching the circuit
breaker or causing the plugin to be skipped.
"""
from unittest.mock import MagicMock
from src.plugin_system.plugin_health import PluginHealthTracker, CircuitState
def _cache():
cache = MagicMock()
cache.get.return_value = None
return cache
def test_set_degraded_marks_and_surfaces_reason():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
assert summary["degraded"] is True
assert summary["degraded_reason"] == "bad config"
def test_set_degraded_none_clears():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
tracker.set_degraded("p", None)
summary = tracker.get_health_summary("p")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_set_degraded_does_not_affect_circuit_breaker():
tracker = PluginHealthTracker(_cache())
tracker.set_degraded("p", "bad config")
summary = tracker.get_health_summary("p")
# Degraded is a *separate* signal from circuit health: the plugin is not
# counted as failing, the circuit stays closed, and it is not skipped.
assert summary["circuit_state"] == CircuitState.CLOSED.value
assert summary["consecutive_failures"] == 0
assert summary["is_healthy"] is True
assert tracker.should_skip_plugin("p") is False
def test_set_degraded_skips_redundant_cache_write():
cache = _cache()
tracker = PluginHealthTracker(cache)
tracker.set_degraded("p", "x")
writes_after_first = cache.set.call_count
assert writes_after_first >= 1
tracker.set_degraded("p", "x") # unchanged → no extra write
assert cache.set.call_count == writes_after_first
def test_default_summary_has_degraded_fields():
tracker = PluginHealthTracker(_cache())
summary = tracker.get_health_summary("never-seen")
assert summary["degraded"] is False
assert summary["degraded_reason"] is None
def test_force_reload_refreshes_stale_in_memory_snapshot():
"""A long-lived reader (e.g. the web process) must not be pinned to the
first snapshot: force_reload re-reads persisted state and bypasses the
cache manager's memory tier so cross-process updates are visible."""
cache = _cache()
tracker = PluginHealthTracker(cache)
# First read snapshots an empty (healthy) state into the in-memory copy.
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# The display service later persists a failing/open state.
cache.get.return_value = {
"consecutive_failures": 5,
"circuit_state": "open",
"total_failures": 5,
"total_successes": 0,
}
# A plain read is still pinned to the stale snapshot...
assert tracker.get_health_summary("p")["consecutive_failures"] == 0
# ...but force_reload observes the new persisted state.
fresh = tracker.get_health_summary("p", force_reload=True)
assert fresh["consecutive_failures"] == 5
assert fresh["circuit_state"] == "open"
# and it asked the cache to bypass the in-memory tier (memory_ttl=0).
assert any(c.kwargs.get("memory_ttl") == 0 for c in cache.get.call_args_list)
# --- persisted state that does not match the current schema -------------------
#
# A record on disk can be missing fields the callers index directly: a partial
# write, a restored backup, or a state written by an older schema. Returning it
# verbatim raises KeyError inside record_success / record_failure, which takes
# the display down in a restart loop that survives reboots, because the bad
# entry is on disk and gets read again on the way back up. Observed in the wild
# as `plugin clock-simple operation failed: 'circuit_state'`, repeating ~50x a
# minute with the panel frozen.
_INDEXED_FIELDS = (
"consecutive_failures", "total_failures", "total_successes",
"last_success_time", "last_failure_time", "circuit_state",
"circuit_opened_time", "half_open_start_time", "last_error",
)
def _tracker_reading(persisted):
cache = _cache()
cache.get.return_value = persisted
return PluginHealthTracker(cache)
def test_partial_state_is_completed_not_returned_raw():
"""The shape seen in the wild: one field, everything else absent."""
state = _tracker_reading({"circuit_state": "closed"}).get_health_state("p")
for field in _INDEXED_FIELDS:
assert field in state, f"{field} missing; callers index it directly"
def test_repair_keeps_real_failure_history():
"""A record with genuine counts must not be reset to healthy just because
an optional field is absent -- that would clear a tripped breaker."""
state = _tracker_reading({
"consecutive_failures": 5,
"total_failures": 5,
"circuit_state": "open",
}).get_health_state("p")
assert state["consecutive_failures"] == 5
assert state["total_failures"] == 5
assert state["circuit_state"] == "open"
def test_wrong_types_fall_back_per_field():
"""A counter persisted as a string would pass a membership check and then
fail on the first += 1; an unknown circuit_state would take a branch the
breaker has no handling for."""
state = _tracker_reading({
"consecutive_failures": "3",
"circuit_state": "melted",
"total_failures": 7,
}).get_health_state("p")
assert state["consecutive_failures"] == 0
assert state["circuit_state"] == CircuitState.CLOSED.value
assert state["total_failures"] == 7, "valid neighbours must survive"
def test_newer_fields_are_carried_through():
"""degraded/degraded_reason are read with .get() and are not part of the
indexed set; repairing must not drop them."""
state = _tracker_reading({
"circuit_state": "closed", "degraded": True, "degraded_reason": "x",
}).get_health_state("p")
assert state["degraded"] is True
assert state["degraded_reason"] == "x"
def test_recording_against_a_repaired_state_does_not_raise():
"""The actual failure: record_failure indexing a field that was not there.
The seed deliberately omits circuit_state. Seeding a record that *has* it
would pass against the old raw-return behaviour too -- the counters are
read with .get(), so circuit_state is the only field whose absence used to
raise.
"""
tracker = _tracker_reading({"total_failures": 2, "total_successes": 1})
tracker.record_failure("p", Exception("boom"))
tracker.record_success("p")
def test_unhashable_or_boolean_values_are_repaired():
"""Values that break the repair itself rather than a later caller.
An unhashable circuit_state raises TypeError inside a set membership test,
and bool is a subclass of int, so True would pass as a timestamp and then
compare as 1.0 -- expiring a cooldown the moment it opens.
"""
for bad_state in ({"circuit_state": []}, {"circuit_state": {}}):
state = _tracker_reading(bad_state).get_health_state("p")
assert state["circuit_state"] == CircuitState.CLOSED.value
state = _tracker_reading({
"circuit_opened_time": True, "last_success_time": False,
}).get_health_state("p")
assert state["circuit_opened_time"] is None
assert state["last_success_time"] is None