Watch
1
0
Fork
You've already forked souveraine
0

sessiond: give the device state machine a clock and real actuators

The state machine landed in b4b30b1 and never ran. Verified on the phone
after 2.5h of use: device_state=active, observed_confidence=0.0,
forensic_log count=0, forensic.jsonl never created. Not one transition.

Root cause was not a missing caller. The daemon had no timebase at all —
every Duration in it was a one-shot handshake deadline — so the machine
could answer requests but nothing ever told it time had passed. It could
not be the proactive half of anything.

- tick loop (1s) drives every timed rule; tick_at() takes the clock so the
  rules are testable without sleeping
- panel is a field, not a ninth state: the enum had no cell for "locked,
  screen dark", and DozeLight (frozen apps, Wi-Fi save) is not a dark glance
- lock-blank rule, two stages: dim as a visible pre-warning, blank one grace
  window later. Nothing owned this before — the only backstop was hypridle's
  600s screen-off timer shared with the desktop case, so glancing at the
  clock lit the panel for ten minutes
- dim/restore via brightnessctl + blueline-undim. The dim listener was
  dropped from hypridle.conf by accident and never restored; it now lives
  here, so brightness has one writer and the save happens exactly once —
  which is the actual fix for the 10/255 wedge the old -s/-r pair hit
- input truth from ext-idle-notify (new idle.rs), not inferred. Tracking the
  compositor's active/quiet boolean instead of stamping last-input is what
  keeps a long swipe from being blanked mid-gesture: continuous input emits
  no events at all
- evidence staleness, 30s TTL per source, clearing toward safety: a stale
  proximity stops suppressing wake, a stale accelerometer stops buying the
  longer budget. Measured need — hexagonrpcd-sdsp exited 0, systemd left it
  dead, and iio-sensor-proxy went on reporting HasProximity=true for hours
- proximity-near while locked blanks now, taking that decision back from
  blueline-proximity-lock
- get_policy/set_policy IPC, Auto-Lock shaped with 0 = never, so Settings can
  be a view over the owning daemon instead of a switch that looks like it worked
- fix: PAM unlock was refused as an illegal transition from Observed,
  DozeLight and DozeDeep. Unlocking a phone whose proximity had fired hit it

33 tests. aarch64 cross-build verified on archdev.
This commit is contained in:
Fimeg 2026-07-25 14:23:07 -04:00
commit aa3027845d
5 changed files with 1022 additions and 12 deletions

View file

