Watch
1
0
Fork
You've already forked souveraine
0

sessiond: debounce the proximity edge that actually flaps

Measured: 44 near-episodes in 2.4h, median dwell 1s, 15 sub-second. The
negative debounce §9.5 specified would have delayed each blip, not removed it.
This commit is contained in:
Fimeg 2026-07-26 12:50:26 -04:00
commit 4cd7ddd046

View file

@ -71,6 +71,33 @@ pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
/// periodically, so silence means silence rather than "nothing changed". /// periodically, so silence means silence rather than "nothing changed".
pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90); pub const SOURCE_DOWN_AFTER: Duration = Duration::from_secs(90);
/// How long proximity must read `near` before the machine believes it.
///
/// §9.5 specified Android's `DisplayPowerProximityStateController` — 0 ms
/// positive, 250 ms negative — and that is the wrong shape for this device.
/// Measured on the trail 2026-07-26: 44 near-episodes over 2.4 hours, median
/// dwell **1 second**, 15 of them sub-second, and a median 115 s of quiet
/// between them. That is not a sensor bouncing around a threshold; it is
/// isolated one-second blips. A negative debounce delays believing `far`, so it
/// would have turned each 1 s blip into a 1.25 s blip and left all 88
/// transitions in place.
///
/// Android debounces the negative edge because there `near` means *screen off
/// at the ear, immediately* — a positive delay would be felt. That constraint
/// left when proximity stopped actuating: it now only vetoes tap-to-wake, where
/// waiting is imperceptible and a false veto is the more annoying failure.
///
/// Nine of the 44 episodes ran ≥5 s (max 173 s). Those are the real ones —
/// pocket, ear, deliberate cover — and they survive this threshold untouched.
pub const PROXIMITY_NEAR_DEBOUNCE: Duration = Duration::from_millis(700);
/// How long proximity must read `far` before the machine believes it.
///
/// Zero. An uncovered sensor is believed at once: the reading that ends a veto
/// should never be the slow one, and erring toward `far` errs toward letting a
/// wake through, which is the recoverable direction.
pub const PROXIMITY_FAR_DEBOUNCE: Duration = Duration::ZERO;
/// How long a blank waits for the compositor to acknowledge the lock it asked /// How long a blank waits for the compositor to acknowledge the lock it asked
/// for before going dark anyway. /// for before going dark anyway.
/// ///
@ -119,6 +146,10 @@ pub struct DeviceStatePolicy {
pub unlocked_blank_after: Option<Duration>, pub unlocked_blank_after: Option<Duration>,
/// A source silent for this long, having previously reported, is down. /// A source silent for this long, having previously reported, is down.
pub source_down_after: Duration, pub source_down_after: 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.
pub proximity_far_debounce: Duration,
} }
impl DeviceStatePolicy { impl DeviceStatePolicy {
@ -200,6 +231,8 @@ impl Default for DeviceStatePolicy {
lock_ack_budget: LOCK_ACK_BUDGET, lock_ack_budget: LOCK_ACK_BUDGET,
unlocked_blank_after: None, unlocked_blank_after: None,
source_down_after: SOURCE_DOWN_AFTER, source_down_after: SOURCE_DOWN_AFTER,
proximity_near_debounce: PROXIMITY_NEAR_DEBOUNCE,
proximity_far_debounce: PROXIMITY_FAR_DEBOUNCE,
} }
} }
} }
@ -915,6 +948,11 @@ pub struct DeviceStateMachine {
/// deadline after which the panel goes dark regardless — see /// deadline after which the panel goes dark regardless — see
/// `LOCK_ACK_BUDGET` for why that fail-open is the documented choice. /// `LOCK_ACK_BUDGET` for why that fail-open is the documented choice.
pending_blank: Option<Instant>, pending_blank: Option<Instant>,
/// The last raw proximity reading, before debounce. `sensor_evidence`
/// carries the believed value; this carries what the sensor actually said.
proximity_raw: bool,
/// When the raw reading last changed. The debounce measures from here.
proximity_since: Option<Instant>,
/// Timed policy. Settable, so Settings can own it. /// Timed policy. Settable, so Settings can own it.
pub policy: DeviceStatePolicy, pub policy: DeviceStatePolicy,
} }
@ -981,6 +1019,8 @@ impl DeviceStateMachine {
blank_requested: false, blank_requested: false,
dimmed: false, dimmed: false,
pending_blank: None, pending_blank: None,
proximity_raw: false,
proximity_since: None,
// Loaded, not defaulted: the user's Auto-Lock choices are settings, // Loaded, not defaulted: the user's Auto-Lock choices are settings,
// and a setting that reverts on reboot is not a setting. // and a setting that reverts on reboot is not a setting.
policy: DeviceStatePolicy::load(), policy: DeviceStatePolicy::load(),
@ -1018,7 +1058,11 @@ impl DeviceStateMachine {
/// `tick` with the clock passed in, so the rules can be tested at any /// `tick` with the clock passed in, so the rules can be tested at any
/// point on the timeline instead of by sleeping. /// point on the timeline instead of by sleeping.
pub fn tick_at(&mut self, now: Instant) -> Vec<Action> { pub fn tick_at(&mut self, now: Instant) -> Vec<Action> {
// Expiry first. A source that has gone stale must not be allowed to
// win a pending debounce and then be cleared in the same tick — that
// would put a transition in the trail for a reading nobody confirmed.
self.expire_stale_evidence(now); self.expire_stale_evidence(now);
self.resolve_proximity_debounce(now);
self.evaluate_source_health(now); self.evaluate_source_health(now);
let mut actions = Vec::new(); let mut actions = Vec::new();
@ -1212,6 +1256,12 @@ impl DeviceStateMachine {
&mut self.evidence_seen.proximity, &mut self.evidence_seen.proximity,
&mut self.sensor_evidence.proximity_near, &mut self.sensor_evidence.proximity_near,
) { ) {
// Clear the raw reading and any pending debounce with it. Stale
// means unknown, and a half-resolved edge left behind an expiry
// would let a reading nobody has confirmed in 30 s win the moment
// the next tick ran.
self.proximity_raw = false;
self.proximity_since = None;
dropped.push("proximity"); dropped.push("proximity");
} }
if expired( if expired(
@ -1598,6 +1648,87 @@ impl DeviceStateMachine {
actions actions
} }
/// Decide whether to believe a raw proximity reading yet.
///
/// Returns the *believed* value. A raw reading that disagrees with the
/// believed one starts a clock; it only wins once it has held for the
/// direction's threshold. `resolve_proximity_debounce` finishes the job on
/// the tick, for the case where the sensor reports once and goes quiet.
fn debounce_proximity(&mut self, raw: bool, now: Instant) -> bool {
if raw != self.proximity_raw {
self.proximity_raw = raw;
self.proximity_since = Some(now);
}
let believed = self.sensor_evidence.proximity_near;
if raw == believed {
// Nothing pending — the sensor agrees with what we already think.
self.proximity_since = None;
return believed;
}
let held_for = self
.proximity_since
.map(|t| now.saturating_duration_since(t))
.unwrap_or_default();
let threshold = if raw {
self.policy.proximity_near_debounce
} else {
self.policy.proximity_far_debounce
};
if held_for >= threshold {
self.proximity_since = None;
raw
} else {
believed
}
}
/// Let a held reading win once its threshold passes with no new report.
///
/// Without this the debounce would only resolve when the next reading
/// arrives, and a reporter that heartbeats every 30 s would make a real
/// `near` take up to half a minute to be believed.
fn resolve_proximity_debounce(&mut self, now: Instant) {
if self.proximity_raw == self.sensor_evidence.proximity_near {
return;
}
let held_for = self
.proximity_since
.map(|t| now.saturating_duration_since(t))
.unwrap_or_default();
let believed = self.debounce_proximity(self.proximity_raw, now);
if believed != self.sensor_evidence.proximity_near {
self.sensor_evidence.proximity_near = believed;
// Record the edge. Without this the trail would carry a
// Locked → Observed transition with no sensor input behind it —
// the reading was filtered on the way in and believed a tick
// later, so nothing would say why the machine moved. Filtered
// blips stay out of the trail deliberately; the edge that wins
// does not.
let confidence = self.sensor_evidence.confidence();
let snapshot = self.snapshot("", false, false, false, "", false);
self.forensic.append(
ForensicEvent::SensorInput {
source: SensorSource::Proximity,
value: SensorValue::Near(believed),
confidence,
},
snapshot,
&format!(
"proximity={believed} believed after holding {} ms",
held_for.as_millis()
),
);
let should_be_observed = self.state == DeviceState::Locked && believed;
if should_be_observed {
self.transition(DeviceState::Observed);
} else if matches!(self.state, DeviceState::Observed) && !believed {
self.transition(DeviceState::Locked);
}
}
}
/// Proximity-shaped convenience wrapper. /// Proximity-shaped convenience wrapper.
pub fn update_sensors(&mut self, evidence: SensorEvidence) { pub fn update_sensors(&mut self, evidence: SensorEvidence) {
self.update_sensors_from(SensorSource::Proximity, evidence) self.update_sensors_from(SensorSource::Proximity, evidence)
@ -1607,6 +1738,27 @@ impl DeviceStateMachine {
/// potentially transition to/from Observed. /// potentially transition to/from Observed.
/// Emits forensic entries for every sensor input. /// Emits forensic entries for every sensor input.
pub fn update_sensors_from(&mut self, source: SensorSource, evidence: SensorEvidence) { pub fn update_sensors_from(&mut self, source: SensorSource, evidence: SensorEvidence) {
self.update_sensors_from_at(source, evidence, Instant::now())
}
/// `update_sensors_from` with the clock passed in, so the debounce can be
/// tested on a timeline instead of by sleeping.
pub fn update_sensors_from_at(
&mut self,
source: SensorSource,
mut evidence: SensorEvidence,
now: Instant,
) {
// Proximity is debounced before it is believed. The rest of the
// evidence is taken as reported — none of it flaps the way this one
// does, and none of it has the measurement behind it that would justify
// picking a threshold.
if source == SensorSource::Proximity {
evidence.proximity_near = self.debounce_proximity(evidence.proximity_near, now);
} else {
evidence.proximity_near = self.sensor_evidence.proximity_near;
}
let was_observed = matches!(self.state, DeviceState::Observed); let was_observed = matches!(self.state, DeviceState::Observed);
let proximity_near = evidence.proximity_near; let proximity_near = evidence.proximity_near;
let should_be_observed = self.state == DeviceState::Locked && proximity_near; let should_be_observed = self.state == DeviceState::Locked && proximity_near;
@ -2257,6 +2409,151 @@ mod tests {
assert!(!e.should_promote_idle_faster()); assert!(!e.should_promote_idle_faster());
} }
// ── Proximity debounce ────────────────────────────────────────────
/// A locked machine and a clock, for driving proximity on a timeline.
fn locked_for_proximity() -> (DeviceStateMachine, Instant) {
let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Locked);
(sm, Instant::now())
}
fn prox(near: bool) -> SensorEvidence {
SensorEvidence {
proximity_near: near,
..Default::default()
}
}
/// Report proximity the way `server.rs` does — the reading and the
/// freshness stamp together. A test that only does the first half has a
/// source that is instantly stale.
fn report_prox(sm: &mut DeviceStateMachine, near: bool, at: Instant) {
sm.mark_evidence_seen_at(SensorSource::Proximity, at);
sm.update_sensors_from_at(SensorSource::Proximity, prox(near), at);
}
/// Report `near` and let it hold long enough to be believed.
fn hold_prox_near(sm: &mut DeviceStateMachine, at: Instant) -> Instant {
report_prox(sm, true, at);
let settled = at + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1);
sm.mark_evidence_seen_at(SensorSource::Proximity, settled);
sm.tick_at(settled);
settled
}
#[test]
fn a_sub_second_near_blip_is_never_believed() {
// The measured case, 2026-07-26: 44 near-episodes in 2.4 hours, median
// dwell 1 s, 15 of them sub-second. Each one produced two transitions
// and two trail entries for a reading that meant nothing.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
assert_eq!(sm.state, DeviceState::Locked, "near is not believed yet");
assert!(!sm.sensor_evidence.proximity_near);
report_prox(&mut sm, false, t0 + Duration::from_millis(400));
sm.tick_at(t0 + Duration::from_millis(500));
assert_eq!(
sm.state,
DeviceState::Locked,
"the blip must leave no transition behind"
);
}
#[test]
fn a_held_near_is_believed_once_it_has_held() {
// The nine real episodes ran 5 s to 173 s. They must survive untouched.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
assert_eq!(sm.state, DeviceState::Locked);
// No further reading arrives — the tick has to finish the job, or a
// reporter that heartbeats every 30 s would delay a real near by half
// a minute.
hold_prox_near(&mut sm, t0);
assert!(sm.sensor_evidence.proximity_near);
assert_eq!(sm.state, DeviceState::Observed);
}
#[test]
fn far_is_believed_at_once() {
// The reading that ends a veto is never the slow one.
let (mut sm, t0) = locked_for_proximity();
hold_prox_near(&mut sm, t0);
assert_eq!(sm.state, DeviceState::Observed);
let t1 = t0 + Duration::from_secs(10);
report_prox(&mut sm, false, t1);
assert!(!sm.sensor_evidence.proximity_near);
assert_eq!(sm.state, DeviceState::Locked);
}
#[test]
fn a_flapping_sensor_that_never_settles_is_never_believed() {
// Alternating faster than the threshold: the near edge keeps restarting
// its clock, so nothing is ever believed and the trail stays quiet.
let (mut sm, t0) = locked_for_proximity();
let mut t = t0;
for _ in 0..20 {
report_prox(&mut sm, true, t);
t += Duration::from_millis(200);
report_prox(&mut sm, false, t);
t += Duration::from_millis(200);
}
assert_eq!(sm.state, DeviceState::Locked);
assert!(!sm.sensor_evidence.proximity_near);
}
#[test]
fn a_heartbeat_repeat_does_not_restart_the_debounce() {
// Reporters re-send their last value every 30 s (§10). A repeat is the
// same edge continuing, not a new one — if it reset the clock, a held
// near would never be believed.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
report_prox(&mut sm, true, t0 + Duration::from_millis(500));
report_prox(
&mut sm,
true,
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1),
);
assert!(sm.sensor_evidence.proximity_near);
assert_eq!(sm.state, DeviceState::Observed);
}
#[test]
fn a_non_proximity_reading_does_not_disturb_the_debounce() {
// accel/light/touch share the evidence struct; updating one must not
// silently overwrite a proximity value mid-debounce.
let (mut sm, t0) = locked_for_proximity();
report_prox(&mut sm, true, t0);
let accel = SensorEvidence {
proximity_near: false, // the caller's stale copy — must be ignored
accel_moving: true,
..Default::default()
};
sm.mark_evidence_seen_at(SensorSource::Accelerometer, t0 + Duration::from_millis(100));
sm.update_sensors_from_at(
SensorSource::Accelerometer,
accel,
t0 + Duration::from_millis(100),
);
sm.mark_evidence_seen_at(
SensorSource::Proximity,
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1),
);
sm.tick_at(t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(1));
assert!(sm.sensor_evidence.accel_moving);
assert!(
sm.sensor_evidence.proximity_near,
"the proximity edge must still resolve on its own clock"
);
}
#[test] #[test]
fn only_tap_to_wake_is_vetoed_by_a_covered_sensor() { fn only_tap_to_wake_is_vetoed_by_a_covered_sensor() {
// A double tap is the one wake a pocket can produce by itself. The // A double tap is the one wake a pocket can produce by itself. The
@ -2280,11 +2577,9 @@ mod tests {
sm.transition(DeviceState::Dimmed); sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked); sm.transition(DeviceState::Locked);
let evidence = SensorEvidence { // Held, not blipped — near is debounced now, so a reading that does not
proximity_near: true, // last is a reading the machine never believed.
..Default::default() hold_prox_near(&mut sm, Instant::now());
};
sm.update_sensors(evidence);
assert_eq!(sm.state(), DeviceState::Observed); assert_eq!(sm.state(), DeviceState::Observed);
} }
@ -2295,11 +2590,9 @@ mod tests {
sm.transition(DeviceState::Locked); sm.transition(DeviceState::Locked);
// Enter observed // Enter observed
sm.update_sensors(SensorEvidence { let settled = hold_prox_near(&mut sm, Instant::now());
proximity_near: true,
..Default::default()
});
assert_eq!(sm.state(), DeviceState::Observed); assert_eq!(sm.state(), DeviceState::Observed);
let _ = settled;
// Leave observed // Leave observed
sm.update_sensors(SensorEvidence { sm.update_sensors(SensorEvidence {
@ -2354,7 +2647,9 @@ mod tests {
"native IdleMonitor fired, requesting lock", "native IdleMonitor fired, requesting lock",
); );
// 4. Phone goes into pocket — proximity near // 4. Phone goes into pocket — proximity near, held. A pocket is not a
// one-second blip; the debounce is what tells them apart.
hold_prox_near(&mut sm, Instant::now());
sm.update_sensors(SensorEvidence { sm.update_sensors(SensorEvidence {
proximity_near: true, proximity_near: true,
accel_moving: false, accel_moving: false,
@ -2388,11 +2683,9 @@ mod tests {
sm.transition(DeviceState::Locked); sm.transition(DeviceState::Locked);
sm.record_wake(WakeTrigger::PowerButton, "user pressed power button"); sm.record_wake(WakeTrigger::PowerButton, "user pressed power button");
// 8. Phone goes back in pocket briefly // 8. Phone goes back in pocket. Held — "briefly" is precisely what the
sm.update_sensors(SensorEvidence { // debounce now refuses to believe, which is the point of it.
proximity_near: true, hold_prox_near(&mut sm, Instant::now());
..Default::default()
});
assert_eq!(sm.state(), DeviceState::Observed); assert_eq!(sm.state(), DeviceState::Observed);
// 9. Phone comes back out // 9. Phone comes back out
@ -2526,13 +2819,20 @@ mod tests {
let mut sm = DeviceStateMachine::new(); let mut sm = DeviceStateMachine::new();
sm.transition(DeviceState::Dimmed); sm.transition(DeviceState::Dimmed);
sm.transition(DeviceState::Locked); sm.transition(DeviceState::Locked);
// Proximity near + accel moving = walking with phone // Proximity near + accel moving = walking with phone. Near is
sm.update_sensors(SensorEvidence { // debounced, so it has to be held before the pair is on the record.
proximity_near: true, let t0 = Instant::now();
accel_moving: true, hold_prox_near(&mut sm, t0);
light_changing: false, sm.update_sensors_from_at(
touch_active: false, SensorSource::Accelerometer,
}); SensorEvidence {
proximity_near: true,
accel_moving: true,
light_changing: false,
touch_active: false,
},
t0 + PROXIMITY_NEAR_DEBOUNCE + Duration::from_millis(2),
);
let entries = sm.forensic.recent(10); let entries = sm.forensic.recent(10);
let sensor_entries: Vec<_> = entries let sensor_entries: Vec<_> = entries
.iter() .iter()
@ -2540,7 +2840,7 @@ mod tests {
.collect(); .collect();
assert!(!sensor_entries.is_empty()); assert!(!sensor_entries.is_empty());
// The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5) // The confidence should reflect the disagreement (0.4 + 0.3 - 0.2 = 0.5)
match &sensor_entries[0].event { match &sensor_entries[sensor_entries.len() - 1].event {
ForensicEvent::SensorInput { confidence, .. } => { ForensicEvent::SensorInput { confidence, .. } => {
assert!( assert!(
(confidence - 0.5).abs() < 0.01, (confidence - 0.5).abs() < 0.01,
@ -2708,15 +3008,13 @@ mod tests {
// `near` across a lock flips should_be_observed with no change in the // `near` across a lock flips should_be_observed with no change in the
// evidence at all — an early return here would strand the machine. // evidence at all — an early return here would strand the machine.
let mut sm = DeviceStateMachine::new(); let mut sm = DeviceStateMachine::new();
let near = SensorEvidence { let t0 = Instant::now();
proximity_near: true, let settled = hold_prox_near(&mut sm, t0);
..Default::default()
};
sm.update_sensors(near.clone());
assert_eq!(sm.state, DeviceState::Active); assert_eq!(sm.state, DeviceState::Active);
assert!(sm.sensor_evidence.proximity_near, "near is believed by now");
sm.transition(DeviceState::Locked); sm.transition(DeviceState::Locked);
sm.update_sensors(near); report_prox(&mut sm, true, settled + Duration::from_secs(1));
assert_eq!( assert_eq!(
sm.state, sm.state,
DeviceState::Observed, DeviceState::Observed,