Nothing owned which link carries traffic. It was the residue of a DHCP metric,
a NetworkManager penalty and a shell script's assumption, and when those
disagreed the phone silently lost the network - every IPv4 destination leaving
over a degraded clat while wifi sat associated.
The thing this replaces, wiuf-vpn-gate, is worth stating because its three
defects are the design constraints:
- it was driven by the NM dispatcher AND a 90s timer while calling nmcli
up/down, which is itself an NM event. 652 tunnel recycles in 90 minutes,
~7/min - a loop, not a bad interval. Here the probe only reads, the decision
is a pure function, and the only egress is an Action from tick().
- its home test was ' inet 10.10.' - a /16 match that reads the foreign network
this phone lives on (10.10.30.213) as home. home_ssids is identity; an empty
list means never claim to be home.
- it managed CONN=wiuf while two profiles answer to that name.
LinkHealth keeps 'associated' apart from 'carrying', which is DEVICE-STATE-
MACHINE §10's source-health lesson applied to links: a wifi link that
associates and cannot route is a sensor that heartbeats and lies, and it is
what NM's +20000 penalty mislabels. A carrying cellular link therefore beats a
merely associated wifi one; wifi only wins ties.
TunnelHealth reads wg's received-byte counter, which nothing read before. Up
and deaf is a real state - measured over clat as 368 B received against 5.30
KiB sent - and every other readout on the device calls it connected.
Anti-flap is a settling window, not a rate limit: a changed preference must
hold BEARER_SETTLE (20s) before it produces a command, and the clock restarts
whenever the candidate itself changes. A steady state emits nothing, so it is
safe to evaluate every tick. Tests cover the flap that never settles, the
settled state that must not re-emit, and the dead network that must not be
flailed at.
NetworkManager stays the single writer of routes (acceptance #6): preference
is expressed as ipv4.route-metric plus device reapply, never ip route, and
reapply rather than up/down so a correction is not itself an NM event. The one
route sessiond owns is the WireGuard endpoint /32, which NM has no opinion
about.
The tunnel toggle stays the user's. tunnel_underlay() returns None when the
tunnel is off - this decides how traffic is carried, not whether the tunnel
exists.
Probed off the existing clock every 5th tick, with the lock released; no new
timer (acceptance #6) and no subprocess spawned under the state mutex.