From c7dff14807c2de68ad407d0b3c432de2677767fd Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 23 Sep 2026 22:14:15 -0400 Subject: [PATCH] van-leds fix round 1: realistic sysfs fake, error paths, stale failover state, log-once, interval and empty-iface guards Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KqhjP7nud5gdBg25zCiRk9 --- leds/van-leds | 62 ++++++++++++++++--- tests/check-templates.sh | 128 +++++++++++++++++++++++++++++++++------ 2 files changed, 163 insertions(+), 27 deletions(-) diff --git a/leds/van-leds b/leds/van-leds index d50281a..4a7d077 100755 --- a/leds/van-leds +++ b/leds/van-leds @@ -23,6 +23,8 @@ import os import signal import sys import threading +import time +from datetime import datetime DEFAULTS = { "led_power": "", "led_lan": "", "led_wan": "", "led_wlan": "", @@ -31,6 +33,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) } LED_KEYS = (("power", "led_power"), ("lan", "led_lan"), ("wan", "led_wan"), ("wlan", "led_wlan")) @@ -41,6 +44,27 @@ def log(msg): print(msg, flush=True) +_last = {} + + +def log_once(key, msg): + """Log msg unless it is what this key last logged (a persistent failure is logged once).""" + if _last.get(key) != msg: + _last[key] = msg + log(msg) + + +def clear_log(key): + _last.pop(key, None) + + +def interval(cfg): + try: + return max(1.0, float(cfg["poll_interval_s"])) + except (TypeError, ValueError): + return 5.0 + + def load_config(path): cfg = dict(DEFAULTS) with open(path) as f: @@ -65,8 +89,19 @@ def write_attr(path, value): f.write(str(value)) -def desired_wan(state, uplink_carrier, uplink=None): - """-> ("netdev", device) | ("heartbeat", None) | ("none", None).""" +def state_stale(state, now, max_age): + """True when state["updated"] parses and is older than max_age seconds (unparseable: not stale).""" + try: + t = datetime.strptime(state["updated"], "%Y-%m-%dT%H:%M:%S%z").timestamp() + except (TypeError, KeyError, ValueError): + return False + return now - t > max_age + + +def desired_wan(state, uplink_carrier, uplink=None, now=None, max_age=60): + """-> ("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 valid = isinstance(state, dict) and isinstance(state.get("wans"), list) and "active_device" in state if not valid: # no failover info: judge by the wired uplink alone if not uplink_carrier: @@ -113,9 +148,10 @@ def carrier(cfg, iface): return bool(iface) and read_attr(os.path.join(cfg["sysfs_net"], iface, "carrier")) == "1" -def wanted(cfg): +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) + wan = desired_wan(load_state(cfg["failover_state"]), carrier(cfg, cfg["uplink_iface"]), + cfg["uplink_iface"] or None, now, cfg["state_max_age_s"]) return { "power": ("default-on", None), "lan": ("netdev", cfg["lan_iface"]), @@ -133,6 +169,11 @@ def usable_leds(cfg, announce=False): if announce: log(f"LED {role}: no name configured, skipped") continue + iface_key = {"lan": "lan_iface", "wlan": "wlan_iface"}.get(role) + if iface_key and not cfg[iface_key]: + if announce: + log(f"LED {role}: {iface_key} is empty, skipped") + continue d = os.path.join(cfg["sysfs_leds"], name) if not os.path.isdir(d): if announce: @@ -142,17 +183,18 @@ def usable_leds(cfg, announce=False): return out -def poll_once(cfg): +def poll_once(cfg, now=None): """One evaluation of every usable LED. Returns the files written.""" - want = wanted(cfg) + want = wanted(cfg, now) writes = [] for role, d in usable_leds(cfg).items(): trig, dev = want[role] try: w = apply_led(d, trig, dev) except OSError as e: - log(f"LED {role}: cannot set {trig}: {e}") + log_once(role, f"LED {role}: cannot set {trig}: {e}") continue + clear_log(role) if w: log(f"LED {role}: {trig}" + (f" {dev}" if dev else "")) writes += w @@ -170,8 +212,10 @@ def main(argv): try: poll_once(cfg) except Exception as e: # never die on a transient sysfs/state oddity - log(f"poll failed: {e}") - stop.wait(float(cfg["poll_interval_s"])) + log_once("poll", f"poll failed: {e}") + else: + clear_log("poll") + stop.wait(interval(cfg)) log("van-leds stopping, LEDs left as they are") return 0 diff --git a/tests/check-templates.sh b/tests/check-templates.sh index f562f18..9d9d207 100755 --- a/tests/check-templates.sh +++ b/tests/check-templates.sh @@ -847,42 +847,98 @@ for label, bs in (("missing", None), ("invalid", "garbage"), ("no wans", {"x": 1 check(f"22b: {label} state + carrier -> netdev on the uplink", d(bs, True, "up0") == ("netdev", "up0")) check(f"22b: {label} state + no carrier -> none", d(bs, False, "up0") == ("none", None)) -# fake sysfs +check("22b: healthy None -> heartbeat", d(st("eth1", [{"device": "eth1", "healthy": None}]), True) == ("heartbeat", None)) +check("22b: active device absent from wans -> heartbeat", d(st("ghost", [{"device": "eth1", "healthy": True}]), True) == ("heartbeat", None)) +# stale state (fixed clock): older than 60 s is treated as missing +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)) +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")) + +# fake sysfs with the kernel's formats and write rules +TRIGS = ["none", "default-on", "heartbeat", "netdev", "activity"] +NDATTR = ("device_name", "link", "tx", "rx") leds, net = os.path.join(tmp, "leds"), os.path.join(tmp, "net") +def wr(led, f, v): open(os.path.join(leds, led, f), "w").write(v + "\n") +def rd(led, f): return open(os.path.join(leds, led, f)).read().strip() +def settrig(led, t): wr(led, "trigger", " ".join(f"[{x}]" if x == t else x for x in TRIGS)) for n in ("red:power", "green:lan", "green:wan", "green:wlan"): os.makedirs(os.path.join(leds, n)) - for f, v in (("trigger", "none"), ("brightness", "0"), ("device_name", ""), ("link", "0"), ("tx", "0"), ("rx", "0")): - open(os.path.join(leds, n, f), "w").write(v + "\n") + settrig(n, "none"); wr(n, "brightness", "0") + for f in NDATTR: wr(n, f, "") for i, c in (("lan0", "1"), ("wl0", "1"), ("up0", "1"), ("eth1", "1")): os.makedirs(os.path.join(net, i)) open(os.path.join(net, i, "carrier"), "w").write(c + "\n") +log_w = [] # every write: (led, attr, value) +netdev_loaded = [True] +def kernel_write(path, value): + led, attr = os.path.basename(os.path.dirname(path)), os.path.basename(path) + value = str(value) + log_w.append((led, attr, value)) + if attr == "trigger": + if value not in TRIGS or (value == "netdev" and not netdev_loaded[0]): + raise OSError(22, "Invalid argument") + if value != rd(led, "trigger").split("[")[1].split("]")[0]: + for f in NDATTR: wr(led, f, "") # leaving netdev drops its attributes + settrig(led, value) + elif attr in NDATTR: + if "[netdev]" not in rd(led, "trigger"): + raise OSError(2, "No such file or directory") + wr(led, attr, value) + else: + wr(led, attr, value) +orig_write = m.write_attr +m.write_attr = kernel_write sf = os.path.join(tmp, "state.json") json.dump(H, open(sf, "w")) cfg = dict(m.DEFAULTS, led_power="red:power", led_lan="green:lan", led_wan="green:wan", led_wlan="green:wlan", lan_iface="lan0", wlan_iface="wl0", uplink_iface="up0", sysfs_leds=leds, sysfs_net=net, failover_state=sf) -def rd(led, f): return open(os.path.join(leds, led, f)).read().strip() def poll(c=cfg): + del log_w[:] with contextlib.redirect_stdout(io.StringIO()): - return m.poll_once(c) + m.poll_once(c) + return list(log_w) +def trig(led): return rd(led, "trigger").split("[")[1].split("]")[0] w = poll() -check("22c: power default-on", rd("red:power", "trigger") == "default-on") -check("22c: lan netdev on lan0, link/tx/rx 1", (rd("green:lan", "trigger"), rd("green:lan", "device_name"), rd("green:lan", "link"), rd("green:lan", "tx"), rd("green:lan", "rx")) == ("netdev", "lan0", "1", "1", "1")) -check("22c: wlan netdev on wl0", (rd("green:wlan", "trigger"), rd("green:wlan", "device_name")) == ("netdev", "wl0")) -check("22c: wan netdev on the active device", (rd("green:wan", "trigger"), rd("green:wan", "device_name")) == ("netdev", "eth1")) -check("22c: first run writes", len(w) > 0) -w = poll() -check("22c: second run makes no writes", w == []) +check("22c: power default-on", trig("red:power") == "default-on") +check("22c: lan netdev on lan0, link/tx/rx 1", (trig("green:lan"), rd("green:lan", "device_name"), rd("green:lan", "link"), rd("green:lan", "tx"), rd("green:lan", "rx")) == ("netdev", "lan0", "1", "1", "1")) +check("22c: wlan netdev on wl0", (trig("green:wlan"), rd("green:wlan", "device_name")) == ("netdev", "wl0")) +check("22c: wan netdev on the active device", (trig("green:wan"), rd("green:wan", "device_name")) == ("netdev", "eth1")) +check("22c: trigger is written before its netdev attributes", w.index(("green:lan", "trigger", "netdev")) < w.index(("green:lan", "device_name", "lan0"))) +check("22c: second run makes ZERO writes on the kernel-format trigger list", poll() == []) json.dump(U, open(sf, "w")); w = poll() -check("22c: unhealthy state -> wan heartbeat", rd("green:wan", "trigger") == "heartbeat" and len(w) == 1) +check("22c: netdev -> heartbeat: one trigger write, no attribute writes", trig("green:wan") == "heartbeat" and w == [("green:wan", "trigger", "heartbeat")]) +check("22c: no stale netdev attributes after leaving netdev", rd("green:wan", "device_name") == "" and rd("green:wan", "link") == "") +check("22c: heartbeat is stable (zero writes)", poll() == []) json.dump(N, open(sf, "w")); open(os.path.join(net, "up0", "carrier"), "w").write("0\n"); poll() -check("22c: nothing -> wan none, brightness 0", rd("green:wan", "trigger") == "none" and rd("green:wan", "brightness") == "0") +check("22c: -> none: trigger none, brightness 0", trig("green:wan") == "none" and rd("green:wan", "brightness") == "0") +wr("green:wan", "brightness", "1"); w = poll() +check("22c: stray brightness under none is reset", w == [("green:wan", "brightness", "0")]) +json.dump(H, open(sf, "w")); w = poll() +check("22c: none -> netdev: trigger first, then device_name/link/tx/rx", w == [("green:wan", "trigger", "netdev"), ("green:wan", "device_name", "eth1"), ("green:wan", "link", "1"), ("green:wan", "tx", "1"), ("green:wan", "rx", "1")]) open(sf, "w").write("{broken"); open(os.path.join(net, "up0", "carrier"), "w").write("1\n"); poll() -check("22c: invalid state falls back to wired uplink", (rd("green:wan", "trigger"), rd("green:wan", "device_name")) == ("netdev", "up0")) -open(os.path.join(leds, "green:lan", "trigger"), "w").write("none\n"); w = poll() -check("22c: a reset trigger is re-asserted", rd("green:lan", "trigger") == "netdev" and any(x.endswith("trigger") for x in w)) +check("22c: invalid state falls back to wired uplink", (trig("green:wan"), rd("green:wan", "device_name")) == ("netdev", "up0")) +settrig("green:lan", "none"); w = poll() +check("22c: a reset trigger is re-asserted", trig("green:lan") == "netdev" and rd("green:lan", "device_name") == "lan0") +json.dump(dict(H, updated=stamp(0)), open(sf, "w")) +def poll_at(now): + del log_w[:] + with contextlib.redirect_stdout(io.StringIO()): + m.poll_once(cfg, now) +json.dump(dict(H, updated=stamp(300)), open(sf, "w")); poll_at(NOW) +check("22c: stale state file -> wired-uplink fallback", rd("green:wan", "device_name") == "up0") +json.dump(dict(H, updated=stamp(1)), open(sf, "w")); poll_at(NOW) +check("22c: fresh state file retargets the WAN LED", rd("green:wan", "device_name") == "eth1") +json.dump(H, open(sf, "w")) c2 = dict(cfg, led_lan="green:nope", led_wlan="") try: - w = poll(c2); ok_ = True + poll(c2); ok_ = True except Exception as e: ok_ = False check("22c: missing / empty LED names are skipped without raising", ok_) @@ -890,7 +946,43 @@ buf = io.StringIO() with contextlib.redirect_stdout(buf): m.usable_leds(c2, announce=True) check("22c: skipped LEDs are logged once at start", buf.getvalue().count("skipped") == 2) +c3 = dict(cfg, lan_iface="") +buf = io.StringIO() +with contextlib.redirect_stdout(buf): + r3 = m.usable_leds(c3, announce=True) +check("22c: named LED with empty iface is skipped and logged", "lan" not in r3 and "lan_iface is empty" in buf.getvalue()) +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: 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") +netdev_loaded[0] = False +buf = io.StringIO() +try: + with contextlib.redirect_stdout(buf): + m.poll_once(cfg); m.poll_once(cfg) + ok_ = True +except Exception: + ok_ = False +check("22c: netdev not loaded: poll_once does not raise", ok_) +check("22c: netdev not loaded: other LEDs still update", trig("red:power") == "default-on") +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") +# error path: a real unwritable trigger file (skipped as root) +m.write_attr = orig_write +if os.geteuid() != 0: + settrig("green:lan", "none"); settrig("red:power", "none") + os.chmod(os.path.join(leds, "green:lan", "trigger"), 0o444) + try: + poll() + ok_ = True + except Exception: + ok_ = False + check("22c: unwritable trigger: no raise, other LEDs update", ok_ and open(os.path.join(leds, "red:power", "trigger")).read().strip() == "default-on") + os.chmod(os.path.join(leds, "green:lan", "trigger"), 0o644) print("STATUS", int(bad)) PY )