@ -16,9 +16,100 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tracing::{info, warn};
use crate::sessiond::protocol::{SensorSource, SensorValue};
use crate::sessiond::protocol::{InputTrigger, SensorSource, SensorValue};
/// How long a locked, lit panel waits for input before it blanks.
///
/// Android blanks the lock screen in about this long; Sailfish's mce keeps a
/// separate blank-from-lockscreen timeout for exactly this case. We had
/// neither: the only backstop was hypridle's 600 s screen-off timer, shared
/// with the desktop case, so glancing at the clock lit the panel for ten
/// minutes.
pub const LOCK_BLANK_AFTER: Duration = Duration::from_secs(15);
/// Same, when evidence says the device is in a hand rather than on a table.
pub const LOCK_BLANK_AFTER_HELD: Duration = Duration::from_secs(25);
/// How long the dimmed warning lasts before the panel goes dark.
///
/// Dimming first is the pre-warning: the screen visibly fades and a tap
/// inside this window cancels the blank. The mechanism already existed and
/// was orphaned — hypridle once carried a dim listener
/// (`brightnessctl -s set 10`, `on-resume = blueline-undim`) which is no
/// longer in the live config, while `blueline-undim` is still installed.
pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
/// Evidence older than this is stale, and stale evidence is *unknown* —
/// neither "user present" nor "user absent".
///
/// This exists because of a measured failure: on 2026-07-25 the SLPI took a
/// CHRE fatal, `blueline-hexagonrpcd-sdsp` exited with status 0 so systemd's
/// `Restart=on-failure` never fired, and every sensor was dead for hours
/// while iio-sensor-proxy still answered `HasProximity: true`. A consumer of
/// a dead sensor looked exactly like a consumer of a quiet one.
pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
/// Tunable policy for the machine's timed behaviour.
///
/// These are settings, not constants: the shape is iOS's Auto-Lock — a
/// user-chosen timeout with a visible dim shortly before it, and a "never"
/// option for the desk-clock case. The Settings control center (TASK-19) is
/// meant to be a view over this struct, so the values live here rather than
/// being baked into the rule.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceStatePolicy {
/// Locked, panel lit, no input for this long → blank. `None` = never.
pub lock_blank_after: Option<Duration>,
/// Same, when evidence says the device is being held.
pub lock_blank_after_held: Option<Duration>,
/// How long the dimmed warning shows before the blank.
pub dim_grace: Duration,
/// Evidence older than this is unknown.
pub evidence_ttl: Duration,
/// Whether the dim pre-warning is used at all.
pub dim_warning: bool,
}
impl Default for DeviceStatePolicy {
fn default() -> Self {
Self {
lock_blank_after: Some(LOCK_BLANK_AFTER),
lock_blank_after_held: Some(LOCK_BLANK_AFTER_HELD),
dim_grace: LOCK_DIM_GRACE,
evidence_ttl: EVIDENCE_TTL,
dim_warning: true,
}
}
}
/// What the machine wants done. The machine decides; it never runs a command
/// itself. `tick` returns intent and the daemon executes it, so the decision
/// stays testable without a compositor.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
/// Dim the panel as the pre-warning. Saves the current brightness first.
Dim,
/// Restore brightness after a dim. Runs `blueline-undim`, never a bare
/// `brightnessctl -r`: the restore needs the floor, or a save that landed
/// while already dim leaves the panel at 10/255 and every later wake
/// looks like dead glass (Pixel3Arch b9f83f0).
Restore,
/// Blank the panel through the DPMS executor.
Blank,
}
/// When each evidence source last reported. Kept beside `SensorEvidence`
/// rather than inside it so the weights stay a pure function of the readings.
#[derive(Debug, Clone, Default)]
pub struct EvidenceSeen {
pub proximity: Option<Instant>,
pub accel: Option<Instant>,
pub light: Option<Instant>,
pub touch: Option<Instant>,
}
// ── Forensic logging ─────────────────────────────────────────────────
// Every decision point emits a ForensicEntry capturing the full state
@ -221,6 +312,12 @@ const LEGAL_TRANSITIONS: &[(DeviceState, DeviceState)] = &[
(DeviceState::Locked, DeviceState::Active),
(DeviceState::Locked, DeviceState::Observed),
(DeviceState::Locked, DeviceState::DozeLight),
// PAM auth unlocks from ANY locked sub-state, not just the base one.
// Without these, unlocking a phone that was in Observed (proximity had
// fired) or dozing was refused as an illegal transition.
(DeviceState::Observed, DeviceState::Active),
(DeviceState::DozeLight, DeviceState::Active),
(DeviceState::DozeDeep, DeviceState::Active),
// Observed → back to Locked (proximity far) or deeper doze
(DeviceState::Observed, DeviceState::Locked),
(DeviceState::Observed, DeviceState::DozeLight),
@ -268,6 +365,25 @@ pub struct DeviceStateMachine {
pub sensor_evidence: SensorEvidence,
/// Forensic log — captures every decision point for post-hoc analysis.
pub forensic: ForensicLog,
/// Panel power. A field, not a ninth state: panel-off is orthogonal to
/// the doze tier, and the enum had no cell for "locked, screen dark".
panel_on: bool,
/// When the compositor's quiet period began, or `None` while the user is
/// interacting. This is reported by `ext-idle-notify`, never inferred: a
/// continuous gesture produces no events at all, so a machine that stamped
/// "last input" itself would blank in the middle of a swipe.
idle_since: Option<Instant>,
/// Per-source freshness, for the staleness rule.
evidence_seen: EvidenceSeen,
/// True once a blank has been asked for, so we ask exactly once per wake
/// instead of every tick.
blank_requested: bool,
/// True while the pre-warning dim is showing. The machine tracks this so
/// brightness is saved exactly once per dim; the old hypridle `-s/-r`
/// pair had no such memory, which is how it wedged at 10/255.
dimmed: bool,
/// Timed policy. Settable, so Settings can own it.
pub policy: DeviceStatePolicy,
}
/// Sensor inputs that feed the Observed state. Each is a reading, not
@ -329,9 +445,272 @@ impl DeviceStateMachine {
state: DeviceState::Active,
sensor_evidence: SensorEvidence::default(),
forensic: ForensicLog::new(),
panel_on: true,
idle_since: None,
evidence_seen: EvidenceSeen::default(),
blank_requested: false,
dimmed: false,
policy: DeviceStatePolicy::default(),
}
}
/// The clock. Called on a fixed interval by the daemon.
///
/// This is the piece the machine did not have. Everything else was
/// event-driven, so the machine could only answer other actors and could
/// never notice that time had passed — which is why it sat in `Active`
/// with an empty forensic log while the phone locked and unlocked around
/// it. Every timed tier (blank, dim, doze promotion, wake windows) hangs
/// off this one function.
pub fn tick(&mut self) -> Vec<Action> {
self.tick_at(Instant::now())
}
/// `tick` with the clock passed in, so the rules can be tested at any
/// point on the timeline instead of by sleeping.
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
self.expire_stale_evidence(now);
let mut actions = Vec::new();
// The lock-screen burn: locked, panel lit, nobody touching it.
if !is_locked(self.state) || !self.panel_on || self.blank_requested {
return actions;
}
// Pocket or face-down: something is over the sensor while the session
// is locked. Blank now rather than waiting out the idle budget — this
// is the job `blueline-proximity-lock` used to do by calling the DPMS
// executor itself, and it belongs here where it can be weighed
// against the rest of the evidence instead of racing the other rules.
//
// Note this deliberately fires even when the accelerometer says the
// device is moving (walking with it, or holding it to an ear): a lit
// panel against a face or a pocket lining is not wanted either way.
// The hand-held carve-out in §4 is about not suppressing a *wake*,
// which is a different question from blanking one that is already on.
if self.sensor_evidence.proximity_near {
self.blank_requested = true;
info!("[device-state] proximity near while locked — blanking");
self.record_decision(
"panel-off",
serde_json::json!({
"reason": "proximity-near",
"confidence": self.sensor_evidence.confidence(),
"accel_moving": self.sensor_evidence.accel_moving,
}),
"locked and something is over the proximity sensor",
);
actions.push(Action::Blank);
return actions;
}
// The compositor says the user is interacting. Not our business yet —
// and crucially this is what keeps a long swipe from being blanked
// out from under the user's thumb.
let Some(idle_since) = self.idle_since else {
return actions;
};
let held = self.sensor_evidence.accel_moving;
let budget = if held {
self.policy.lock_blank_after_held
} else {
self.policy.lock_blank_after
};
// "Never" is a legitimate setting (plugged in at the desk).
let Some(budget) = budget else {
return actions;
};
let idle_for = now.saturating_duration_since(idle_since);
// Stage two: the grace ran out, go dark.
if idle_for >= budget {
self.blank_requested = true;
info!(
"[device-state] locked, idle {:.1}s of {}s — blanking",
idle_for.as_secs_f32(),
budget.as_secs()
);
self.record_decision(
"panel-off",
serde_json::json!({
"idle_secs": idle_for.as_secs(),
"budget_secs": budget.as_secs(),
"held": held,
"was_dimmed": self.dimmed,
"confidence": self.sensor_evidence.confidence(),
}),
"locked, panel lit, no input within the blank budget",
);
actions.push(Action::Blank);
return actions;
}
// Stage one: the pre-warning. The panel visibly fades and a tap
// inside the grace window cancels the blank — the user gets told
// what is about to happen instead of the screen simply dying.
if self.policy.dim_warning && !self.dimmed {
let dim_at = budget.saturating_sub(self.policy.dim_grace);
if idle_for >= dim_at {
self.dimmed = true;
info!(
"[device-state] locked, idle {:.1}s — dimming, {}s to tap",
idle_for.as_secs_f32(),
self.policy.dim_grace.as_secs()
);
self.record_decision(
"panel-dim",
serde_json::json!({
"idle_secs": idle_for.as_secs(),
"blank_at_secs": budget.as_secs(),
"grace_secs": self.policy.dim_grace.as_secs(),
"held": held,
}),
"pre-warning before blanking; input inside the grace cancels it",
);
actions.push(Action::Dim);
}
}
actions
}
/// Drop evidence nobody has refreshed. Stale readings are cleared rather
/// than trusted, and clearing errs the safe way: a stale proximity no
/// longer suppresses a wake, and a stale accelerometer no longer buys the
/// longer blank budget. Never the reverse.
fn expire_stale_evidence(&mut self, now: Instant) {
let ttl = self.policy.evidence_ttl;
let expired = |seen: &mut Option<Instant>, flag: &mut bool| -> bool {
let stale = match *seen {
Some(t) => now.saturating_duration_since(t) > ttl,
None => true,
};
if stale && *flag {
*flag = false;
*seen = None;
return true;
}
false
};
let mut dropped: Vec<&str> = Vec::new();
if expired(
&mut self.evidence_seen.proximity,
&mut self.sensor_evidence.proximity_near,
) {
dropped.push("proximity");
}
if expired(
&mut self.evidence_seen.accel,
&mut self.sensor_evidence.accel_moving,
) {
dropped.push("accel");
}
if expired(
&mut self.evidence_seen.light,
&mut self.sensor_evidence.light_changing,
) {
dropped.push("light");
}
if expired(
&mut self.evidence_seen.touch,
&mut self.sensor_evidence.touch_active,
) {
dropped.push("touch");
}
if !dropped.is_empty() {
warn!(
"[device-state] evidence went stale, treating as unknown: {}",
dropped.join(", ")
);
self.record_decision(
"evidence-stale",
serde_json::json!({ "sources": dropped, "ttl_secs": ttl.as_secs() }),
"no reading within the evidence TTL — a dead sensor is not a quiet one",
);
}
}
/// Stamp a source as freshly reported. Called alongside the reading.
pub fn mark_evidence_seen(&mut self, source: SensorSource) {
let now = Instant::now();
match source {
SensorSource::Proximity => self.evidence_seen.proximity = Some(now),
SensorSource::Accelerometer => self.evidence_seen.accel = Some(now),
SensorSource::Light => self.evidence_seen.light = Some(now),
SensorSource::Touch => self.evidence_seen.touch = Some(now),
}
}
/// Real user input. Resets the blank budget, re-arms the rule, and undoes
/// the pre-warning dim if one is showing — that cancel is the whole point
/// of the grace window, so it returns the action rather than waiting for
/// the next tick to notice.
pub fn note_input(&mut self, trigger: InputTrigger) -> Vec<Action> {
self.idle_since = None;
self.blank_requested = false;
let mut actions = Vec::new();
if self.dimmed {
self.dimmed = false;
info!("[device-state] input during the dim warning — restoring");
actions.push(Action::Restore);
}
let wake = match trigger {
InputTrigger::PowerButton => WakeTrigger::PowerButton,
InputTrigger::DoubleTapToWake => WakeTrigger::DoubleTapToWake,
InputTrigger::Touch | InputTrigger::Key => WakeTrigger::UserInput,
InputTrigger::Unknown => WakeTrigger::Unknown,
};
self.record_wake(wake, &format!("input: {:?}", trigger));
actions
}
/// The executor reports the panel's real state. Turning on counts as
/// input, so a dt2w wake starts the blank budget from the wake itself.
pub fn set_panel(&mut self, on: bool) -> Vec<Action> {
if self.panel_on == on {
return Vec::new();
}
self.panel_on = on;
info!("[device-state] panel {}", if on { "on" } else { "off" });
self.blank_requested = false;
if on {
self.idle_since = None;
// A wake must never come up dim. If the blank landed while the
// warning was showing, the saved brightness is still low, so the
// restore has to run before the user sees the panel.
if self.dimmed {
self.dimmed = false;
return vec![Action::Restore];
}
}
Vec::new()
}
/// The compositor reported a quiet period beginning at `since`.
pub fn note_idle_start(&mut self, since: Instant) {
if self.idle_since.is_none() {
self.idle_since = Some(since);
}
}
/// Seconds of compositor-reported quiet, or 0 while the user is active.
pub fn idle_secs(&self) -> u64 {
match self.idle_since {
Some(t) => Instant::now().saturating_duration_since(t).as_secs(),
None => 0,
}
}
/// Whether the compositor currently reports the user as interacting.
pub fn user_active(&self) -> bool {
self.idle_since.is_none()
}
/// Build a forensic snapshot of the current state.
pub fn snapshot(&self, phase: &str, shell_alive: bool, screen_locked: bool, screen_lock_secure: bool, idle_state: &str, inhibitor_held: bool) -> StateSnapshot {
StateSnapshot {
@ -558,6 +937,17 @@ impl DeviceStateMachine {
"observed": matches!(self.state, DeviceState::Observed),
"observed_confidence": self.sensor_evidence.confidence(),
"suppress_dpms_wake": self.sensor_evidence.should_suppress_dpms_wake(),
"panel_on": self.panel_on,
"user_active": self.user_active(),
"idle_secs": self.idle_secs(),
"dimmed": self.dimmed,
"blank_requested": self.blank_requested,
"evidence_fresh": {
"proximity": self.evidence_seen.proximity.is_some(),
"accel": self.evidence_seen.accel.is_some(),
"light": self.evidence_seen.light.is_some(),
"touch": self.evidence_seen.touch.is_some(),
},
})
}
}
@ -587,6 +977,203 @@ mod tests {
assert_eq!(sm.state(), DeviceState::Active);
}
/// A locked machine with a lit panel, where the compositor has just
/// reported that input stopped at `t0`.
fn locked_and_lit() -> (DeviceStateMachine, Instant) {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
assert!(sm.panel_on);
let t0 = Instant::now();
sm.note_idle_start(t0);
(sm, t0)
}
#[test]
fn lockscreen_dims_as_a_warning_then_blanks() {
let (mut sm, t0) = locked_and_lit();
// Nothing at all early on.
assert!(sm.tick_at(t0 + Duration::from_secs(1)).is_empty());
// Dim lands one grace-window before the blank, not at it.
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
assert_eq!(
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
vec![Action::Dim]
);
// The warning is asked for once, not on every tick.
assert!(sm.tick_at(t0 + dim_at + Duration::from_secs(1)).is_empty());
// Then the panel goes dark when the budget runs out.
assert_eq!(
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
vec![Action::Blank]
);
// And that is asked for once too.
assert!(sm
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(5))
.is_empty());
}
#[test]
fn tap_inside_the_grace_window_cancels_the_blank() {
let (mut sm, t0) = locked_and_lit();
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
assert_eq!(
sm.tick_at(t0 + dim_at + Duration::from_millis(10)),
vec![Action::Dim]
);
// The user taps: brightness comes back and the budget restarts.
assert_eq!(sm.note_input(InputTrigger::Touch), vec![Action::Restore]);
assert!(sm.user_active(), "a tap means the user is here");
// While the compositor reports the user as active, nothing happens at
// all — this is the case a self-stamped clock gets wrong.
assert!(sm.tick_at(t0 + LOCK_BLANK_AFTER * 10).is_empty());
// Quiet resumes later; the budget runs from there, not from before.
let tap = t0 + LOCK_BLANK_AFTER * 10;
sm.note_idle_start(tap);
let at_old_deadline = tap + LOCK_BLANK_AFTER - Duration::from_secs(1);
assert!(!sm
.tick_at(at_old_deadline)
.contains(&Action::Blank));
// And it does eventually blank, one full budget after the tap.
assert_eq!(
sm.tick_at(tap + LOCK_BLANK_AFTER + Duration::from_millis(10)),
vec![Action::Blank]
);
}
#[test]
fn proximity_near_while_locked_blanks_at_once() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.proximity_near = true;
sm.mark_evidence_seen(SensorSource::Proximity);
// No waiting for the idle budget: it is in a pocket now.
assert_eq!(
sm.tick_at(t0 + Duration::from_millis(10)),
vec![Action::Blank]
);
}
#[test]
fn proximity_near_while_unlocked_is_left_alone() {
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.sensor_evidence.proximity_near = true;
sm.mark_evidence_seen(SensorSource::Proximity);
// A hand over the sensor on an unlocked, in-use phone is not a
// reason to blank it.
assert!(sm.tick_at(t0 + Duration::from_secs(1)).is_empty());
}
#[test]
fn an_unlocked_screen_is_never_blanked_by_this_rule() {
let mut sm = DeviceStateMachine::new();
let t0 = Instant::now();
sm.note_idle_start(t0);
assert_eq!(sm.state(), DeviceState::Active);
// Hours idle and unlocked: not this rule's business.
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
}
#[test]
fn never_is_a_real_setting() {
let (mut sm, t0) = locked_and_lit();
sm.policy.lock_blank_after = None;
assert!(sm.tick_at(t0 + Duration::from_secs(3600)).is_empty());
}
#[test]
fn a_held_device_gets_the_longer_budget() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.accel_moving = true;
sm.mark_evidence_seen(SensorSource::Accelerometer);
// Past the table budget, but a hand is on it: the panel has not gone
// dark. (A dim warning is due here — the held budget's grace window
// starts exactly where the table budget would have blanked.)
assert!(!sm
.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_secs(1))
.contains(&Action::Blank));
// The longer budget still ends.
assert_eq!(
sm.tick_at(t0 + LOCK_BLANK_AFTER_HELD + Duration::from_millis(10)),
vec![Action::Blank]
);
}
#[test]
fn stale_evidence_stops_buying_the_longer_budget() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.accel_moving = true;
sm.mark_evidence_seen(SensorSource::Accelerometer);
// The sensor dies: no further readings. Once the TTL passes the
// reading is unknown, and unknown must not extend the budget —
// erring toward saving the panel, never toward burning it.
let late = t0 + EVIDENCE_TTL + LOCK_BLANK_AFTER;
let actions = sm.tick_at(late);
assert!(!sm.sensor_evidence.accel_moving, "stale reading must clear");
assert_eq!(actions, vec![Action::Blank]);
}
#[test]
fn stale_proximity_stops_suppressing_wake() {
let (mut sm, t0) = locked_and_lit();
sm.sensor_evidence.proximity_near = true;
sm.mark_evidence_seen(SensorSource::Proximity);
assert!(sm.sensor_evidence.should_suppress_dpms_wake());
// A dead proximity sensor must not keep vetoing wakes forever.
sm.tick_at(t0 + EVIDENCE_TTL + Duration::from_secs(1));
assert!(!sm.sensor_evidence.should_suppress_dpms_wake());
}
#[test]
fn waking_from_a_dimmed_blank_restores_brightness_first() {
let (mut sm, t0) = locked_and_lit();
let dim_at = LOCK_BLANK_AFTER - LOCK_DIM_GRACE;
sm.tick_at(t0 + dim_at + Duration::from_millis(10));
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10));
sm.set_panel(false);
// dt2w wake: the saved brightness is still the dim value, so the
// restore has to run or the panel comes up looking dead.
assert_eq!(sm.set_panel(true), vec![Action::Restore]);
}
#[test]
fn pam_unlock_is_legal_from_every_locked_substate() {
for from in [
DeviceState::Locked,
DeviceState::Observed,
DeviceState::DozeLight,
DeviceState::DozeDeep,
] {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
// Walk the real graph to get there; doze tiers promote in order.
match from {
DeviceState::Locked => {}
DeviceState::Observed => assert!(sm.transition(DeviceState::Observed)),
DeviceState::DozeLight => assert!(sm.transition(DeviceState::DozeLight)),
DeviceState::DozeDeep => {
assert!(sm.transition(DeviceState::DozeLight));
assert!(sm.transition(DeviceState::DozeDeep));
}
other => panic!("unexpected setup state {other:?}"),
}
assert!(
sm.transition(DeviceState::Active),
"unlock from {from:?} must be legal"
);
}
}
#[test]
fn legal_transitions_work() {
let mut sm = DeviceStateMachine::new();

175
src/sessiond/idle.rs Normal file
View file

@ -0,0 +1,175 @@
//! ext-idle-notify-v1 client: the machine's input truth.
//!
//! The state machine needs one fact it cannot work out for itself — is the
//! user touching the device right now. Nothing in sessiond could answer that
//! before: the daemon saw handshakes and PAM verdicts, never input. So the
//! lock-blank rule had no honest clock to run against and the only actor that
//! knew about input was hypridle, off in its own config with a single
//! 600-second timer shared with the desktop case.
//!
//! The compositor is the only thing that actually sees input, so we ask it
//! rather than guess. `ext_idle_notifier_v1` gives exactly two events:
//! `idled` after the requested quiet period, and `resumed` on the next input.
//! Tracking the *boolean* — active or quiet — instead of stamping "last input"
//! ourselves is what makes continuous input safe: a long swipe on the lock
//! surface never idles, so the machine never blanks mid-gesture.
//!
//! This connection is deliberately separate from `lock.rs`. A lock session is
//! transient — it exists only while sessiond holds the lock — but input truth
//! must be continuous, including the whole time the shell owns the lock.
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use tracing::{info, warn};
use wayland_client::{
delegate_noop,
protocol::wl_registry::{self, WlRegistry},
Connection, Dispatch, QueueHandle,
};
use wayland_protocols::ext::idle_notify::v1::client::{
ext_idle_notification_v1::{self, ExtIdleNotificationV1},
ext_idle_notifier_v1::ExtIdleNotifierV1,
};
use wayland_client::protocol::wl_seat::WlSeat;
/// How long a quiet period must last before the compositor tells us the user
/// went away. Short, because it is only the *edge* we care about — the budgets
/// themselves live in the machine's policy. One second keeps the reported idle
/// start accurate to within a second without waking us needlessly.
const QUIET_PERIOD: Duration = Duration::from_secs(1);
/// What the idle watcher reports back into the daemon.
pub enum IdleEvent {
/// The compositor has seen no input for `QUIET_PERIOD`. The user stopped
/// interacting approximately that long ago.
Idled { since: Instant },
/// Input happened. The user is here now.
Resumed,
}
struct IdleState {
notifier: Option<ExtIdleNotifierV1>,
seat: Option<WlSeat>,
sink: Arc<dyn Fn(IdleEvent) + Send + Sync>,
}
/// Run the idle watcher until the connection drops. Blocking; call on its own
/// thread.
pub fn run(sink: Arc<dyn Fn(IdleEvent) + Send + Sync>) -> Result<()> {
let conn = Connection::connect_to_env().context("connecting to the compositor")?;
let display = conn.display();
let mut queue = conn.new_event_queue();
let qh = queue.handle();
display.get_registry(&qh, ());
let mut state = IdleState {
notifier: None,
seat: None,
sink,
};
// Two round trips: the first surfaces the globals, the second lets the
// seat and notifier binds settle before we ask for a notification.
queue.roundtrip(&mut state)?;
queue.roundtrip(&mut state)?;
let (Some(notifier), Some(seat)) = (state.notifier.clone(), state.seat.clone()) else {
anyhow::bail!(
"compositor does not offer ext_idle_notifier_v1 — the machine has no input truth"
);
};
// `get_idle_notification` (not the input-ignoring variant): we want the
// idle timer to be reset by real input only.
let _notification = notifier.get_idle_notification(
QUIET_PERIOD.as_millis() as u32,
&seat,
&qh,
(),
);
info!(
"[idle] watching input via ext_idle_notifier_v1 (quiet period {}ms)",
QUIET_PERIOD.as_millis()
);
loop {
queue.blocking_dispatch(&mut state)?;
}
}
impl Dispatch<WlRegistry, ()> for IdleState {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version,
} = event
{
match interface.as_str() {
"ext_idle_notifier_v1" => {
state.notifier =
Some(registry.bind::<ExtIdleNotifierV1, _, _>(name, 1, qh, ()));
}
"wl_seat" => {
if state.seat.is_none() {
state.seat =
Some(registry.bind::<WlSeat, _, _>(name, version.min(5), qh, ()));
}
}
_ => {}
}
}
}
}
impl Dispatch<ExtIdleNotificationV1, ()> for IdleState {
fn event(
state: &mut Self,
_: &ExtIdleNotificationV1,
event: ext_idle_notification_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
ext_idle_notification_v1::Event::Idled => {
// Input stopped one quiet period ago, not now.
let since = Instant::now()
.checked_sub(QUIET_PERIOD)
.unwrap_or_else(Instant::now);
(state.sink)(IdleEvent::Idled { since });
}
ext_idle_notification_v1::Event::Resumed => {
(state.sink)(IdleEvent::Resumed);
}
_ => {}
}
}
}
// The seat is bound only to scope the notification; its events are not ours.
delegate_noop!(IdleState: ignore WlSeat);
delegate_noop!(IdleState: ignore ExtIdleNotifierV1);
/// Spawn the watcher and keep it alive. A compositor restart drops the
/// connection; input truth is load-bearing, so we reconnect rather than
/// silently degrade into a machine that cannot see the user.
pub fn spawn(sink: Arc<dyn Fn(IdleEvent) + Send + Sync>) {
std::thread::spawn(move || loop {
match run(Arc::clone(&sink)) {
Ok(()) => warn!("[idle] watcher exited cleanly — retrying"),
Err(e) => warn!("[idle] watcher failed: {e:#} — retrying"),
}
std::thread::sleep(Duration::from_secs(5));
});
}

