Watch
1
0
Fork
You've already forked souveraine
0

sessiond: an expected source that never reports is Absent, not silent

sensord hung off graphical-session.target, which nothing on this device
starts, so it was enabled and dead from every boot. Down starts from a
last-seen stamp and structurally cannot see that. Bind the reporter to
sessiond and make the machine say so.
This commit is contained in:
Fimeg 2026-07-27 15:34:09 -04:00
commit 3d9340057f
2 changed files with 208 additions and 14 deletions

View file

@ -6,7 +6,19 @@ Description=Souveraine sensor reporter (proximity, light, accelerometer)
#
# A user unit, like sessiond: it talks to sessiond over
# $XDG_RUNTIME_DIR/souveraine/sessiond.sock and needs no privilege at all.
PartOf=graphical-session.target
#
# Bound to sessiond, NOT to graphical-session.target. It was WantedBy that
# target until 2026-07-27, and on this device nothing ever starts it: greetd
# launches Hyprland directly, and hyprland.lua starts each unit it wants by
# name. So the reporter was `enabled` and dead from every boot, the machine ran
# with no evidence at all, and — because a source that has never spoken sits at
# SourceHealth::Unknown, which is silent by design — nothing said so.
#
# The dependency that is actually true is this one: the reporter exists to feed
# the state machine, so it should live and die with the state machine. sessiond
# is started explicitly at session start, needs no Wayland, and neither does
# this: it reads iio-sensor-proxy on the system bus and writes a unix socket.
PartOf=souveraine-sessiond.service
After=souveraine-sessiond.service
[Service]
@ -18,4 +30,4 @@ Restart=always
RestartSec=5
[Install]
WantedBy=graphical-session.target
WantedBy=souveraine-sessiond.service

View file

