Watch
1
0
Fork
You've already forked souveraine
0

sessiond: bearer posture as evidence and an Action (TASK-49)

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.
This commit is contained in:
Fimeg 2026-08-01 14:05:26 -04:00
commit 4fd3d244ab
5 changed files with 926 additions and 3 deletions

441
src/sessiond/bearer.rs Normal file
View file

@ -0,0 +1,441 @@
//! Bearer posture — which link carries what.
//!
//! This is the device-state half of TASK-49. It exists because nothing owned
//! the question: which link carries traffic was the residue of a DHCP metric, a
//! NetworkManager penalty and a shell script's assumption, and when those
//! disagreed the device silently lost the network.
//!
//! Three rules shape everything here, and each was bought with an outage.
//!
//! **The machine reports and decides; it never actuates in the reader.** The
//! previous answer, `wiuf-vpn-gate`, was driven by the NM dispatcher *and* a
//! 90 s timer while calling `nmcli connection up/down` — which is itself an NM
//! event. It recycled the tunnel 652 times in 90 minutes, through doze tiers
//! whose contract is "network fetchers stopped", and the user could not switch
//! it off. An actuator wired back into its own sensor. Here the probe is pure
//! reading, the decision is a pure function, and the only way out is an
//! `Action` from `tick()` — DEVICE-STATE-MACHINE §12.
//!
//! **"Associated" is not "carrying."** §10 learned this about sensors: a source
//! that reported and then went silent is `Down`, not quiet. A wifi link that
//! associates and cannot route is exactly a sensor that heartbeats and lies,
//! and it is what NetworkManager's own connectivity penalty mislabels — +20000
//! on the link, so the degraded cellular default wins instead. `LinkHealth`
//! keeps the two apart so a link that lies cannot win.
//!
//! **"Am I home" is not an address-prefix question.** The old gate tested
//! `ip -4 addr show wlan0 | grep ' inet 10\.10\.'` — a /16 match. Home is
//! `10.10.20.0/24`, and this phone spends its time on a foreign network at
//! `10.10.30.213` that the test read as home, taking the tunnel down as
//! "redundant". Identity, never prefix: the SSID, or a host only the home LAN
//! serves.
//!
//! What this module deliberately does **not** decide: whether the tunnel is up.
//! That is the user's switch and stays the user's switch. This decides how
//! traffic is carried, which is a different question and must not quietly
//! re-acquire the first.
use std::process::Command;
use serde::{Deserialize, Serialize};
/// A physical carrier of traffic. The tunnel is not one of these — it rides
/// whichever of these is chosen, which is the whole point of pinning it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bearer {
Wifi,
Cellular,
}
impl Bearer {
pub fn as_str(self) -> &'static str {
match self {
Bearer::Wifi => "wifi",
Bearer::Cellular => "cellular",
}
}
}
/// How much a link is actually doing — not what it claims.
///
/// The ordering is deliberate and is the comparison the decision uses:
/// `Carrying` outranks `Associated` outranks `Down` outranks `Absent`. A link
/// may only win if something has been shown to cross it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkHealth {
/// No such link on this device right now.
Absent,
/// The link exists and is known not to be usable.
Down,
/// Associated and addressed, but nothing has been shown to cross it. This
/// is the honest default for a link we have not probed — it is *not* a
/// claim that the link works.
Associated,
/// Associated and carrying: a default route exists over it and the link is
/// not under a connectivity penalty.
Carrying,
}
impl LinkHealth {
pub fn as_str(self) -> &'static str {
match self {
LinkHealth::Absent => "absent",
LinkHealth::Down => "down",
LinkHealth::Associated => "associated",
LinkHealth::Carrying => "carrying",
}
}
/// May this link be preferred at all? `Associated` is allowed to win when
/// nothing better exists, but `Down`/`Absent` never are.
fn usable(self) -> bool {
matches!(self, LinkHealth::Associated | LinkHealth::Carrying)
}
}
/// The tunnel's own health, which is a different question from a link's.
///
/// It can be `up` in NetworkManager and deaf on the wire — that is exactly what
/// was measured over clat on 2026-07-31: `368 B received, 5.30 KiB sent`, a
/// handshake going stale against a 25 s keepalive, while every status readout
/// said connected. `wg`'s received-byte counter is the honest instrument, the
/// way logind is for the lock, and until this module nothing read it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TunnelHealth {
/// The toggle is off. The user's authority, not ours — never an error.
Off,
/// Up and the far end is answering.
Handshaking,
/// Up, sending, and nothing is coming back. Reported rather than left
/// looking connected.
Deaf,
}
impl TunnelHealth {
pub fn as_str(self) -> &'static str {
match self {
TunnelHealth::Off => "off",
TunnelHealth::Handshaking => "handshaking",
TunnelHealth::Deaf => "deaf",
}
}
}
/// What the machine believes about how traffic can be carried right now.
///
/// Every field is evidence with a source, not a fact — §9. `home` is `Option`
/// precisely because "I do not know where I am" is a third answer, and the old
/// gate's bug was collapsing it into `false`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerEvidence {
pub wifi: LinkHealth,
pub cellular: LinkHealth,
pub tunnel: TunnelHealth,
/// `Some(true)` on the home LAN, `Some(false)` demonstrably elsewhere,
/// `None` when we cannot tell. Derived from SSID identity, never from an
/// address prefix.
pub home: Option<bool>,
/// The SSID we are associated with, when there is one. Kept for the
/// readout so a human can see *why* `home` reads as it does.
pub ssid: Option<String>,
}
impl Default for BearerEvidence {
fn default() -> Self {
Self {
wifi: LinkHealth::Absent,
cellular: LinkHealth::Absent,
tunnel: TunnelHealth::Off,
home: None,
ssid: None,
}
}
}
impl BearerEvidence {
/// Which bearer ordinary traffic should prefer, if any.
///
/// Pure function of the evidence — no clock, no I/O, no memory. That is
/// what makes the flapping question answerable elsewhere and this testable
/// here.
///
/// Wifi wins ties because it is the unmetered link, but only on equal
/// health: a *carrying* cellular link beats a merely *associated* wifi one,
/// which is the case NetworkManager's penalty gets backwards.
pub fn preferred(&self) -> Option<Bearer> {
match (self.wifi.usable(), self.cellular.usable()) {
(false, false) => None,
(true, false) => Some(Bearer::Wifi),
(false, true) => Some(Bearer::Cellular),
(true, true) => {
if self.cellular > self.wifi {
Some(Bearer::Cellular)
} else {
Some(Bearer::Wifi)
}
}
}
}
/// Which bearer the tunnel's endpoint should be pinned to.
///
/// The same link ordinary traffic prefers, and `None` when the tunnel is
/// off — pinning an endpoint for a tunnel the user has switched off would
/// be this module deciding something that is not its to decide.
pub fn tunnel_underlay(&self) -> Option<Bearer> {
match self.tunnel {
TunnelHealth::Off => None,
TunnelHealth::Handshaking | TunnelHealth::Deaf => self.preferred(),
}
}
/// Is the tunnel up but not being answered? Worth surfacing on its own
/// because every other readout on the device calls this "connected".
pub fn tunnel_is_deaf(&self) -> bool {
matches!(self.tunnel, TunnelHealth::Deaf)
}
}
/// Read the current bearer posture off the system.
///
/// Reading, and only reading — the same contract `lockhint` has with logind.
/// Anything that changes state leaves as an `Action`.
pub fn probe(home_ssids: &[String]) -> BearerEvidence {
let (wifi, ssid) = probe_wifi(home_ssids);
BearerEvidence {
wifi: wifi.0,
cellular: probe_cellular(),
tunnel: probe_tunnel(),
home: wifi.1,
ssid,
}
}
/// `nmcli -t -f DEVICE,TYPE,STATE,CONNECTION device` is the one call that
/// answers association without asking NetworkManager for its opinion about
/// connectivity — which is the opinion that mislabels the link.
fn nmcli_devices() -> Vec<(String, String, String, String)> {
let out = match Command::new("nmcli")
.args(["-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device"])
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out)
.lines()
.filter_map(|l| {
let mut f = l.splitn(4, ':');
Some((
f.next()?.to_string(),
f.next()?.to_string(),
f.next()?.to_string(),
f.next().unwrap_or("").to_string(),
))
})
.collect()
}
/// Does a default route exist over this interface? This is the "carrying" half
/// of the distinction — association alone is not evidence of a path.
fn has_default_route(iface: &str) -> bool {
let out = match Command::new("ip").args(["-4", "route", "show", "default"]).output() {
Ok(o) if o.status.success() => o.stdout,
_ => return false,
};
String::from_utf8_lossy(&out)
.lines()
.any(|l| l.split_whitespace().any(|w| w == iface))
}
/// Returns wifi health, whether we are home, and the SSID.
fn probe_wifi(home_ssids: &[String]) -> ((LinkHealth, Option<bool>), Option<String>) {
let devices = nmcli_devices();
let Some((iface, _, state, _)) = devices.iter().find(|(_, t, _, _)| t == "wifi").cloned() else {
return ((LinkHealth::Absent, None), None);
};
if state != "connected" {
return ((LinkHealth::Down, Some(false)), None);
}
let ssid = active_ssid(&iface);
// Identity, never prefix. An unknown SSID is `Some(false)` — we know where
// we are and it is not home. No SSID at all is `None` — we do not know.
let home = ssid
.as_ref()
.map(|s| home_ssids.iter().any(|h| h == s));
let health = if has_default_route(&iface) {
LinkHealth::Carrying
} else {
LinkHealth::Associated
};
((health, home), ssid)
}
fn active_ssid(iface: &str) -> Option<String> {
let out = Command::new("nmcli")
.args(["-t", "-f", "ACTIVE,SSID", "device", "wifi", "list", "ifname", iface])
.output()
.ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.strip_prefix("yes:"))
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn probe_cellular() -> LinkHealth {
let devices = nmcli_devices();
// clat is the v4-over-v6 shim the carrier path rides; on this device the
// modem itself shows up as a `gsm` device and clat as a separate tun.
let modem_up = devices
.iter()
.any(|(_, t, s, _)| (t == "gsm" || t == "cdma") && s == "connected");
if !modem_up {
return LinkHealth::Absent;
}
if has_default_route("clat") || devices.iter().any(|(d, _, s, _)| d == "clat" && s == "connected")
{
LinkHealth::Carrying
} else {
LinkHealth::Associated
}
}
/// `wg show` received-bytes is the tunnel's honest instrument. A peer that has
/// sent and never received is up and deaf, not connected.
fn probe_tunnel() -> TunnelHealth {
let out = match Command::new("wg").arg("show").output() {
Ok(o) if o.status.success() => o.stdout,
_ => return TunnelHealth::Off,
};
let text = String::from_utf8_lossy(&out);
if text.trim().is_empty() {
return TunnelHealth::Off;
}
// " transfer: 368 B received, 5.30 KiB sent"
let transfer = text.lines().find_map(|l| l.trim().strip_prefix("transfer:"));
match transfer {
// Up with a peer but no transfer line yet: it has not handshaken.
None => TunnelHealth::Deaf,
Some(t) => {
let received_zero = t
.split(" received")
.next()
.map(|r| r.trim().starts_with("0 "))
.unwrap_or(false);
if received_zero {
TunnelHealth::Deaf
} else {
TunnelHealth::Handshaking
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(wifi: LinkHealth, cellular: LinkHealth) -> BearerEvidence {
BearerEvidence {
wifi,
cellular,
..Default::default()
}
}
#[test]
fn nothing_usable_prefers_nothing() {
assert_eq!(ev(LinkHealth::Absent, LinkHealth::Absent).preferred(), None);
assert_eq!(ev(LinkHealth::Down, LinkHealth::Down).preferred(), None);
}
#[test]
fn a_carrying_link_beats_a_merely_associated_one() {
// This is the case NetworkManager gets backwards: it penalises the
// associated-but-unvalidated wifi link by +20000 and lets the degraded
// clat default win. Here the *carrying* link wins on its own merit.
assert_eq!(
ev(LinkHealth::Associated, LinkHealth::Carrying).preferred(),
Some(Bearer::Cellular)
);
assert_eq!(
ev(LinkHealth::Carrying, LinkHealth::Associated).preferred(),
Some(Bearer::Wifi)
);
}
#[test]
fn wifi_wins_only_on_equal_health() {
assert_eq!(
ev(LinkHealth::Carrying, LinkHealth::Carrying).preferred(),
Some(Bearer::Wifi)
);
assert_eq!(
ev(LinkHealth::Associated, LinkHealth::Associated).preferred(),
Some(Bearer::Wifi)
);
}
#[test]
fn a_down_link_never_wins_even_alone() {
assert_eq!(
ev(LinkHealth::Down, LinkHealth::Absent).preferred(),
None,
"a link known not to work is not a fallback"
);
assert_eq!(
ev(LinkHealth::Down, LinkHealth::Associated).preferred(),
Some(Bearer::Cellular)
);
}
#[test]
fn tunnel_off_pins_nothing() {
let e = BearerEvidence {
wifi: LinkHealth::Carrying,
tunnel: TunnelHealth::Off,
..Default::default()
};
assert_eq!(
e.tunnel_underlay(),
None,
"the toggle is the user's; a tunnel that is off gets no underlay"
);
}
#[test]
fn a_deaf_tunnel_still_gets_pinned_and_is_reported() {
// Deaf is the case worth acting on: it is up, so the user asked for it,
// and it is not being answered, so the underlay is the suspect.
let e = BearerEvidence {
wifi: LinkHealth::Carrying,
tunnel: TunnelHealth::Deaf,
..Default::default()
};
assert_eq!(e.tunnel_underlay(), Some(Bearer::Wifi));
assert!(e.tunnel_is_deaf());
}
#[test]
fn home_is_identity_not_prefix() {
// The regression this whole module exists to not repeat: 10.10.30.213
// is a foreign network that a /16 prefix test reads as home.
let home = vec!["wiuf".to_string()];
assert_eq!(
home.iter().any(|h| h == "Mabee 19"),
false,
"a foreign SSID is not home however its addresses look"
);
assert!(home.iter().any(|h| h == "wiuf"));
}
}

View file

@ -20,6 +20,7 @@ use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue,
};
@ -125,6 +126,16 @@ pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
/// wake through, which is the recoverable direction.
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
/// How long a changed bearer preference must hold before the machine acts.
///
/// 20 s, chosen against the failure rather than against a feel. The gate this
/// replaces reacted at NM-event speed and produced ~7 tunnel recycles a
/// minute; a wifi association that comes and goes during a roam settles well
/// inside 20 s, and a genuine bearer change (walking out of range) does not
/// reverse itself within one. The cost of being slow here is 20 s on the wrong
/// link; the cost of being fast was an unusable phone.
pub const BEARER_SETTLE: Duration = Duration::from_secs(20);
/// How long a button must stay down to be a hold rather than a tap.
///
/// **Provisional, and the number is the weakest part of this file.** §9.5's
@ -205,6 +216,22 @@ pub struct DeviceStatePolicy {
pub proximity_near_debounce: Duration,
/// How long proximity must hold `far` before the machine believes it.
pub proximity_far_debounce: Duration,
/// SSIDs that are the home LAN.
///
/// Identity, never an address prefix. The gate this replaces tested
/// `inet 10.10.` and so read the foreign network this phone lives on
/// (`10.10.30.213`) as home, taking the tunnel down as redundant. Empty
/// means "never claim to be home", which is the safe default: an unknown
/// network is not a reason to drop the tunnel.
pub home_ssids: Vec<String>,
/// How long a bearer preference must hold before the machine acts on it.
///
/// This is the entire anti-flap mechanism, and it is a debounce rather
/// than a rate limit on purpose. `wiuf-vpn-gate` recycled the tunnel 652
/// times in 90 minutes because every correction re-entered the controller;
/// a decision that has to survive a settling window cannot oscillate at
/// event speed no matter how the events arrive.
pub bearer_settle: Duration,
}
impl DeviceStatePolicy {
@ -289,6 +316,8 @@ impl Default for DeviceStatePolicy {
source_expected_within: SOURCE_EXPECTED_WITHIN,
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
home_ssids: Vec::new(),
bearer_settle: BEARER_SETTLE,
}
}
}
@ -318,6 +347,21 @@ pub enum Action {
/// otherwise; either way the panel does not go dark until it is answered
/// or the ack budget expires.
Lock,
/// Carry ordinary traffic over this bearer.
///
/// Expressed to NetworkManager as a route metric, never written with `ip
/// route`: NM stays the single writer of routes, which is TASK-49's
/// acceptance #6. Emitted only when the decision *changes* and has held
/// for `bearer_settle`, so a link flapping at event speed cannot produce
/// a command per event — the failure that recycled the tunnel 652 times.
PreferLink(Bearer),
/// Pin the WireGuard endpoint's host route to this bearer.
///
/// The tunnel is not a bearer; it rides one. Over clat it was measured
/// sending and never receiving, and pinning the endpoint via wlan0
/// produced a handshake in seconds. This does not turn the tunnel on or
/// off — that switch is the user's and stays the user's.
PinTunnelUnderlay(Bearer),
}
/// When each evidence source last reported. Kept beside `SensorEvidence`
@ -1283,6 +1327,23 @@ pub struct DeviceStateMachine {
proximity_since: Option<Instant>,
/// Timed policy. Settable, so Settings can own it.
pub policy: DeviceStatePolicy,
/// Last bearer posture read off the system. Evidence, refreshed by the
/// daemon and never inferred here — the same contract `panel_on` has with
/// the DPMS executor.
pub bearer: BearerEvidence,
/// The bearer the machine has actually acted on, and when the current
/// candidate first differed from it.
///
/// These two fields *are* the anti-flap rule. A preference is only emitted
/// once it has differed from `bearer_applied` continuously for
/// `bearer_settle`; anything that reverses inside that window never
/// produces a command at all. `wiuf-vpn-gate` had no such memory, which is
/// why every NM event it caused became another correction.
bearer_applied: Option<Bearer>,
bearer_candidate_since: Option<(Bearer, Instant)>,
/// Whether the tunnel was deaf last time we looked, so the trail records
/// the edge rather than one entry per tick.
bearer_deaf: bool,
}
/// Sensor inputs that feed the Observed state. Each is a reading, not
@ -1355,6 +1416,10 @@ impl DeviceStateMachine {
// Loaded, not defaulted: the user's Auto-Lock choices are settings,
// and a setting that reverts on reboot is not a setting.
policy: DeviceStatePolicy::load(),
bearer: BearerEvidence::default(),
bearer_applied: None,
bearer_candidate_since: None,
bearer_deaf: false,
};
// First entry of the run, and the only one that proves the trail is
@ -1502,7 +1567,7 @@ impl DeviceStateMachine {
self.resolve_proximity_debounce(now);
self.evaluate_source_health(now);
let mut actions = Vec::new();
let mut actions = self.bearer_actions(now);
// A blank is already decided and waiting on the lock it asked for.
// Nothing else may run while that is outstanding — the whole point is
@ -1820,6 +1885,109 @@ impl DeviceStateMachine {
}
/// Stamp a source as freshly reported. Called alongside the reading.
/// The bearer actually in force, for the readout. `None` until the first
/// preference settles — which is a real state and not the same as "wifi".
pub fn bearer_applied_str(&self) -> Option<&'static str> {
self.bearer_applied.map(|b| b.as_str())
}
/// Fresh bearer evidence from the daemon's probe.
///
/// Ingress only. The machine never reads the network itself for the same
/// reason it never reads logind itself: a reader that can also act is one
/// refactor away from being a controller, and the controller this replaces
/// is the one that recycled the tunnel 652 times.
pub fn note_bearer(&mut self, evidence: BearerEvidence) {
self.bearer = evidence;
}
/// Decide the bearer posture, subject to the settling window.
///
/// Returns at most one `PreferLink` and one `PinTunnelUnderlay`, and only
/// on a change that has held. A steady state produces no actions at all,
/// which is what makes this safe to call every tick.
fn bearer_actions(&mut self, now: Instant) -> Vec<Action> {
let mut actions = Vec::new();
// Report the tunnel going deaf on the edge. This is the one thing that
// is worth saying even when nothing can be done about it: every other
// readout on the device calls a deaf tunnel "connected".
let deaf = self.bearer.tunnel_is_deaf();
if deaf != self.bearer_deaf {
self.bearer_deaf = deaf;
if deaf {
warn!("[device-state] tunnel is up but receiving nothing");
}
self.record_decision(
"bearer-tunnel-health",
serde_json::json!({ "tunnel": self.bearer.tunnel.as_str() }),
if deaf {
"tunnel up but no bytes received — up is not carrying"
} else {
"tunnel handshake healthy"
},
);
}
let Some(want) = self.bearer.preferred() else {
// Nothing usable. Deliberately not an action: with no link worth
// preferring there is nothing to prefer it over, and issuing a
// command here would be the machine flailing at a dead network.
self.bearer_candidate_since = None;
return actions;
};
if Some(want) == self.bearer_applied {
self.bearer_candidate_since = None;
return actions;
}
// A changed candidate has to survive the settling window. Restarting
// the clock whenever the candidate itself changes is what stops a link
// oscillating between two answers from ever accumulating enough time.
let since = match self.bearer_candidate_since {
Some((b, t)) if b == want => t,
_ => {
self.bearer_candidate_since = Some((want, now));
now
}
};
if now.duration_since(since) < self.policy.bearer_settle {
return actions;
}
let previous = self.bearer_applied;
self.bearer_applied = Some(want);
self.bearer_candidate_since = None;
info!(
"[device-state] bearer: {} -> {}",
previous.map(|b| b.as_str()).unwrap_or("none"),
want.as_str()
);
self.record_decision(
"bearer-preferred",
serde_json::json!({
"from": previous.map(|b| b.as_str()),
"to": want.as_str(),
"wifi": self.bearer.wifi.as_str(),
"cellular": self.bearer.cellular.as_str(),
"tunnel": self.bearer.tunnel.as_str(),
"home": self.bearer.home,
"ssid": self.bearer.ssid,
"settled_for_secs": now.duration_since(since).as_secs(),
}),
"bearer preference changed and held for the settling window",
);
actions.push(Action::PreferLink(want));
// The tunnel follows the underlay, and only when it is already up.
if let Some(underlay) = self.bearer.tunnel_underlay() {
actions.push(Action::PinTunnelUnderlay(underlay));
}
actions
}
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
self.mark_evidence_seen_at(source, Instant::now())
}
@ -2503,6 +2671,7 @@ impl fmt::Display for DeviceState {
#[cfg(test)]
mod tests {
use super::*;
use crate::sessiond::bearer::{LinkHealth, TunnelHealth};
#[test]
fn initial_state_is_active() {
@ -3950,4 +4119,136 @@ mod tests {
write_n(&log, 3);
assert_eq!(log.recent(10).len(), 3);
}
// ---- bearer: the anti-flap rule is the whole point ----
fn machine_with_bearer(wifi: LinkHealth, cellular: LinkHealth) -> DeviceStateMachine {
let mut m = DeviceStateMachine::new();
m.note_bearer(BearerEvidence {
wifi,
cellular,
..Default::default()
});
m
}
#[test]
fn a_bearer_change_waits_for_the_settling_window() {
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Associated);
let t0 = Instant::now();
// First sight of a preference is not enough. This is the property the
// 652-recycle gate did not have: it acted on every event it saw.
let a = m.tick_at(t0);
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"acted before the window: {a:?}"
);
let early = t0 + BEARER_SETTLE - Duration::from_secs(1);
let a = m.tick_at(early);
assert!(!a.iter().any(|x| matches!(x, Action::PreferLink(_))));
let late = t0 + BEARER_SETTLE + Duration::from_secs(1);
let a = m.tick_at(late);
assert!(
a.contains(&Action::PreferLink(Bearer::Wifi)),
"should have settled on wifi: {a:?}"
);
}
#[test]
fn a_preference_that_reverses_inside_the_window_never_acts() {
// The flap case, stated exactly: wifi appears, then goes away again
// before it settled. No command may be produced by that at all.
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Carrying);
let t0 = Instant::now();
let _ = m.tick_at(t0);
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Down,
cellular: LinkHealth::Carrying,
..Default::default()
});
let mid = t0 + BEARER_SETTLE / 2;
assert!(!m
.tick_at(mid)
.iter()
.any(|x| matches!(x, Action::PreferLink(_))));
// Back to wifi before either candidate matured.
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Carrying,
cellular: LinkHealth::Carrying,
..Default::default()
});
let later = t0 + BEARER_SETTLE + Duration::from_secs(1);
let a = m.tick_at(later);
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"the clock must restart when the candidate changes, not accumulate: {a:?}"
);
}
#[test]
fn a_settled_preference_is_not_re_emitted_every_tick() {
// Idempotence at the decision, not at the executor. A command per tick
// would be the same disease as a command per event.
let mut m = machine_with_bearer(LinkHealth::Carrying, LinkHealth::Absent);
let t0 = Instant::now();
let _ = m.tick_at(t0);
let settled = t0 + BEARER_SETTLE + Duration::from_secs(1);
assert!(m.tick_at(settled).contains(&Action::PreferLink(Bearer::Wifi)));
for i in 1..5 {
let a = m.tick_at(settled + Duration::from_secs(i));
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"re-emitted on a steady state at +{i}s: {a:?}"
);
}
}
#[test]
fn no_usable_link_produces_no_action() {
let mut m = machine_with_bearer(LinkHealth::Down, LinkHealth::Absent);
let t0 = Instant::now();
let _ = m.tick_at(t0);
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
assert!(
!a.iter().any(|x| matches!(x, Action::PreferLink(_))),
"flailed at a dead network: {a:?}"
);
}
#[test]
fn the_tunnel_is_pinned_with_the_link_but_never_switched_on() {
let mut m = DeviceStateMachine::new();
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Carrying,
cellular: LinkHealth::Associated,
tunnel: TunnelHealth::Handshaking,
..Default::default()
});
let t0 = Instant::now();
let _ = m.tick_at(t0);
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
assert!(a.contains(&Action::PinTunnelUnderlay(Bearer::Wifi)));
// Tunnel off: the preference still lands, the pin does not. Deciding
// to bring a tunnel up is the user's call, not this machine's.
let mut m = DeviceStateMachine::new();
m.note_bearer(BearerEvidence {
wifi: LinkHealth::Carrying,
tunnel: TunnelHealth::Off,
..Default::default()
});
let _ = m.tick_at(t0);
let a = m.tick_at(t0 + BEARER_SETTLE + Duration::from_secs(1));
assert!(a.contains(&Action::PreferLink(Bearer::Wifi)));
assert!(
!a.iter().any(|x| matches!(x, Action::PinTunnelUnderlay(_))),
"pinned an underlay for a tunnel the user switched off: {a:?}"
);
}
}

View file

@ -10,6 +10,7 @@
//! Compiled into the `souveraine-sessiond` bin via `#[path]` includes, the
//! same pattern as machined and secrets.
pub mod bearer;
pub mod device_state;
pub mod draw;
pub mod idle;

View file

@ -216,6 +216,13 @@ pub const VERBS: &[VerbDoc] = &[
refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
},
VerbDoc {
op: "bearer",
mutates: false,
summary: "which link carries traffic, and whether the tunnel is being answered",
refuses: &[],
example: r#"{"op":"bearer"}"#,
},
];
/// A request plus the caller's declared intent.
@ -311,6 +318,16 @@ pub enum Request {
Subscribe,
/// Read the timed policy — the Auto-Lock shaped settings.
GetPolicy,
/// Bearer posture: per-link health, the tunnel's handshake state, and
/// which bearer the machine has settled on.
///
/// A read, deliberately. The corresponding write does not exist and should
/// not: the thing that decides which link carries traffic is `tick()`, and
/// a verb that let a caller set it would be the second writer TASK-49
/// acceptance #6 forbids. What a caller can do is read this and change the
/// *policy* (`set_policy`), which is the difference between steering the
/// machine and reaching around it.
Bearer,
/// Change the timed policy. Every field is optional; omitted fields keep
/// their current value. This is the seam the Settings control center reads
/// and writes, so a control there is a view over the owning daemon rather
@ -504,7 +521,7 @@ mod tests {
// And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc.
assert_eq!(VERBS.len(), 14, "a Request variant was added without a VerbDoc");
assert_eq!(VERBS.len(), 15, "a Request variant was added without a VerbDoc");
}
#[test]

View file

@ -34,6 +34,10 @@ use crate::sessiond::protocol::{
/// not be woken more often than it must be.
const TICK_INTERVAL: Duration = Duration::from_secs(1);
/// Sample bearer posture every Nth tick. See the comment at the call site for
/// why this rides the existing clock instead of getting a timer.
const BEARER_PROBE_EVERY: u64 = 5;
/// How long a connection may sit without sending a request before it is closed.
///
/// Exempts the registered shell heartbeat, whose whole job is to stay silent.
@ -167,8 +171,27 @@ pub fn run(initial_lock: bool, socket_path: &Path) -> Result<()> {
/// had passed, so it could not be the proactive half of the nervous system.
fn spawn_clock(shared: &Arc<Shared>) {
let shared = Arc::clone(shared);
std::thread::spawn(move || loop {
std::thread::spawn(move || {
let mut tick_count: u64 = 0;
loop {
std::thread::sleep(TICK_INTERVAL);
tick_count = tick_count.wrapping_add(1);
// Bearer evidence is sampled off the existing clock rather than given a
// timer of its own — TASK-49 acceptance #6, "no new timer". Every fifth
// tick, because the probe costs five subprocesses and the settling
// window is 20 s: four samples per window is enough to establish that a
// change held, and 1 Hz would be five processes a second forever.
//
// Probed with the lock *released*. Holding the state mutex across
// subprocess spawns is exactly the self-deadlock shape that put 347
// threads in __futex_wait on 2026-07-26.
if tick_count.is_multiple_of(BEARER_PROBE_EVERY) {
let home = shared.lock().device_state.policy.home_ssids.clone();
let evidence = crate::sessiond::bearer::probe(&home);
shared.lock().device_state.note_bearer(evidence);
}
// Hold the lock only to decide, never while running a command.
let actions = {
let mut d = shared.lock();
@ -182,6 +205,7 @@ fn spawn_clock(shared: &Arc<Shared>) {
// blank but not the brightnessctl failure underneath it has been told a
// tidier story than what happened.
fan_out(&shared);
}
});
}
@ -300,6 +324,23 @@ fn execute(shared: &Arc<Shared>, action: Action) {
return;
}
// Bearer posture is expressed to NetworkManager, never written with `ip
// route`. NM already writes every route on this device; a second writer is
// how the metric fight that started TASK-49 became unresolvable, and
// acceptance #6 forbids adding one. `connection modify` + `device reapply`
// leaves NM the sole author and makes the preference survive a reconnect.
if let Action::PreferLink(bearer) = action {
apply_link_preference(shared, bearer);
return;
}
// Pinning the tunnel's endpoint is the one route sessiond does own: it is
// a /32 host route for the WireGuard peer, which NM has no opinion about
// because the endpoint is not part of any connection's config.
if let Action::PinTunnelUnderlay(bearer) = action {
pin_tunnel_underlay(shared, bearer);
return;
}
let (program, args, label): (&str, Vec<&str>, &str) = match action {
// sessiond guarantees this runs once per dim, which is what the old
// hypridle `-s`/`-r` listener could not: a second save while already
@ -330,11 +371,115 @@ fn execute(shared: &Arc<Shared>, action: Action) {
(DPMS_EXECUTOR, vec!["off"], "panel-off")
}
Action::Lock => unreachable!("handled above; the lock is not a shell command"),
Action::PreferLink(_) => unreachable!("handled above; expressed to NetworkManager"),
Action::PinTunnelUnderlay(_) => unreachable!("handled above; a host route, not a command"),
};
run_executor(shared, program, &args, label, action);
}
/// Route metrics for the two bearers.
///
/// The numbers are NetworkManager's own defaults for the winning case (wifi
/// 600), with the loser pushed clear of NM's +20000 connectivity penalty so a
/// penalised-but-preferred link still beats the demoted one. That penalty is
/// what inverted the metrics on 2026-07-31: wifi went to 20600 and the clat
/// default at 2048 won, on a link that could not resolve.
const METRIC_PREFERRED: &str = "600";
const METRIC_DEMOTED: &str = "30000";
/// Tell NetworkManager which link ordinary traffic should take.
///
/// Both connections are modified every time rather than only the winner: the
/// loser's metric is half the decision, and leaving it at whatever it happened
/// to be is how the original inversion survived.
fn apply_link_preference(shared: &Arc<Shared>, bearer: crate::sessiond::bearer::Bearer) {
use crate::sessiond::bearer::Bearer;
let (wifi_metric, wwan_metric) = match bearer {
Bearer::Wifi => (METRIC_PREFERRED, METRIC_DEMOTED),
Bearer::Cellular => (METRIC_DEMOTED, METRIC_PREFERRED),
};
for (kind, metric) in [("802-11-wireless", wifi_metric), ("gsm", wwan_metric)] {
for conn in active_connections_of_type(kind) {
let _ = std::process::Command::new("nmcli")
.args(["connection", "modify", &conn, "ipv4.route-metric", metric])
.status();
}
}
// Reapply rather than up/down. Cycling the connection is what made the old
// gate's corrections into NM events that re-entered it; `reapply` changes
// the live config without a state transition, so nothing observing NM sees
// a link flap.
for dev in ["wlan0", "clat"] {
let _ = std::process::Command::new("nmcli")
.args(["device", "reapply", dev])
.status();
}
shared.lock().device_state.record_error(
"bearer",
"prefer-link",
&format!("preferred {}", bearer.as_str()),
);
}
/// List active connection names of a given `connection.type`.
fn active_connections_of_type(kind: &str) -> Vec<String> {
let out = match std::process::Command::new("nmcli")
.args(["-t", "-f", "NAME,TYPE", "connection", "show", "--active"])
.output()
{
Ok(o) if o.status.success() => o.stdout,
_ => return Vec::new(),
};
String::from_utf8_lossy(&out)
.lines()
.filter_map(|l| {
let (name, ty) = l.rsplit_once(':')?;
(ty == kind).then(|| name.to_string())
})
.collect()
}
/// Pin the WireGuard endpoint's /32 to the chosen underlay.
///
/// Over clat the tunnel was measured sending and never receiving; pinning the
/// endpoint via wlan0 produced a handshake in seconds. The endpoint is read
/// from `wg show`, so a profile change does not need this code changed.
fn pin_tunnel_underlay(shared: &Arc<Shared>, bearer: crate::sessiond::bearer::Bearer) {
use crate::sessiond::bearer::Bearer;
let Some(endpoint) = wg_endpoint_ip() else {
return;
};
let dev = match bearer {
Bearer::Wifi => "wlan0",
Bearer::Cellular => "clat",
};
// `replace` rather than `add`: this runs on every settled change, and an
// `add` that collides leaves the old pin in place, which is the stale
// route the whole exercise is trying not to inherit.
let _ = std::process::Command::new("ip")
.args(["route", "replace", &format!("{endpoint}/32"), "dev", dev])
.status();
shared.lock().device_state.record_error(
"bearer",
"pin-tunnel",
&format!("pinned {endpoint} via {dev}"),
);
}
/// The peer endpoint address `wg` reports, without its port.
fn wg_endpoint_ip() -> Option<String> {
let out = std::process::Command::new("wg").arg("show").output().ok()?;
if !out.status.success() {
return None;
}
String::from_utf8_lossy(&out.stdout)
.lines()
.find_map(|l| l.trim().strip_prefix("endpoint:"))
.and_then(|e| e.trim().rsplit_once(':'))
.map(|(host, _port)| host.to_string())
}
/// Act on the machine's decision that the session must lock before the panel
/// goes dark.
///
@ -1000,6 +1145,24 @@ fn handle_request(
}
serde_json::json!({ "ok": true, "panel_on": on })
}
Request::Bearer => {
let d = shared.lock();
let b = &d.device_state.bearer;
serde_json::json!({
"ok": true,
"wifi": b.wifi.as_str(),
"cellular": b.cellular.as_str(),
"tunnel": b.tunnel.as_str(),
// Reported separately from `tunnel` because it is the claim a
// reader most needs and the one every other readout gets
// wrong: a tunnel can be up and not be carrying anything.
"tunnel_deaf": b.tunnel_is_deaf(),
"home": b.home,
"ssid": b.ssid,
"preferred": b.preferred().map(|x| x.as_str()),
"applied": d.device_state.bearer_applied_str(),
})
}
Request::GetPolicy => {
let d = shared.lock();
let p = &d.device_state.policy;