Display client watchdog and secondary health auditor
Display client watchdog and secondary health auditor
The watchdog is a layered, client-only recovery design for the constrained display Raspberry Pi. It does not reboot, shut down, power-cycle, or control a smart plug. Host distress and upstream failure are escalation conditions, not reasons to restart more aggressively.
Older setup/update scripts configured Linux /dev/watchdog with a 15-second
timeout and load thresholds, while the startup scripts disabled the same
service. This was contradictory and unsafe: swap or I/O distress can raise load
without proving an application failure, turning the known Pi pressure mode into
a host reboot. The setup/update paths no longer install, enable, configure, or
toggle that hardware watchdog. Existing installations can remove the legacy
configuration with the main uninstall script after a separately approved live
change; this candidate does not mutate it.
Detection and recovery layers
The split client architecture owns systemd notify semantics as its primary hang detector:
display-client.serviceusesType=notify,NotifyAccess=main, andWatchdogSec=45.- Its unit bounds starts with
StartLimitIntervalSec=6h,StartLimitBurst=3, andStartLimitAction=none; service recovery usesRestart=on-failure,RestartSec=10,TimeoutStartSec=60, andTimeoutStopSec=30. - Its main loop sends
READY=1after hardware initialization. Fresh-frame readiness remains independently visible in the client health file and secondary auditor rather than blocking service startup through a server outage. - That same main loop sends
WATCHDOG=1after each completed bounded poll, including a safely classified rejection. A200succeeds only after the validated display update; a fresh304preserves or restores the in-memory verified frame. Rejected data never updates protocol state or reaches the panel; after the configured threshold the client may instead draw its tiny local-only outage diagnostic. - No helper or timer sends keepalives. A client thread wedged in rendering, lock acquisition, SPI, or I/O therefore cannot be hidden by a healthy helper.
- Clean shutdown does not clear the e-paper. Whole-host
RuntimeWatchdogSec, hardware watchdog activation, reboot, and power actions are not configured.
The architecture change owns the client implementation and unit. This candidate
does not supply another notifier, client unit, or keepalive path. The current
monolith can retain display.service during migration.
The optional minute-level display-watchdog.timer is a secondary auditor, not
the split client’s watchdog. It is not installed unless the setup script is run,
and automatic fallback recovery remains disabled by default. It checks:
- systemd main PID, active/sub states, exit status,
NRestarts, and whether the client is actually notify/watchdog guarded; - the tmpfs client state, sequence, ETag, last successful fresh response, and matching boot ID/main PID;
- optional server JSON or HTTP freshness, including timestamps, ETag, or frame sequence metadata;
- D-state tasks, available memory, swap occupancy and swap-in/out deltas, CPU I/O wait, and PSI when the kernel exposes it;
- an opt-in legacy debug PNG fallback during monolith migration.
The auditor classifies a failure before acting:
| Classification | Meaning | Automatic fallback action |
|---|---|---|
healthy |
Client, frame, host, and configured server are fresh | None |
startup_grace |
New client PID is inside bounded warm-up | None |
client_unhealthy_systemd_guarded |
Split client is stale; systemd watchdog owns it | None |
systemd_contract_mismatch |
Installed split-client unit differs from the exact bounded notify contract | Human escalation; no auditor restart |
systemd_restart_budget_exhausted |
Notify client is unhealthy after its observed restart budget is consumed | Human escalation; no auditor restart |
render_failure |
Legacy client is active but its render evidence is stale | Optional client-service restart |
service_failure |
Service is stably failed, not auto-restarting | Optional restart only for a legacy non-notify service; otherwise escalation |
service_manager_recovery |
systemd is already activating/restarting it | None |
server_network_distress |
Configured upstream is unreachable or stale | Human escalation; no client restart |
host_kernel_storage_distress |
D-state, swap/memory, PSI, or I/O gate tripped | Human escalation; no restart |
The auditor never restarts display-client.service or another
notify/watchdog-guarded client, even if recovery is enabled; systemd remains the
sole restart owner. Its /run JSON and this auditor are observation-only for
the split architecture. The legacy restart path is
disabled by default. When enabled, it still requires
three consecutive eligible failures, a 15-minute cooldown, at most two restarts
per six hours, and at most three per boot. Observed systemd NRestarts increases
also consume the persistent budget. The only command available to the auditor
is systemctl restart --no-block <configured-client.service>.
Runtime contract
The split client may publish this versioned diagnostic contract atomically after state or sequence changes:
{
"schema_version": 1,
"role": "display-client",
"boot_id": "...",
"pid": 123,
"state": "healthy",
"sequence": 42,
"etag": "\"frame-42\"",
"last_attempt_at": "2026-07-15T18:20:00+00:00",
"last_success_at": "2026-07-15T18:20:01+00:00",
"last_error_at": null,
"error": null,
"frame_source_created_at": "2026-07-15T18:19:58+00:00",
"server_generated_at": "2026-07-15T18:19:58+00:00",
"server_received_at": "2026-07-15T18:20:01+00:00"
}
The default path is /run/rpi-waiting-time-display/client-health.json; a blank
client health path disables it. The file is diagnostic-only and /run is
tmpfs, so frame health never writes to the checkout, /var, or the SD card.
The split server exposes /healthz with
status/service/sequence/generated_at. Its independent /readyz freshness
gate returns status/sequence/published_at and a non-2xx response for a stale
or absent frame. Configure server_health_url to /readyz for the strongest
live check. The auditor accepts generated_at, published_at,
server_generated_at, updated_at, last_success_at, or timestamp by
default. For HTTP it also understands ETag,
X-Frame-Sequence, and X-Generated-At. Server freshness remains separate
from client/display freshness so an upstream or network outage does not cause a
client restart.
On physical hardware, the debug image defaults to
/run/rpi-waiting-time-display/debug_output.png and is limited to one save per
five seconds. A persistent debug path is possible only through the explicit
debug_image_path setting. Mock displays retain ./debug_output.png for local
tests and feature screenshots.
Optional install in observe-only mode
Review the templates first. Installation enables the audit timer but does not restart the display service:
sudo docs/service/setup_display_watchdog.sh
systemctl status display-watchdog.timer
python3 /usr/local/lib/display-watchdog/display_watchdog.py check \
--config /etc/display-watchdog/config.json --no-recovery
The default target is display-client.service. The installer reports whether
Type=notify, NotifyAccess=main, and a nonzero watchdog interval are active,
but it never restarts the target service.
Only a deliberate migration decision should enable the auditor’s legacy fallback:
sudo docs/service/setup_display_watchdog.sh \
--service display.service --enable-legacy-recovery
Existing /etc/display-watchdog/config.json is never overwritten. Edit it
explicitly to add a server path/URL or tune freshness thresholds. Keep recovery
off through the initial observation period.
Logs, status, and metrics
The timer logs only health transitions, actions, and escalations to the journal:
journalctl -u display-watchdog.service
cat /run/display-watchdog/status.json
cat /run/display-watchdog/metrics.prom
systemctl show display-client.service \
-p Type -p NotifyAccess -p WatchdogUSec -p ActiveState -p MainPID -p NRestarts
Prometheus text metrics include the classification, client and server freshness, sequence advancement, D-state count, swap ratio/rate, I/O wait, restart budgets, escalation state, and a reboot-recommendation gauge.
The reboot gauge defaults to zero. It can become one only after the cycle budget is exhausted, a human-only recommendation is explicitly enabled, and hostname, machine-ID SHA-256, and exact hardware model all match the configured physical target. A device-tree serial hash may add a fourth proof. Even then the program only reports a recommendation; it has no reboot or power action. Generate the non-secret proof values locally with:
python3 /usr/local/lib/display-watchdog/display_watchdog.py print-identity
Rollback and uninstall
To stop all checks while preserving evidence:
sudo systemctl disable --now display-watchdog.timer
To remove the timer and auditor without restarting the display:
sudo docs/service/uninstall_display_watchdog.sh
For the migration monolith, pass --service display.service; setup adds a small
runtime-directory drop-in only for that legacy target, and uninstall removes it.
Configuration and persistent recovery history are retained by default. Add
--purge only after capturing any needed evidence. The installer/uninstaller
never alters or replaces the architecture-owned client unit.
Resource and write budget
- The auditor is a short-lived, standard-library-only Python oneshot every 60 seconds; no monitoring daemon remains resident.
- systemd enforces
MemoryHigh=32M,MemoryMax=48M,CPUQuota=20%, idle I/O scheduling, and a 20-second execution timeout. - Per-check observations, status, metrics, and optional client diagnostics live
only in
/run. Persistent recovery state changes only when a restart is requested or a new systemd restart is observed. - Auditor observations, status, and metrics are installed as
0644for local diagnostics/scraping; persistent recovery history remains root-only0600. - Healthy checks are quiet, avoiding one journal entry per minute.
- The system remains compatible with kernels that do not expose PSI;
/procswap, CPU, process-state, and memory deltas remain authoritative.
An indicative macOS arm64 development measurement of an observe-only collection
path on 2026-07-15 took 1.42 seconds wall time, reported 26,836,992 bytes maximum
RSS (about 25.6 MiB), zero swaps, and zero block-input/output operations. That
is evidence for the candidate’s systemd limits, not a substitute for the
device-side gate below; macOS lacks the target’s /proc and systemd surfaces.
Before deployment on a Pi Zero, measure the candidate in observe-only mode:
/usr/bin/time -v python3 /usr/local/lib/display-watchdog/display_watchdog.py \
check --config /etc/display-watchdog/config.json --no-recovery --quiet-healthy
systemd-run --wait --pipe -p MemoryMax=48M \
/usr/bin/python3 /usr/local/lib/display-watchdog/display_watchdog.py \
check --config /etc/display-watchdog/config.json --no-recovery
That device-side measurement is intentionally a deployment gate; this change does not touch the currently wedged physical Pi.