diff --git a/src/sessiond/lockhint.rs b/src/sessiond/lockhint.rs index 3d03c30..d2e70b7 100644 --- a/src/sessiond/lockhint.rs +++ b/src/sessiond/lockhint.rs @@ -55,7 +55,7 @@ use tracing::{info, warn}; /// path, so monitoring the alias yields a subscription that is silently never /// delivered. That is the failure the shell already hit /// (`[session-events] no session path returned`). -fn resolve_session_path() -> Result { +pub fn resolve_session_path() -> Result { let uid = std::fs::metadata("/proc/self") .map(|m| m.uid()) .context("reading our own uid")?; @@ -121,7 +121,7 @@ fn resolve_session_path() -> Result { /// One-shot read, so the machine starts from the truth rather than from a /// default that happens to be wrong until the first signal arrives. -fn read_locked_hint(path: &str) -> Result { +pub fn read_locked_hint(path: &str) -> Result { let out = Command::new("busctl") .args([ "--system", diff --git a/src/sessiond/server.rs b/src/sessiond/server.rs index 98616a3..df6c274 100644 --- a/src/sessiond/server.rs +++ b/src/sessiond/server.rs @@ -205,6 +205,21 @@ fn spawn_lock_hint_watcher(shared: &Arc) { /// 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. +/// Ask logind whether this session is locked. Fail closed: if the question +/// cannot be answered, the answer is "not locked", because the failure this +/// guards is a dark screen on an open session. +fn session_is_locked() -> bool { + match crate::sessiond::lockhint::resolve_session_path() + .and_then(|p| crate::sessiond::lockhint::read_locked_hint(&p)) + { + Ok(locked) => locked, + Err(e) => { + warn!("could not read LockedHint ({e:#}); treating the session as unlocked"); + false + } + } +} + fn execute(shared: &Arc, action: Action) { // The lock is not a shell command, so it does not go through the executor // table below. It is the authority acting as the authority: the shell owns @@ -265,7 +280,29 @@ fn execute(shared: &Arc, action: Action) { // dim is what pinned brightness at 10/255. Action::Dim => ("brightnessctl", vec!["set", "10"], "panel-dim"), Action::Restore => unreachable!("handled above; the restore carries a value"), - Action::Blank => (DPMS_EXECUTOR, vec!["off"], "panel-off"), + Action::Blank => { + // The invariant, enforced where it cannot be reasoned around: the + // panel does not go dark unless logind says this session is + // locked, right now. The machine's own `Locked` is a belief, and a + // belief that entered by one route (locked_ack) and can only leave + // by another (logind's falling edge) gets stuck — observed + // 2026-07-26: machine `locked`, LockedHint `no`, panel off, and + // double-tap waking straight to an unlocked screen. + // + // Checked at the actuator, not at the decision, because every path + // to a dark panel ends here and only here. + if !session_is_locked() { + warn!("refusing to blank: logind says this session is not locked"); + shared.lock().device_state.record_error( + "device-state", + "panel-off", + "refused: session not locked per logind", + ); + request_session_lock(shared); + return; + } + (DPMS_EXECUTOR, vec!["off"], "panel-off") + } Action::Lock => unreachable!("handled above; the lock is not a shell command"), };