@ -71,6 +71,31 @@ pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
/// periodically, so silence means silence rather than "nothing changed".
pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90);
/// How long after daemon start an *expected* source may stay silent before the
/// machine calls it `Absent`.
///
/// Deliberately much longer than `SOURCE_DOWN_AFTER`. sessiond starts before
/// the session does — that is the whole point of it, it takes the lock before
/// quickshell exists — so its reporters legitimately arrive late. This window
/// has to cover session startup on a cold boot without covering a reporter
/// that is never coming, and it is a policy field so it can be moved off the
/// trail rather than argued about here.
pub const SOURCE_EXPECTED_WITHIN: Duration = Duration::from_secs(300);
/// Which sources a reporter is expected to serve on this device.
///
/// Exactly what `souveraine-sensord` reports (`SOURCES` in its `main`):
/// proximity, light, accelerometer. `Touch` is deliberately absent — nothing
/// reports it, so it must stay `Unknown` and silent, per §10. Adding a source
/// here without a reporter behind it manufactures a permanent false alarm,
/// which is the failure this whole mechanism exists to avoid in the other
/// direction.
pub const EXPECTED_SOURCES: &[SensorSource] = &[
SensorSource::Proximity,
SensorSource::Light,
SensorSource::Accelerometer,
];
/// How long proximity must read `near` before the machine believes it.
///
/// §9.5 specified Android's `DisplayPowerProximityStateController` — 0 ms
@ -146,6 +171,9 @@ pub struct DeviceStatePolicy {
pub unlocked_blank_after: Option<Duration>,
/// A source silent for this long, having previously reported, is down.
pub source_down_after: Duration,
/// Grace from daemon start before an expected-but-silent source is
/// reported `Absent`. See `SOURCE_EXPECTED_WITHIN`.
pub source_expected_within: Duration,
/// How long proximity must hold `near` before the machine believes it.
pub proximity_near_debounce: Duration,
/// How long proximity must hold `far` before the machine believes it.
@ -231,6 +259,7 @@ impl Default for DeviceStatePolicy {
lock_ack_budget: LOCK_ACK_BUDGET,
unlocked_blank_after: None,
source_down_after: SOURCE_DOWN_AFTER,
source_expected_within: SOURCE_EXPECTED_WITHIN,
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
}
@ -274,6 +303,20 @@ pub struct EvidenceSeen {
pub touch: Option<Instant>,
}
impl EvidenceSeen {
/// Last time this source was heard from, if ever. `None` means never —
/// which is a different claim from "not recently", and the one the
/// `Absent` check is built on.
pub fn get(&self, source: SensorSource) -> Option<Instant> {
match source {
SensorSource::Proximity => self.proximity,
SensorSource::Accelerometer => self.accel,
SensorSource::Light => self.light,
SensorSource::Touch => self.touch,
}
}
}
/// Whether an evidence source is *there*, as opposed to what it last said.
///
/// Doctrine §9 says sensor readings are evidence, not fact. This is the
@ -293,15 +336,33 @@ pub struct EvidenceSeen {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceHealth {
/// Nothing has ever been heard from this source. Not an error — a light
/// sensor with no reporter installed is silent, correctly, forever. Only a
/// source that spoke and then stopped has failed.
/// Nothing has ever been heard from this source, and nothing is expected
/// to be. Correct for hardware with no reporter: `Touch` has none, so it
/// sits here forever and says nothing. A signal that fires for a source
/// nobody wired up is a signal nobody reads.
#[default]
Unknown,
/// Reporting within `source_down_after`.
Live,
/// Reported once and has now been silent past the threshold.
Down,
/// A reporter is expected to serve this source and it has never once
/// spoken, past `source_expected_within` from daemon start.
///
/// This variant exists because `Unknown` was silent by design and that
/// silence hid a real outage for a whole boot (2026-07-27). `Down` cannot
/// catch it: `evaluate_source_health` starts from the last-seen stamp, and
/// a source that has never reported has no stamp, so it is skipped and
/// stays `Unknown` forever. `souveraine-sensord` — the one reporter for
/// every iio-sensor-proxy source (§12) — was `enabled` but never started,
/// because its unit hung off `graphical-session.target` and nothing on
/// this device starts that target. The machine ran the entire session with
/// zero evidence, and the trail recorded not one line about it.
///
/// §10 is explicit that "no evidence" and "evidence says nothing is
/// happening" must not be the same state. It made that true for a source
/// that dies mid-session. This makes it true for one that never lived.
Absent,
}
impl SourceHealth {
@ -310,6 +371,7 @@ impl SourceHealth {
SourceHealth::Unknown => "unknown",
SourceHealth::Live => "live",
SourceHealth::Down => "down",
SourceHealth::Absent => "absent",
}
}
}
@ -333,13 +395,16 @@ impl SourceHealthTable {
}
}
/// True when any source that once worked has gone silent. This is the
/// single question the rest of the system asks — a surface showing
/// "sensors degraded" does not need to know which one.
/// True when any source the machine should be hearing from is not
/// arriving — whether it died mid-session (`Down`) or never started
/// (`Absent`). This is the single question the rest of the system asks: a
/// surface showing "sensors degraded" does not need to know which one, and
/// a decision taken on absent evidence is no sounder than one taken on
/// evidence that stopped.
pub fn any_down(&self) -> bool {
[self.proximity, self.accel, self.light, self.touch]
.iter()
.any(|h| *h == SourceHealth::Down)
.any(|h| matches!(h, SourceHealth::Down | SourceHealth::Absent))
}
pub fn as_json(&self) -> serde_json::Value {
@ -1007,6 +1072,10 @@ pub struct DeviceStateMachine {
/// Per-source health — whether evidence is arriving at all, which is a
/// different question from what it says. See `SourceHealth`.
pub source_health: SourceHealthTable,
/// When this run of the daemon began. The only thing that can tell an
/// expected source which has not reported *yet* from one that is never
/// going to — see `SOURCE_EXPECTED_WITHIN`.
started_at: Instant,
/// True once a blank has been asked for, so we ask exactly once per wake
/// instead of every tick.
blank_requested: bool,
@ -1101,6 +1170,7 @@ impl DeviceStateMachine {
idle_since: None,
evidence_seen: EvidenceSeen::default(),
source_health: SourceHealthTable::default(),
started_at: Instant::now(),
blank_requested: false,
dimmed: false,
pending_blank: None,
@ -1413,6 +1483,42 @@ impl DeviceStateMachine {
newly_down.push(source.as_str());
}
// Expected sources that have never spoken at all. `Down` above starts
// from a last-seen stamp and so structurally cannot see these: no
// stamp, no entry, silence forever. That is how a reporter which never
// started produced a whole session of evidence-free decisions with
// nothing in the trail (2026-07-27).
let mut newly_absent: Vec<&str> = Vec::new();
if now.saturating_duration_since(self.started_at) > self.policy.source_expected_within {
for &source in EXPECTED_SOURCES {
if self.evidence_seen.get(source).is_some() {
continue;
}
let health = self.source_health.get_mut(source);
if *health == SourceHealth::Absent {
continue;
}
*health = SourceHealth::Absent;
newly_absent.push(source.as_str());
}
}
// One entry per edge, not per tick — same contract as source-down.
for source in &newly_absent {
warn!(
"[device-state] evidence source {source} has never reported in {}s since start — its reporter is not running",
self.policy.source_expected_within.as_secs()
);
self.record_error(
"sensor-health",
"source-never-reported",
&format!(
"{source} is expected on this device but has never reported in the {}s since sessiond started; its reporter is not running, so every rule reading it is deciding on absence, not on a negative",
self.policy.source_expected_within.as_secs()
),
);
}
// One entry per outage edge, not per tick. A source stays Down until
// it speaks again, and a trail that repeated this every second would
// bury the transition that actually diagnoses anything.
@ -1450,18 +1556,23 @@ impl DeviceStateMachine {
let health = self.source_health.get_mut(source);
let was = *health;
*health = SourceHealth::Live;
if was == SourceHealth::Down {
// Absent recovers by the same path as Down. Both mean "the machine was
// deciding without this source", and both need the closing entry that
// bounds the window — an outage with no end tells you when evidence
// died and never when it came back.
if matches!(was, SourceHealth::Down | SourceHealth::Absent) {
// Recovery is as diagnostic as the outage: the pair of entries
// bounds the window in which every sensor-driven rule was running
// on nothing, which is what makes the trail usable after the fact.
info!(
"[device-state] evidence source {} is reporting again",
source.as_str()
"[device-state] evidence source {} is reporting again (was {})",
source.as_str(),
was.as_str()
);
self.record_decision(
"source-recovered",
serde_json::json!({ "source": source.as_str() }),
"a source previously recorded as down has resumed reporting",
serde_json::json!({ "source": source.as_str(), "was": was.as_str() }),
"a source the machine had no evidence from has resumed reporting",
);
}
}
@ -3025,6 +3136,77 @@ mod tests {
assert_eq!(errors.len(), 1, "expected exactly one source-down entry");
}
#[test]
fn an_expected_source_that_never_reports_is_absent_and_loud() {
// The regression this exists for: souveraine-sensord was `enabled` but
// never started (its unit hung off a target nothing activates), so
// proximity/light/accel had no stamp at all. `Down` starts from a
// stamp, so it could not see this, and the machine ran a whole session
// on no evidence with nothing in the trail.
let mut sm = DeviceStateMachine::new();
let t0 = sm.started_at;
// Inside the grace window: a reporter is allowed to arrive late.
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN - Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Unknown);
assert!(!sm.source_health.any_down());
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
assert_eq!(sm.source_health.light, SourceHealth::Absent);
assert_eq!(sm.source_health.accel, SourceHealth::Absent);
assert!(sm.source_health.any_down(), "absent evidence is degraded evidence");
// Touch has no reporter on this device, so it must stay silent — a
// permanent false alarm is the same defect in the other direction.
assert_eq!(sm.source_health.touch, SourceHealth::Unknown);
let errors: Vec<_> = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
.collect();
assert_eq!(errors.len(), 3, "one entry per expected source, once");
}
#[test]
fn an_absent_source_is_recorded_once_not_every_tick() {
let mut sm = DeviceStateMachine::new();
let t0 = sm.started_at;
for i in 0..10 {
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1 + i));
}
let errors = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Error { action, .. } if action == "source-never-reported"))
.count();
assert_eq!(errors, 3, "three expected sources, one entry each");
}
#[test]
fn a_late_reporter_clears_absent_and_bounds_the_gap() {
let mut sm = DeviceStateMachine::new();
let t0 = sm.started_at;
sm.tick_at(t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(1));
assert_eq!(sm.source_health.proximity, SourceHealth::Absent);
// The reporter finally starts. Absent must close like Down does, or
// the trail says when evidence went missing and never when it returned.
sm.mark_evidence_seen_at(SensorSource::Proximity, t0 + SOURCE_EXPECTED_WITHIN + Duration::from_secs(5));
assert_eq!(sm.source_health.proximity, SourceHealth::Live);
let recovered = sm
.forensic
.recent(50)
.into_iter()
.filter(|e| matches!(&e.event, ForensicEvent::Decision { decision, .. } if decision == "source-recovered"))
.count();
assert_eq!(recovered, 1);
}
#[test]
fn a_down_source_is_recorded_once_not_every_tick() {
// A tick loop that re-logged this every second would bury the