power: add van-nvme-watch — pages on NVMe I/O-timeout/reset recurrence

Tails journalctl -kf for the "nvme nvmeN: I/O tag ... timeout, reset
controller" signature that crashed and corrupted the root fs on 2026-08-02
(and recurred 2026-08-04, that time self-healing). Watches a short grace
window to tell a clean self-heal from an escalation (repeated timeout or a
following ext4 error) before paging via the existing Pushover credentials,
with a live SMART/superblock snapshot in the alert body.
This commit is contained in:
Andreas Wrede
2026-08-04 08:44:53 -04:00
parent 86fa9509df
commit e7a82126f2
5 changed files with 302 additions and 1 deletions
+13
View File
@@ -48,6 +48,7 @@ This directory is the source of truth. The live system files live under `/etc`,
| NAT + forwarding | **nftables** + sysctl | | NAT + forwarding | **nftables** + sysctl |
| WAN health + failover | **van-failover** daemon | | WAN health + failover | **van-failover** daemon |
| Temperature monitor / alert / log | **van-thermal** daemon | | Temperature monitor / alert / log | **van-thermal** daemon |
| NVMe I/O-timeout/reset watchdog + alert | **van-nvme-watch** daemon |
| Battery monitor / low-charge alert + shutdown | **van-battery** daemon | | Battery monitor / low-charge alert + shutdown | **van-battery** daemon |
| Auto-reboot on hang | **systemd hardware watchdog** (`intel_oc_wdt`) | | Auto-reboot on hang | **systemd hardware watchdog** (`intel_oc_wdt`) |
| Liveness / dead-man's switch + metrics | **hbc** heartbeat client → hbd.wrede.pvt | | Liveness / dead-man's switch + metrics | **hbc** heartbeat client → hbd.wrede.pvt |
@@ -121,6 +122,9 @@ This directory is the source of truth. The live system files live under `/etc`,
| `van-thermal` | `/usr/local/sbin/van-thermal` | temperature daemon (Python): publishes state, alerts, logs history | | `van-thermal` | `/usr/local/sbin/van-thermal` | temperature daemon (Python): publishes state, alerts, logs history |
| `thermal-config.json` | `/etc/van-thermal/config.json` | sensors + warn/crit thresholds + sample/log intervals | | `thermal-config.json` | `/etc/van-thermal/config.json` | sensors + warn/crit thresholds + sample/log intervals |
| `van-thermal.service` | `/etc/systemd/system/van-thermal.service` | `Restart=always` | | `van-thermal.service` | `/etc/systemd/system/van-thermal.service` | `Restart=always` |
| `van-nvme-watch` | `/usr/local/sbin/van-nvme-watch` | NVMe watchdog (Python): tails `journalctl -k` for I/O-timeout/reset events, Pushover alerts |
| `nvme-watch-config.json` | `/etc/van-nvme-watch/config.json` | grace period, cooldown, device paths |
| `van-nvme-watch.service` | `/etc/systemd/system/van-nvme-watch.service` | `Restart=always` |
| `van-battery` | `/usr/local/sbin/van-battery` | battery daemon (Python): Pushover low-charge alerts + safe shutdown | | `van-battery` | `/usr/local/sbin/van-battery` | battery daemon (Python): Pushover low-charge alerts + safe shutdown |
| `battery-config.json` | `/etc/van-battery/config.json` | warn levels, shutdown level, poll interval, paths | | `battery-config.json` | `/etc/van-battery/config.json` | warn levels, shutdown level, poll interval, paths |
| `van-battery.service` | `/etc/systemd/system/van-battery.service` | `Restart=always` | | `van-battery.service` | `/etc/systemd/system/van-battery.service` | `Restart=always` |
@@ -180,6 +184,15 @@ The `homeassistant` LAN name comes from `ap/van-ap-dnsmasq.conf` (`host-record`
- **Tuning**: edit `/etc/van-thermal/config.json` (thresholds, intervals, sensor list), then `systemctl restart van-thermal`. Defaults: CPU warn 80 / crit 95 °C (silicon crit is 100), NVMe warn 65 / crit 70 °C (drive crit ~71). - **Tuning**: edit `/etc/van-thermal/config.json` (thresholds, intervals, sensor list), then `systemctl restart van-thermal`. Defaults: CPU warn 80 / crit 95 °C (silicon crit is 100), NVMe warn 65 / crit 70 °C (drive crit ~71).
- Status: `systemctl status van-thermal` or `cat /run/van-thermal/state.json`. - Status: `systemctl status van-thermal` or `cat /run/van-thermal/state.json`.
### NVMe watchdog (`van-nvme-watch`)
- Event-driven, not polled: tails `journalctl -kf` for the `nvme nvmeN: I/O tag ... timeout, reset controller` signature that crashed and corrupted the root fs on 2026-08-02 (recurred 2026-08-04, self-healed — see the memory notes for that investigation). There's no sensor to sample, only a log line to catch.
- On a match it watches a `grace_period` (default 20 s) for either a clean re-init (self-healed) or a second timeout / an ext4 error following it (escalated) before alerting, so the Pushover message already says which outcome happened — no need to SSH in during a scare.
- The alert body includes a live `smartctl`/`tune2fs` snapshot (SMART health, critical-warning flag, media error count, filesystem state).
- **Live state**: `/run/van-nvme-watch/state.json` (last event, same convention as van-failover/van-thermal).
- Shares Pushover credentials with van-thermal/van-battery (`/etc/van-battery/pushover.json`).
- **Tuning**: edit `/etc/van-nvme-watch/config.json` (`grace_period`, `cooldown`, device paths), then `systemctl restart van-nvme-watch`.
- Status: `systemctl status van-nvme-watch`, `journalctl -u van-nvme-watch -f`, or `cat /run/van-nvme-watch/state.json`.
### Battery monitor (`van-battery`) ### Battery monitor (`van-battery`)
- Watches mains vs battery via `/sys/class/power_supply/AC0/online` (0 = on battery) and charge via `BAT0/capacity`. Both resolve by `type` (Mains/Battery) if those names ever differ. - Watches mains vs battery via `/sys/class/power_supply/AC0/online` (0 = on battery) and charge via `BAT0/capacity`. Both resolve by `type` (Mains/Battery) if those names ever differ.
- **Only while on battery**, it sends escalating **Pushover** alerts at **25 / 20 / 15 %**, and at **10 %** sends a final alert and runs `systemctl poweroff` (after `shutdown_grace`, default 8 s, so the alert flushes first). - **Only while on battery**, it sends escalating **Pushover** alerts at **25 / 20 / 15 %**, and at **10 %** sends a final alert and runs `systemctl poweroff` (after `shutdown_grace`, default 8 s, so the alert flushes first).
+8 -1
View File
@@ -192,6 +192,11 @@ if [ ! -f /etc/van-battery/pushover.json ]; then
warn "seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)" warn "seeded /etc/van-battery/pushover.json (EDIT IT: add Pushover token + user)"
fi fi
echo "== nvme watchdog =="
install -D -m0755 power/van-nvme-watch /usr/local/sbin/van-nvme-watch
install -D -m0644 power/nvme-watch-config.json /etc/van-nvme-watch/config.json
install -D -m0644 power/van-nvme-watch.service /etc/systemd/system/van-nvme-watch.service
echo "== home assistant ==" echo "== home assistant =="
# Native HA (Podman Quadlet, replaced the ha_van VM). daemon-reload below # Native HA (Podman Quadlet, replaced the ha_van VM). daemon-reload below
# regenerates homeassistant.service; started (not restarted) at the end so a # regenerates homeassistant.service; started (not restarted) at the end so a
@@ -221,12 +226,13 @@ systemctl restart systemd-resolved
# its wait-online would just stall network-online.target. NM-wait-online covers WANs. # its wait-online would just stall network-online.target. NM-wait-online covers WANs.
systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true systemctl mask systemd-networkd-wait-online.service >/dev/null 2>&1 || true
systemctl unmask hostapd >/dev/null 2>&1 || true systemctl unmask hostapd >/dev/null 2>&1 || true
systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-gps-owntracks >/dev/null 2>&1 || true systemctl enable regdomain.service hostapd hostapd-2g van-ap-dnsmasq nftables systemd-networkd van-failover van-thermal van-nvme-watch van-ap-watchdog van-ap-watchdog-2g van-wlan-watchdog van-gps-owntracks >/dev/null 2>&1 || true
# bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus # bluetooth: host BlueZ serves the onboard hci0 to the HA container over D-Bus
systemctl enable --now bluetooth >/dev/null 2>&1 || true systemctl enable --now bluetooth >/dev/null 2>&1 || true
systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)" systemctl start homeassistant || warn "homeassistant failed to start (podman/quadlet — check journalctl -u homeassistant)"
systemctl start esphome || warn "esphome failed to start (podman/quadlet — check journalctl -u esphome)" systemctl start esphome || warn "esphome failed to start (podman/quadlet — check journalctl -u esphome)"
systemctl restart van-thermal systemctl restart van-thermal
systemctl restart van-nvme-watch
systemctl restart van-gps-owntracks systemctl restart van-gps-owntracks
# Pick up unmanaged-devices changes so NM releases/keeps the right interfaces. # Pick up unmanaged-devices changes so NM releases/keeps the right interfaces.
nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true nmcli general reload 2>/dev/null || systemctl reload NetworkManager 2>/dev/null || true
@@ -252,3 +258,4 @@ echo " iw dev $WIFI_5G_IFACE info | grep -E 'ssid|channel|width'"
echo " iw dev $WIFI_2G_IFACE info | grep -E 'ssid|channel|width'" echo " iw dev $WIFI_2G_IFACE info | grep -E 'ssid|channel|width'"
echo " cat /run/van-failover/state.json" echo " cat /run/van-failover/state.json"
echo " cat /run/van-thermal/state.json" echo " cat /run/van-thermal/state.json"
echo " cat /run/van-nvme-watch/state.json"
+7
View File
@@ -0,0 +1,7 @@
{
"nvme_device": "nvme0",
"fs_device": "/dev/nvme0n1p2",
"grace_period": 20,
"cooldown": 300,
"credentials_path": "/etc/van-battery/pushover.json"
}
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""van-nvme-watch — pages when the NVMe root hits an I/O-timeout/reset event.
This exact kernel signature ("nvme nvmeN: I/O tag ... timeout, reset
controller") crashed and corrupted the NVMe root on 2026-08-02, then recurred
2026-08-04 and self-healed. Root cause is still unconfirmed (suspected
USB/PCIe host-bandwidth contention during heavy interface churn), so this
just watches for it recurring rather than trying to prevent it.
Tails `journalctl -kf` (event-driven, not polled) rather than sysfs like
van-thermal, since there's no sensor to sample — only a log line to catch.
On a match it watches a short grace window for either a clean controller
re-init (self-healed) or a follow-up ext4 error / a second timeout (escalating)
before paging, and includes a live SMART + superblock snapshot in the message
so the phone alert already answers "is the filesystem actually at risk".
Publishes /run/van-nvme-watch/state.json (last event, same convention as
van-failover/van-thermal). Pushover credentials shared with van-battery/
van-thermal (/etc/van-battery/pushover.json, 0600). Stdlib only.
"""
import json
import os
import queue
import re
import socket
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
CONFIG_PATH = os.environ.get("VAN_NVME_WATCH_CONFIG", "/etc/van-nvme-watch/config.json")
STATE_DIR = Path("/run/van-nvme-watch")
STATE_PATH = STATE_DIR / "state.json"
HOST = socket.gethostname()
DEFAULTS = {
"nvme_device": "nvme0", # controller name as it appears in dmesg / smartctl target
"fs_device": "/dev/nvme0n1p2", # for the superblock snapshot
"grace_period": 20, # seconds to watch for escalation after a timeout line
"cooldown": 300, # minimum seconds between Pushover sends
"credentials_path": "/etc/van-battery/pushover.json",
}
# The exact crash/recurrence signature, kept nvme-scoped and loose enough to
# catch variant opcodes/tags/queue ids without matching unrelated nvme lines.
TIMEOUT_RE = re.compile(r"nvme (nvme\d+): .*\b(?:timeout|reset controller)\b", re.IGNORECASE)
# Signs the event is more than a clean self-heal: an actual filesystem error,
# or forced read-only remount, following the reset.
ESCALATION_RE = re.compile(
r"EXT4-fs error|EXT4-fs.*remount.*read-only|Remounting filesystem read-only|"
r"Buffer I/O error|I/O error, dev nvme",
re.IGNORECASE,
)
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
def log(msg, level="info"):
pri = {"info": "<6>", "warn": "<4>", "crit": "<2>"}.get(level, "<6>")
print(pri + msg, flush=True)
def load_creds(cfg):
try:
c = json.loads(Path(cfg["credentials_path"]).read_text())
token, user = str(c.get("token", "")).strip(), str(c.get("user", "")).strip()
if token in PLACEHOLDERS or user in PLACEHOLDERS:
return None
return token, user
except FileNotFoundError:
return None
except Exception as e:
log(f"credentials {cfg['credentials_path']} unreadable ({e})", "warn")
return None
def pushover(cfg, title, message, priority=0, attempts=3, retry_delay=15):
creds = load_creds(cfg)
if not creds:
log(f"pushover skipped (no credentials): {title} — {message}", "warn")
return False
token, user = creds
data = urllib.parse.urlencode({
"token": token, "user": user, "title": title,
"message": message, "priority": priority,
}).encode()
req = urllib.request.Request("https://api.pushover.net/1/messages.json", data=data)
for attempt in range(1, attempts + 1):
try:
with urllib.request.urlopen(req, timeout=10) as resp:
ok = resp.status == 200
if not ok:
log(f"pushover HTTP {resp.status}", "warn")
return ok
except Exception as e:
last = attempt == attempts
log(f"pushover send failed ({attempt}/{attempts}): {e}", "warn")
if not last:
time.sleep(retry_delay)
return False
def load_config():
cfg = dict(DEFAULTS)
try:
with open(CONFIG_PATH) as f:
cfg.update(json.load(f))
except FileNotFoundError:
log(f"config {CONFIG_PATH} not found, using built-in defaults")
except Exception as e:
log(f"config {CONFIG_PATH} unreadable ({e}), using built-in defaults", "warn")
return cfg
def snapshot_health(cfg):
"""Best-effort SMART + superblock snapshot for the alert body — answers
"did this one actually hurt the filesystem" without needing to SSH in."""
info = {}
try:
out = subprocess.run(["smartctl", "-a", f"/dev/{cfg['nvme_device']}"],
capture_output=True, text=True, timeout=15).stdout
for key, pattern in (
("smart_health", r"SMART overall-health self-assessment test result:\s*(\S+)"),
("smart_critical_warning", r"Critical Warning:\s*(\S+)"),
("smart_media_errors", r"Media and Data Integrity Errors:\s*(\d+)"),
):
m = re.search(pattern, out)
info[key] = m.group(1) if m else "?"
except Exception as e:
info["smart_error"] = str(e)
try:
out = subprocess.run(["tune2fs", "-l", cfg["fs_device"]],
capture_output=True, text=True, timeout=15).stdout
m = re.search(r"Filesystem state:\s*(\S+)", out)
info["fs_state"] = m.group(1) if m else "?"
except Exception as e:
info["fs_state_error"] = str(e)
return info
def write_state(payload):
STATE_DIR.mkdir(parents=True, exist_ok=True)
tmp = STATE_PATH.with_suffix(".tmp")
tmp.write_text(json.dumps(payload))
tmp.replace(STATE_PATH)
def journal_reader(q):
"""Runs forever in a background thread, pushing new kernel log lines onto
q. journalctl itself can exit (journald restart, etc.) — respawn it."""
while True:
try:
proc = subprocess.Popen(
["journalctl", "-kf", "-n", "0", "-o", "cat"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
text=True, bufsize=1)
for line in proc.stdout:
q.put(line.rstrip("\n"))
proc.wait()
log(f"journalctl exited (code {proc.returncode}), restarting in 5s", "warn")
except Exception as e:
log(f"journalctl reader error: {e}, restarting in 5s", "warn")
time.sleep(5)
def finalize(cfg, pending, escalated, reason, alert_state):
"""Wrap up a pending event: snapshot health, log, publish state, and
Pushover (subject to cooldown). alert_state is a {"last_alert": monotonic}
box shared across calls so the cooldown persists across events."""
health = snapshot_health(cfg)
outcome = "ESCALATED" if escalated else "self-healed"
icon = "🚨" if escalated else "⚠️"
priority = 1 if escalated else 0
lines = [f"first: {pending['line']}"]
if pending.get("count", 1) > 1:
lines.append(f"repeated {pending['count']}x within the grace window")
if pending.get("escalation_line"):
lines.append(f"then: {pending['escalation_line']}")
lines.append(f"fs state: {health.get('fs_state', '?')} | "
f"SMART: {health.get('smart_health', '?')}, "
f"critical_warning={health.get('smart_critical_warning', '?')}, "
f"media_errors={health.get('smart_media_errors', '?')}")
message = "\n".join(lines)
log(f"nvme event {outcome} ({reason}): {message}", "crit" if escalated else "warn")
write_state({
"updated": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"device": pending["device"],
"count": pending.get("count", 1),
"escalated": escalated,
"reason": reason,
"first_line": pending["line"],
"escalation_line": pending.get("escalation_line"),
"health": health,
})
if time.monotonic() - alert_state["last_alert"] >= cfg["cooldown"]:
pushover(cfg, f"{icon} {HOST}: NVMe timeout — {outcome}", message, priority=priority)
alert_state["last_alert"] = time.monotonic()
else:
log("pushover suppressed (cooldown)", "warn")
def main():
cfg = load_config()
log(f"van-nvme-watch up: tailing journalctl -k for nvme timeout/reset events "
f"(grace period {cfg['grace_period']}s, cooldown {cfg['cooldown']}s)")
q = queue.Queue()
threading.Thread(target=journal_reader, args=(q,), daemon=True).start()
pending = None
alert_state = {"last_alert": 0.0}
while True:
if pending is not None:
remaining = pending["deadline"] - time.monotonic()
if remaining <= 0:
finalize(cfg, pending, False, "grace period elapsed", alert_state)
pending = None
continue
try:
line = q.get(timeout=remaining)
except queue.Empty:
continue
else:
line = q.get()
if pending is None:
m = TIMEOUT_RE.search(line)
if m:
pending = {"device": m.group(1), "line": line, "count": 1,
"deadline": time.monotonic() + cfg["grace_period"]}
log(f"nvme timeout detected: {line}", "warn")
continue
if TIMEOUT_RE.search(line):
pending["count"] += 1
if pending["count"] >= 2:
finalize(cfg, pending, True, "repeated timeouts", alert_state)
pending = None
continue
if ESCALATION_RE.search(line):
pending["escalation_line"] = line
finalize(cfg, pending, True, "filesystem error followed", alert_state)
pending = None
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=NVMe I/O-timeout/reset watchdog + Pushover alert for the campervan router
After=local-fs.target network-online.target systemd-journald.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/sbin/van-nvme-watch
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target