View file

@ -13,6 +13,7 @@
pub mod auth;
pub mod device_state;
pub mod draw;
pub mod idle;
pub mod lock;
pub mod protocol;
pub mod server;

View file

@ -60,9 +60,45 @@ pub enum Request {
/// Report a sensor reading to the device state machine.
/// The shell or a sensor daemon feeds proximity/accel/touch evidence.
SensorInput(SensorInput),
/// Real user input happened — touch, key, power button, dt2w wake.
/// Resets the idle budget that the lock-blank rule counts against.
/// This is the wire that lets the machine tell "the user is looking at
/// the lock screen" from "the lock screen has been lit for ten minutes."
Input { trigger: Option<InputTrigger> },
/// The DPMS executor reports the panel's real power state. The machine
/// keeps the panel as a field, not a state: "locked with the screen off"
/// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
/// glance. Reported, never assumed — the executor owns the panel.
Panel { on: bool },
/// Query recent forensic log entries. Returns the last N entries
/// from the in-memory forensic buffer for post-hoc analysis.
ForensicLog { count: Option<usize> },
/// Read the timed policy — the Auto-Lock shaped settings.
GetPolicy,
/// 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
/// than a switch that only looks like it did something (TASK-19's rule).
SetPolicy {
/// Seconds; `Some(0)` means never blank.
lock_blank_after_secs: Option<u64>,
lock_blank_after_held_secs: Option<u64>,
dim_grace_secs: Option<u64>,
evidence_ttl_secs: Option<u64>,
dim_warning: Option<bool>,
},
}
/// What produced a user input. Recorded so the forensic trail can tell a
/// deliberate power-button press from an accidental pocket touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputTrigger {
Touch,
Key,
PowerButton,
DoubleTapToWake,
Unknown,
}
/// A sensor reading fed to the device state machine.

View file

@ -17,12 +17,27 @@ use std::time::Duration;
use anyhow::{Context, Result};
use tracing::{info, warn};
use crate::sessiond::device_state::{DeviceStateMachine, SensorEvidence};
use crate::sessiond::device_state::{Action, DeviceState, DeviceStateMachine, SensorEvidence};
use crate::sessiond::idle;
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
use crate::sessiond::protocol::{
Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES,
InputTrigger, Phase, Request, SensorSource, SensorValue, LOCKED_ACK_TIMEOUT_SECS,
MAX_REQUEST_BYTES,
};
/// How often the machine's clock runs. One second is fine: the shortest
/// budget it enforces is measured in seconds, and a locked idle phone should
/// not be woken more often than it must be.
const TICK_INTERVAL: Duration = Duration::from_secs(1);
/// The one userspace authority for panel DPMS on blueline. sessiond decides;
/// this serializes the transition and owns the FTS pacing lesson.
const DPMS_EXECUTOR: &str = "blueline-screen-toggle";
/// Restores brightness after a dim, with the 10% floor. Never a bare
/// `brightnessctl -r` — see Pixel3Arch b9f83f0.
const UNDIM_EXECUTOR: &str = "blueline-undim";
struct Daemon {
phase: Phase,
/// Present exactly while a lock-session thread is running.
@ -90,6 +105,8 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
if initial_lock {
spawn_lock_session(&shared);
}
spawn_clock(&shared);
spawn_idle_watcher(&shared);
info!("sessiond serving on {}", socket_path.display());
for stream in listener.incoming() {
@ -104,6 +121,105 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
Ok(())
}
/// The machine's clock. Ticks the state machine and carries out whatever it
/// decided.
///
/// The daemon had no periodic anything before this: every `Duration` in it was
/// a one-shot handshake deadline. That is the whole reason the state machine
/// never moved — it could answer requests, but nothing ever told it that time
/// 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::sleep(TICK_INTERVAL);
// Hold the lock only to decide, never while running a command.
let actions = {
let mut d = shared.lock();
d.device_state.tick()
};
for action in actions {
execute(&shared, action);
}
});
}
/// Feed compositor input truth into the machine.
///
/// Without this the machine has a clock but no idea whether anyone is
/// touching the device, and the only other actor holding that knowledge is
/// hypridle — which is why the lock screen's timeout lived in hypridle's
/// config with a single 600 s timer instead of in the authority that owns the
/// lock.
fn spawn_idle_watcher(shared: &Arc<Shared>) {
let shared = Arc::clone(shared);
idle::spawn(Arc::new(move |event| match event {
idle::IdleEvent::Idled { since } => {
shared.lock().device_state.note_idle_start(since);
}
idle::IdleEvent::Resumed => {
let actions = {
let mut d = shared.lock();
d.device_state.note_input(InputTrigger::Unknown)
};
for action in actions {
execute(&shared, action);
}
}
}));
}
/// Carry out one decision. Failures are recorded in the forensic trail — a
/// blank that did not happen is a state divergence, not a log line to lose.
fn execute(shared: &Arc<Shared>, action: Action) {
let (program, args, label): (&str, Vec<&str>, &str) = match action {
// Save-then-dim. sessiond guarantees this runs once per dim, which is
// what the old hypridle `-s/-r` listener could not: a second save
// while already dim is what pinned brightness at 10/255.
Action::Dim => ("brightnessctl", vec!["-s", "set", "10"], "panel-dim"),
Action::Restore => (UNDIM_EXECUTOR, vec![], "panel-restore"),
Action::Blank => (DPMS_EXECUTOR, vec!["off"], "panel-off"),
};
match std::process::Command::new(program).args(&args).status() {
Ok(status) if status.success() => {
if action == Action::Blank {
// Record the panel we actually turned off, so the machine's
// field tracks the hardware rather than its own intent.
let follow_up = shared.lock().device_state.set_panel(false);
for a in follow_up {
execute(shared, a);
}
}
}
Ok(status) => {
let code = status.code().unwrap_or(-1).to_string();
warn!("{program} {args:?} exited {code}");
shared
.lock()
.device_state
.record_error("device-state", label, &code);
}
Err(e) => {
warn!("could not run {program}: {e}");
shared
.lock()
.device_state
.record_error("device-state", label, &e.to_string());
}
}
}
/// Drive the machine to a tier. Kept separate from the lock plumbing so the
/// call sites read as one line each.
fn set_tier(d: &mut Daemon, next: DeviceState, why: &str) {
if d.device_state.state() == next {
return;
}
if !d.device_state.transition(next) {
warn!("[device-state] {why}: refused {:?}", next);
}
}
/// Start a lock session unless one is already running. Returns whether a
/// session is running when we leave (either ours or a pre-existing one).
fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
@ -128,6 +244,9 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
};
d.controller = Some(controller);
d.phase = Phase::Holding;
// sessiond itself now holds the lock — boot lock, retake after a shell
// death, or an explicit `lock` request. All three are the locked tier.
set_tier(&mut d, DeviceState::Locked, "sessiond holds the lock");
drop(d);
let shared = Arc::clone(shared);
@ -140,6 +259,8 @@ fn spawn_lock_session(shared: &Arc<Shared>) -> bool {
Ok(SessionOutcome::Unlocked) => {
info!("session unlocked at fallback surface");
d.phase = if d.shell_alive { Phase::Released } else { Phase::Idle };
// PAM succeeded. Only this path may leave the locked tier.
set_tier(&mut d, DeviceState::Active, "unlocked at fallback surface");
}
Ok(SessionOutcome::Released) => {
d.phase = Phase::AwaitingShellLock;
@ -311,6 +432,11 @@ fn handle_request(
}
d.phase = Phase::Released;
info!("shell lock confirmed");
// The compositor has acknowledged a secure lock surface. THIS is
// the transition nothing ever made: without it the machine stayed
// in Active for the life of the daemon, so every locked-state rule
// below it was unreachable.
set_tier(&mut d, DeviceState::Locked, "shell lock confirmed");
// The handoff is a deliberate connection drop, but Hyprland
// announces any dying lock client as a crashed lockscreen.
// The shell's lock is confirmed, so clear that banner.
@ -365,20 +491,24 @@ fn handle_request(
let conf = evidence.confidence();
let suppress = evidence.should_suppress_dpms_wake();
let promote = evidence.should_promote_idle_faster();
info!(
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
input.source, input.value, conf, suppress, promote
);
// Let the state machine evaluate Observed transitions.
// Clone the evidence before passing it to update_sensors
// (which takes ownership).
let evidence_clone = SensorEvidence {
// Copy the readings out, ending the borrow before the machine is
// touched again.
let evidence_copy = SensorEvidence {
proximity_near: evidence.proximity_near,
accel_moving: evidence.accel_moving,
light_changing: evidence.light_changing,
touch_active: evidence.touch_active,
};
d.device_state.update_sensors(evidence_clone);
// Stamp the source as fresh. Without this every reading expires
// on the next tick, because "never reported" and "reported long
// ago" are the same thing to the staleness rule.
d.device_state.mark_evidence_seen(input.source);
info!(
"[device-state] sensor {:?} = {:?} (confidence={:.2}, suppress_dpms={}, promote_idle={})",
input.source, input.value, conf, suppress, promote
);
// Let the state machine evaluate Observed transitions.
d.device_state.update_sensors(evidence_copy);
serde_json::json!({
"ok": true,
"confidence": conf,
@ -387,6 +517,87 @@ fn handle_request(
"device_state": d.device_state.state(),
})
}
Request::Input { trigger } => {
let trigger = trigger.unwrap_or(InputTrigger::Unknown);
let actions = {
let mut d = shared.lock();
d.device_state.note_input(trigger)
};
let cancelled = !actions.is_empty();
for action in actions {
execute(shared, action);
}
serde_json::json!({ "ok": true, "dim_cancelled": cancelled })
}
Request::Panel { on } => {
let actions = {
let mut d = shared.lock();
d.device_state.set_panel(on)
};
for action in actions {
execute(shared, action);
}
serde_json::json!({ "ok": true, "panel_on": on })
}
Request::GetPolicy => {
let d = shared.lock();
let p = &d.device_state.policy;
serde_json::json!({
"ok": true,
"lock_blank_after_secs": p.lock_blank_after.map(|v| v.as_secs()).unwrap_or(0),
"lock_blank_after_held_secs":
p.lock_blank_after_held.map(|v| v.as_secs()).unwrap_or(0),
"dim_grace_secs": p.dim_grace.as_secs(),
"evidence_ttl_secs": p.evidence_ttl.as_secs(),
"dim_warning": p.dim_warning,
})
}
Request::SetPolicy {
lock_blank_after_secs,
lock_blank_after_held_secs,
dim_grace_secs,
evidence_ttl_secs,
dim_warning,
} => {
// 0 means "never" — the desk-clock case, and iOS's Auto-Lock has
// the same option for the same reason.
let as_budget = |secs: u64| {
if secs == 0 {
None
} else {
Some(Duration::from_secs(secs))
}
};
let mut d = shared.lock();
let p = &mut d.device_state.policy;
if let Some(v) = lock_blank_after_secs {
p.lock_blank_after = as_budget(v);
}
if let Some(v) = lock_blank_after_held_secs {
p.lock_blank_after_held = as_budget(v);
}
if let Some(v) = dim_grace_secs {
p.dim_grace = Duration::from_secs(v);
}
if let Some(v) = evidence_ttl_secs {
p.evidence_ttl = Duration::from_secs(v);
}
if let Some(v) = dim_warning {
p.dim_warning = v;
}
let applied = serde_json::json!({
"lock_blank_after_secs": p.lock_blank_after.map(|v| v.as_secs()).unwrap_or(0),
"lock_blank_after_held_secs":
p.lock_blank_after_held.map(|v| v.as_secs()).unwrap_or(0),
"dim_grace_secs": p.dim_grace.as_secs(),
"evidence_ttl_secs": p.evidence_ttl.as_secs(),
"dim_warning": p.dim_warning,
});
info!("[device-state] policy set: {applied}");
d.device_state
.record_decision("policy-set", applied.clone(), "settings changed the policy");
serde_json::json!({ "ok": true, "policy": applied })
}
Request::ForensicLog { count } => {
let d = shared.lock();
let entries = d.device_state.forensic.recent(count.unwrap_or(50));