From e2da20a1b96b49a66e63e7829965248bf67b0a42 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 23 Sep 2026 22:19:26 -0400 Subject: [PATCH] van-leds fix round 2: state_max_age_s default 180, non-finite/invalid config guards, log-recurrence test Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9 --- leds/van-leds | 18 ++++++++++++++---- tests/check-templates.sh | 28 ++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/leds/van-leds b/leds/van-leds index 4a7d077..e951f5b 100755 --- a/leds/van-leds +++ b/leds/van-leds @@ -19,6 +19,7 @@ keys: "sysfs_leds", "sysfs_net", "failover_state". """ import json +import math import os import signal import sys @@ -33,7 +34,7 @@ DEFAULTS = { "sysfs_leds": "/sys/class/leds", "sysfs_net": "/sys/class/net", "failover_state": "/run/van-failover/state.json", - "state_max_age_s": 60, # older failover state is ignored (wired-uplink fallback) + "state_max_age_s": 180, # older failover state is ignored (wired-uplink fallback); van-failover rewrites it every ~61-65 s } LED_KEYS = (("power", "led_power"), ("lan", "led_lan"), ("wan", "led_wan"), ("wlan", "led_wlan")) @@ -60,9 +61,18 @@ def clear_log(key): def interval(cfg): try: - return max(1.0, float(cfg["poll_interval_s"])) + v = float(cfg["poll_interval_s"]) except (TypeError, ValueError): return 5.0 + return max(1.0, v) if math.isfinite(v) else 5.0 + + +def max_age(cfg): + try: + v = float(cfg["state_max_age_s"]) + except (TypeError, ValueError): + return 180.0 + return v if math.isfinite(v) else 180.0 def load_config(path): @@ -98,7 +108,7 @@ def state_stale(state, now, max_age): return now - t > max_age -def desired_wan(state, uplink_carrier, uplink=None, now=None, max_age=60): +def desired_wan(state, uplink_carrier, uplink=None, now=None, max_age=180): """-> ("netdev", device) | ("heartbeat", None) | ("none", None). `now`: epoch seconds (default: clock).""" if isinstance(state, dict) and state_stale(state, time.time() if now is None else now, max_age): state = None @@ -151,7 +161,7 @@ def carrier(cfg, iface): def wanted(cfg, now=None): """{role: (trigger, device)} for every role (before skipping absent LEDs).""" wan = desired_wan(load_state(cfg["failover_state"]), carrier(cfg, cfg["uplink_iface"]), - cfg["uplink_iface"] or None, now, cfg["state_max_age_s"]) + cfg["uplink_iface"] or None, now, max_age(cfg)) return { "power": ("default-on", None), "lan": ("netdev", cfg["lan_iface"]), diff --git a/tests/check-templates.sh b/tests/check-templates.sh index 9d9d207..d76bdfe 100755 --- a/tests/check-templates.sh +++ b/tests/check-templates.sh @@ -853,13 +853,15 @@ check("22b: active device absent from wans -> heartbeat", d(st("ghost", [{"devic import time as _t, datetime as _dt NOW = 1_800_000_000 def stamp(age): return _dt.datetime.fromtimestamp(NOW - age, _dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S%z") -fresh, old = dict(H, updated=stamp(10)), dict(H, updated=stamp(120)) +fresh, old = dict(H, updated=stamp(10)), dict(H, updated=stamp(300)) check("22b: fresh state is used", d(fresh, False, "up0", NOW) == ("netdev", "eth1")) check("22b: stale state + carrier -> netdev on the uplink", d(old, True, "up0", NOW) == ("netdev", "up0")) check("22b: stale state + no carrier -> none", d(old, False, "up0", NOW) == ("none", None)) check("22b: unparseable updated is valid", d(dict(H, updated="garbage"), False, "up0", NOW) == ("netdev", "eth1")) check("22b: missing updated is valid", d({k: v for k, v in H.items() if k != "updated"}, False, "up0", NOW) == ("netdev", "eth1")) -check("22b: max_age is a parameter", d(old, False, "up0", NOW, 300) == ("netdev", "eth1")) +check("22b: 100 s old state is still valid (default 180 s)", d(dict(H, updated=stamp(100)), False, "up0", NOW) == ("netdev", "eth1")) +check("22b: 200 s old state is stale", d(dict(H, updated=stamp(200)), False, "up0", NOW) == ("netdev", "up0")) +check("22b: max_age is a parameter", d(old, False, "up0", NOW, 400) == ("netdev", "eth1")) # fake sysfs with the kernel's formats and write rules TRIGS = ["none", "default-on", "heartbeat", "netdev", "activity"] @@ -955,6 +957,16 @@ settrig("green:lan", "none") poll(c3) check("22c: empty iface: no netdev trigger written to that LED", trig("green:lan") == "none") check("22c: apply_led writes only on difference", m.apply_led(os.path.join(leds, "red:power"), "default-on") == []) +check("22c: poll_interval_s inf/nan -> 5", (m.interval({"poll_interval_s": float("inf")}), m.interval({"poll_interval_s": "nan"}), m.interval({"poll_interval_s": None})) == (5.0, 5.0, 5.0)) +check("22c: state_max_age_s default 180; bad values fall back", (m.DEFAULTS["state_max_age_s"], m.max_age({"state_max_age_s": None}), m.max_age({"state_max_age_s": "x"}), m.max_age({"state_max_age_s": 90})) == (180, 180.0, 180.0, 90.0)) +try: + del log_w[:] + with contextlib.redirect_stdout(io.StringIO()): + m.poll_once(dict(cfg, state_max_age_s=None)) + ok_ = True +except Exception: + ok_ = False +check("22c: poll_once with state_max_age_s=None does not raise", ok_) check("22c: poll_interval_s: clamped to >=1, non-numeric -> 5", (m.interval({"poll_interval_s": 0}), m.interval({"poll_interval_s": "x"}), m.interval({"poll_interval_s": 7})) == (1.0, 5.0, 7.0)) # error paths: netdev module not loaded for n in ("green:lan", "green:wlan", "green:wan"): settrig(n, "none") @@ -971,6 +983,18 @@ check("22c: netdev not loaded: other LEDs still update", trig("red:power") == "d check("22c: persistent failure logged once per LED, not every poll", buf.getvalue().count("cannot set netdev") == 3) netdev_loaded[0] = True; w = poll() check("22c: next poll retries and succeeds", trig("green:lan") == "netdev" and trig("green:wan") == "netdev") +for n in ("green:lan", "green:wlan", "green:wan"): settrig(n, "none") +netdev_loaded[0] = False +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + m.poll_once(cfg) +netdev_loaded[0] = True; poll() +for n in ("green:lan", "green:wlan", "green:wan"): settrig(n, "none") +netdev_loaded[0] = False +with contextlib.redirect_stdout(buf): + m.poll_once(cfg) +netdev_loaded[0] = True +check("22c: a failure that recurs after clearing is logged again", buf.getvalue().count("cannot set netdev") == 6) # error path: a real unwritable trigger file (skipped as root) m.write_attr = orig_write if os.geteuid() != 0: