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:
parent
684d1f9fd5
commit
3d9340057f
2 changed files with 208 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue