sessiond: make lock-before-blank an invariant, not a timer coincidence
LOCK-DPMS-LESSONS §1 is "Ordering: lock, then off". It held only because hypridle's 300s lock listener fired before its own 600s screen-off listener — two numbers in a config file, not a guarantee. Anything that skipped the lock (idle inhibitor, native coordinator off, dead shell) still met the blank, and the panel went dark on an unlocked session with nothing said about it. Every path to a dark panel now routes through request_blank(): - locked -> Blank, unchanged, no added round trip - unlocked -> Action::Lock, blank withheld, pending_blank deadline armed - ack lands -> Blank - budget expires -> Blank anyway + error-security in the forensic trail The panel fails open because a lit unlocked phone in a pocket is worse than a dark one (§1); the *claim* fails closed because doctrine §8 forbids pretending the session locked. Input inside the ack window cancels the blank outright. Action::Lock does not go through the executor table — the lock is not a shell command. While a live shell owns steady state the directive goes down the heartbeat connection and the shell raises its rich surface; with no shell alive sessiond raises its own. Same relationship DEVICE-STATE-MACHINE §6 gives the DPMS executor: the authority decides, the surface executes. A directive that cannot be delivered takes the lock here rather than timing out into a dark unlocked screen, and is recorded either way. Policy gains lock_ack_budget (2s, refuses 0) and unlocked_blank_after (None by default — the shell's IdleCoordinator owns the unlocked idle timer through ext-idle-notify per doctrine §5, and a second one here would recreate the competing-owner disease). Both are on the GetPolicy/SetPolicy seam so Settings is a view over the owning daemon, per TASK-19. Unknown directives are loud on the shell side: an older shell silently dropping one would leave the daemon waiting out its budget and blanking unlocked. 38 tests pass, 5 new covering the ordering.
This commit is contained in:
parent
044c373cad
commit
15a32796ba
4 changed files with 376 additions and 17 deletions
|
|
@ -52,6 +52,17 @@ pub const LOCK_DIM_GRACE: Duration = Duration::from_secs(10);
|
|||
/// a dead sensor looked exactly like a consumer of a quiet one.
|
||||
pub const EVIDENCE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a blank waits for the compositor to acknowledge the lock it asked
|
||||
/// for before going dark anyway.
|
||||
///
|
||||
/// `LOCK-DPMS-LESSONS.md` §1 ("Ordering: lock, then off") fixes this at ≤2 s
|
||||
/// for the power-button path and gives the reason for the fail-open: "if the
|
||||
/// lock IPC fails, blank anyway — dark-but-unlocked is recoverable,
|
||||
/// lit-and-unlocked in a pocket is not." Doctrine §8 supplies the other half:
|
||||
/// the panel may proceed, but the session is never *recorded* as locked. The
|
||||
/// timeout is an `error-security` in the trail, not a silent success.
|
||||
pub const LOCK_ACK_BUDGET: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Tunable policy for the machine's timed behaviour.
|
||||
///
|
||||
/// These are settings, not constants: the shape is iOS's Auto-Lock — a
|
||||
|
|
@ -71,6 +82,15 @@ pub struct DeviceStatePolicy {
|
|||
pub evidence_ttl: Duration,
|
||||
/// Whether the dim pre-warning is used at all.
|
||||
pub dim_warning: bool,
|
||||
/// How long a pending blank waits for the lock it asked for.
|
||||
pub lock_ack_budget: Duration,
|
||||
/// Unlocked, panel lit, no input for this long → lock, then blank.
|
||||
/// `None` = never, and that is the default: the shell's `IdleCoordinator`
|
||||
/// owns the unlocked idle→lock timer through `ext-idle-notify` (doctrine
|
||||
/// §5), and a second unlocked timer here would recreate the competing-owner
|
||||
/// disease this machine exists to end. The field is settable so the
|
||||
/// capability is real rather than implied.
|
||||
pub unlocked_blank_after: Option<Duration>,
|
||||
}
|
||||
|
||||
impl Default for DeviceStatePolicy {
|
||||
|
|
@ -81,6 +101,8 @@ impl Default for DeviceStatePolicy {
|
|||
dim_grace: LOCK_DIM_GRACE,
|
||||
evidence_ttl: EVIDENCE_TTL,
|
||||
dim_warning: true,
|
||||
lock_ack_budget: LOCK_ACK_BUDGET,
|
||||
unlocked_blank_after: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,6 +121,17 @@ pub enum Action {
|
|||
Restore,
|
||||
/// Blank the panel through the DPMS executor.
|
||||
Blank,
|
||||
/// Request the session lock, because something wants the panel dark and
|
||||
/// the session is not locked yet.
|
||||
///
|
||||
/// This is the machine's half of `LOCK-DPMS-LESSONS.md` §1 — the ordering
|
||||
/// is lock, *then* off, and it is an invariant the authority enforces
|
||||
/// rather than a coincidence of two timers (hypridle's 300 s lock landing
|
||||
/// before its own 600 s blank). The daemon routes this to the shell's lock
|
||||
/// surface while a live shell owns steady state, and takes the lock itself
|
||||
/// otherwise; either way the panel does not go dark until it is answered
|
||||
/// or the ack budget expires.
|
||||
Lock,
|
||||
}
|
||||
|
||||
/// When each evidence source last reported. Kept beside `SensorEvidence`
|
||||
|
|
@ -382,6 +415,10 @@ pub struct DeviceStateMachine {
|
|||
/// 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,
|
||||
/// A blank is decided but withheld until the session locks. Carries the
|
||||
/// deadline after which the panel goes dark regardless — see
|
||||
/// `LOCK_ACK_BUDGET` for why that fail-open is the documented choice.
|
||||
pending_blank: Option<Instant>,
|
||||
/// Timed policy. Settable, so Settings can own it.
|
||||
pub policy: DeviceStatePolicy,
|
||||
}
|
||||
|
|
@ -450,6 +487,7 @@ impl DeviceStateMachine {
|
|||
evidence_seen: EvidenceSeen::default(),
|
||||
blank_requested: false,
|
||||
dimmed: false,
|
||||
pending_blank: None,
|
||||
policy: DeviceStatePolicy::default(),
|
||||
}
|
||||
}
|
||||
|
|
@ -473,8 +511,43 @@ impl DeviceStateMachine {
|
|||
|
||||
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 {
|
||||
// A blank is already decided and waiting on the lock it asked for.
|
||||
// Nothing else may run while that is outstanding — the whole point is
|
||||
// that the panel does not go dark ahead of the lock.
|
||||
if let Some(deadline) = self.pending_blank {
|
||||
if is_locked(self.state) {
|
||||
self.pending_blank = None;
|
||||
self.blank_requested = true;
|
||||
info!("[device-state] lock acked — blanking");
|
||||
self.record_decision(
|
||||
"panel-off",
|
||||
serde_json::json!({ "waited_for": "lock-ack" }),
|
||||
"the lock this blank asked for was acknowledged",
|
||||
);
|
||||
actions.push(Action::Blank);
|
||||
} else if now >= deadline {
|
||||
// Fail open on the panel, never on the claim. §1: a lit,
|
||||
// unlocked phone in a pocket is the worse outcome, so the
|
||||
// blank proceeds — but doctrine §8 forbids pretending the
|
||||
// session locked, so this is an error in the trail, loudly,
|
||||
// and the machine's state is left untouched.
|
||||
self.pending_blank = None;
|
||||
self.blank_requested = true;
|
||||
warn!(
|
||||
"[device-state] lock NOT acked within {}s — blanking an UNLOCKED session",
|
||||
self.policy.lock_ack_budget.as_secs()
|
||||
);
|
||||
self.record_error(
|
||||
"device-state",
|
||||
"blank-without-lock",
|
||||
"lock ack did not arrive within the budget; panel blanked unlocked",
|
||||
);
|
||||
actions.push(Action::Blank);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
if !self.panel_on || self.blank_requested {
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
|
@ -489,11 +562,10 @@ impl DeviceStateMachine {
|
|||
// 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;
|
||||
if is_locked(self.state) && self.sensor_evidence.proximity_near {
|
||||
info!("[device-state] proximity near while locked — blanking");
|
||||
self.record_decision(
|
||||
"panel-off",
|
||||
return self.request_blank(
|
||||
now,
|
||||
serde_json::json!({
|
||||
"reason": "proximity-near",
|
||||
"confidence": self.sensor_evidence.confidence(),
|
||||
|
|
@ -501,8 +573,6 @@ impl DeviceStateMachine {
|
|||
}),
|
||||
"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 —
|
||||
|
|
@ -513,7 +583,12 @@ impl DeviceStateMachine {
|
|||
};
|
||||
|
||||
let held = self.sensor_evidence.accel_moving;
|
||||
let budget = if held {
|
||||
let budget = if !is_locked(self.state) {
|
||||
// An unlocked screen is not this rule's business by default; the
|
||||
// shell's IdleCoordinator owns that timer. When it IS set, the
|
||||
// blank still routes through lock-then-off below.
|
||||
self.policy.unlocked_blank_after
|
||||
} else if held {
|
||||
self.policy.lock_blank_after_held
|
||||
} else {
|
||||
self.policy.lock_blank_after
|
||||
|
|
@ -527,14 +602,14 @@ impl DeviceStateMachine {
|
|||
|
||||
// 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",
|
||||
"[device-state] {}, idle {:.1}s of {}s — blanking",
|
||||
if is_locked(self.state) { "locked" } else { "unlocked" },
|
||||
idle_for.as_secs_f32(),
|
||||
budget.as_secs()
|
||||
);
|
||||
self.record_decision(
|
||||
"panel-off",
|
||||
return self.request_blank(
|
||||
now,
|
||||
serde_json::json!({
|
||||
"idle_secs": idle_for.as_secs(),
|
||||
"budget_secs": budget.as_secs(),
|
||||
|
|
@ -542,10 +617,8 @@ impl DeviceStateMachine {
|
|||
"was_dimmed": self.dimmed,
|
||||
"confidence": self.sensor_evidence.confidence(),
|
||||
}),
|
||||
"locked, panel lit, no input within the blank budget",
|
||||
"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
|
||||
|
|
@ -577,6 +650,41 @@ impl DeviceStateMachine {
|
|||
actions
|
||||
}
|
||||
|
||||
/// Every path to a dark panel goes through here.
|
||||
///
|
||||
/// `LOCK-DPMS-LESSONS.md` §1 is "Ordering: lock, then off", and until now
|
||||
/// that held only because hypridle's 300 s lock listener happened to fire
|
||||
/// before its own 600 s blank listener. That is an assumption, not an
|
||||
/// invariant: anything that skipped the lock (an idle inhibitor, the
|
||||
/// native coordinator disabled, a dead shell) still got blanked, unlocked
|
||||
/// and silently. Routing every blank through one function makes the
|
||||
/// ordering a property of the authority instead of a coincidence of two
|
||||
/// timers owned by a config file.
|
||||
fn request_blank(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
inputs: serde_json::Value,
|
||||
reason: &str,
|
||||
) -> Vec<Action> {
|
||||
if is_locked(self.state) {
|
||||
self.blank_requested = true;
|
||||
self.record_decision("panel-off", inputs, reason);
|
||||
return vec![Action::Blank];
|
||||
}
|
||||
|
||||
info!(
|
||||
"[device-state] blanking an unlocked session — locking first, {}s to ack",
|
||||
self.policy.lock_ack_budget.as_secs()
|
||||
);
|
||||
self.pending_blank = Some(now + self.policy.lock_ack_budget);
|
||||
self.record_decision(
|
||||
"lock-before-blank",
|
||||
inputs,
|
||||
"a blank was decided for an unlocked session; the lock goes first",
|
||||
);
|
||||
vec![Action::Lock]
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -653,6 +761,12 @@ impl DeviceStateMachine {
|
|||
pub fn note_input(&mut self, trigger: InputTrigger) -> Vec<Action> {
|
||||
self.idle_since = None;
|
||||
self.blank_requested = false;
|
||||
// Input inside the lock-ack window cancels the blank outright. The
|
||||
// lock request already went out and is not recalled — a lock the user
|
||||
// interrupted is a lock, and only PAM leaves it.
|
||||
if self.pending_blank.take().is_some() {
|
||||
info!("[device-state] input during the lock-ack window — blank cancelled");
|
||||
}
|
||||
let mut actions = Vec::new();
|
||||
if self.dimmed {
|
||||
self.dimmed = false;
|
||||
|
|
@ -988,6 +1102,109 @@ mod tests {
|
|||
(sm, t0)
|
||||
}
|
||||
|
||||
/// Unlocked, panel lit, an explicit unlocked blank budget set. This is the
|
||||
/// configuration `unlocked_blank_after` exists for; the default is None.
|
||||
fn unlocked_and_lit() -> (DeviceStateMachine, Instant) {
|
||||
let mut sm = DeviceStateMachine::new();
|
||||
assert!(!is_locked(sm.state));
|
||||
assert!(sm.panel_on);
|
||||
sm.policy.unlocked_blank_after = Some(Duration::from_secs(30));
|
||||
sm.policy.dim_warning = false;
|
||||
let t0 = Instant::now();
|
||||
sm.note_idle_start(t0);
|
||||
(sm, t0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unlocked_blank_asks_for_the_lock_first() {
|
||||
let (mut sm, t0) = unlocked_and_lit();
|
||||
|
||||
// The budget expires: the machine wants the panel dark, but the
|
||||
// session is unlocked, so the lock goes first — LOCK-DPMS §1.
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + Duration::from_secs(31)),
|
||||
vec![Action::Lock]
|
||||
);
|
||||
assert!(sm.pending_blank.is_some());
|
||||
assert!(!sm.blank_requested, "the panel must not be dark yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_blank_fires_once_the_lock_is_acked() {
|
||||
let (mut sm, t0) = unlocked_and_lit();
|
||||
let t1 = t0 + Duration::from_secs(31);
|
||||
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
|
||||
|
||||
// Nothing while the lock is still outstanding, well inside the budget.
|
||||
assert!(sm.tick_at(t1 + Duration::from_millis(500)).is_empty());
|
||||
|
||||
// The lock lands. Now — and only now — the panel goes dark.
|
||||
sm.transition(DeviceState::Locked);
|
||||
assert_eq!(
|
||||
sm.tick_at(t1 + Duration::from_millis(600)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
assert!(sm.pending_blank.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lock_that_never_acks_blanks_anyway_and_says_so() {
|
||||
let (mut sm, t0) = unlocked_and_lit();
|
||||
let t1 = t0 + Duration::from_secs(31);
|
||||
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
|
||||
|
||||
let before = sm.forensic.recent(200).len();
|
||||
|
||||
// Past the ack budget with no lock. §1: dark-but-unlocked beats
|
||||
// lit-and-unlocked in a pocket, so the blank proceeds.
|
||||
assert_eq!(
|
||||
sm.tick_at(t1 + LOCK_ACK_BUDGET + Duration::from_millis(10)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
|
||||
// But doctrine §8 forbids pretending it locked: the state is untouched
|
||||
// and the trail carries an error, not a decision.
|
||||
assert!(!is_locked(sm.state));
|
||||
let entries = sm.forensic.recent(200);
|
||||
assert!(entries.len() > before);
|
||||
assert!(
|
||||
entries.iter().any(|e| matches!(
|
||||
&e.event,
|
||||
ForensicEvent::Error { action, .. } if action == "blank-without-lock"
|
||||
)),
|
||||
"the unlocked blank must be a loud error in the trail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_inside_the_ack_window_cancels_the_blank() {
|
||||
let (mut sm, t0) = unlocked_and_lit();
|
||||
let t1 = t0 + Duration::from_secs(31);
|
||||
assert_eq!(sm.tick_at(t1), vec![Action::Lock]);
|
||||
|
||||
// The user came back within the second-or-so window.
|
||||
sm.note_input(InputTrigger::Touch);
|
||||
assert!(sm.pending_blank.is_none());
|
||||
|
||||
// And the panel stays lit past where the deadline would have been.
|
||||
assert!(sm
|
||||
.tick_at(t1 + LOCK_ACK_BUDGET + Duration::from_secs(1))
|
||||
.is_empty());
|
||||
assert!(!sm.blank_requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_locked_blank_still_goes_straight_to_dark() {
|
||||
// The ordering rule must not add a lock round-trip to the case that
|
||||
// was already correct: locked screens blank immediately.
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
assert_eq!(
|
||||
sm.tick_at(t0 + LOCK_BLANK_AFTER + Duration::from_millis(10)),
|
||||
vec![Action::Blank]
|
||||
);
|
||||
assert!(sm.pending_blank.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lockscreen_dims_as_a_warning_then_blanks() {
|
||||
let (mut sm, t0) = locked_and_lit();
|
||||
|
|
|
|||
|
|
@ -86,6 +86,12 @@ pub enum Request {
|
|||
dim_grace_secs: Option<u64>,
|
||||
evidence_ttl_secs: Option<u64>,
|
||||
dim_warning: Option<bool>,
|
||||
/// Seconds a pending blank waits for its lock ack before going dark
|
||||
/// anyway. Never 0 — a blank that waits for nothing is the ordering
|
||||
/// bug this field exists to close.
|
||||
lock_ack_budget_secs: Option<u64>,
|
||||
/// Seconds; `Some(0)` means never blank an unlocked session here.
|
||||
unlocked_blank_after_secs: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ struct Daemon {
|
|||
/// its generation is still current (a restarted shell supersedes).
|
||||
heartbeat_gen: u64,
|
||||
shell_alive: bool,
|
||||
/// Write handle to the live shell's heartbeat connection, so the authority
|
||||
/// can push a directive down it instead of only answering requests. The
|
||||
/// shell owns the rich lock surface in steady state; this is how the
|
||||
/// daemon that owns the *decision* reaches the process that owns the
|
||||
/// *surface*.
|
||||
shell_directives: Option<UnixStream>,
|
||||
pam_service: String,
|
||||
/// The unified device state machine. The single authority for device
|
||||
/// power state — idle, lock, doze, sleep. All actors route through it.
|
||||
|
|
@ -96,6 +102,7 @@ pub fn run(pam_service: String, initial_lock: bool, socket_path: &Path) -> Resul
|
|||
controller: None,
|
||||
heartbeat_gen: 0,
|
||||
shell_alive: false,
|
||||
shell_directives: None,
|
||||
pam_service,
|
||||
device_state: DeviceStateMachine::new(),
|
||||
}),
|
||||
|
|
@ -171,6 +178,18 @@ fn spawn_idle_watcher(shared: &Arc<Shared>) {
|
|||
/// 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) {
|
||||
// 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
|
||||
// the rich lock surface in steady state and executes the directive, and
|
||||
// sessiond takes the lock itself when no shell is alive to do it. Either
|
||||
// way the decision is the daemon's — doctrine §11, the session authority
|
||||
// and the binary authority are the same authority, and it is not the UID
|
||||
// that makes it one (§1: everything running as you is you).
|
||||
if action == Action::Lock {
|
||||
request_session_lock(shared);
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -178,6 +197,7 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
|||
Action::Dim => ("brightnessctl", vec!["-s", "set", "10"], "panel-dim"),
|
||||
Action::Restore => (UNDIM_EXECUTOR, vec![], "panel-restore"),
|
||||
Action::Blank => (DPMS_EXECUTOR, vec!["off"], "panel-off"),
|
||||
Action::Lock => unreachable!("handled above; the lock is not a shell command"),
|
||||
};
|
||||
|
||||
match std::process::Command::new(program).args(&args).status() {
|
||||
|
|
@ -209,6 +229,67 @@ fn execute(shared: &Arc<Shared>, action: Action) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Act on the machine's decision that the session must lock before the panel
|
||||
/// goes dark.
|
||||
///
|
||||
/// Two paths, one authority. While a live shell owns steady state it holds the
|
||||
/// rich lock surface, so the directive goes down the heartbeat connection and
|
||||
/// the shell executes it — the same relationship §6 of `DEVICE-STATE-MACHINE.md`
|
||||
/// gives the DPMS executor ("receives commands from the unified state machine,
|
||||
/// not from independent actors"). With no shell alive there is nothing to
|
||||
/// delegate to and sessiond raises its own surface.
|
||||
///
|
||||
/// A failure here is never swallowed. If the directive cannot be written the
|
||||
/// daemon takes the lock itself rather than letting the pending blank time out
|
||||
/// into a dark unlocked screen, and the attempt is recorded either way.
|
||||
fn request_session_lock(shared: &Arc<Shared>) {
|
||||
let directive = serde_json::json!({ "directive": "lock", "why": "blank-requires-lock" });
|
||||
let mut line = directive.to_string();
|
||||
line.push('\n');
|
||||
|
||||
let mut d = shared.lock();
|
||||
if d.shell_alive {
|
||||
if let Some(conn) = d.shell_directives.as_mut() {
|
||||
match conn.write_all(line.as_bytes()).and_then(|_| conn.flush()) {
|
||||
Ok(()) => {
|
||||
info!("[device-state] lock directive sent to the shell");
|
||||
d.device_state.record_decision(
|
||||
"lock-directive",
|
||||
serde_json::json!({ "target": "shell" }),
|
||||
"authority told the shell to raise its lock surface",
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("could not push lock directive to the shell: {e}");
|
||||
d.device_state.record_error(
|
||||
"device-state",
|
||||
"lock-directive",
|
||||
&e.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("shell is alive but no directive channel — taking the lock here");
|
||||
d.device_state.record_error(
|
||||
"device-state",
|
||||
"lock-directive",
|
||||
"shell registered without a directive channel",
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(d);
|
||||
|
||||
if !spawn_lock_session(shared) {
|
||||
warn!("could not start a lock session for a pending blank");
|
||||
shared.lock().device_state.record_error(
|
||||
"device-state",
|
||||
"lock-for-blank",
|
||||
"no lock session could be started",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
|
|
@ -344,7 +425,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
|||
continue;
|
||||
}
|
||||
let response = match serde_json::from_str::<Request>(line) {
|
||||
Ok(req) => handle_request(req, &shared, &mut heartbeat),
|
||||
Ok(req) => handle_request(req, &shared, Some(&writer), &mut heartbeat),
|
||||
Err(e) => refusal(&format!("malformed request: {e}")),
|
||||
};
|
||||
if respond(&mut writer, response).is_err() {
|
||||
|
|
@ -358,6 +439,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
|||
let mut d = shared.lock();
|
||||
if d.heartbeat_gen == gen && d.shell_alive {
|
||||
d.shell_alive = false;
|
||||
d.shell_directives = None;
|
||||
let holding = d.controller.is_some();
|
||||
drop(d);
|
||||
warn!("shell heartbeat lost — locking the session");
|
||||
|
|
@ -371,6 +453,7 @@ fn handle_connection(stream: UnixStream, shared: Arc<Shared>) {
|
|||
fn handle_request(
|
||||
req: Request,
|
||||
shared: &Arc<Shared>,
|
||||
directives: Option<&UnixStream>,
|
||||
heartbeat: &mut Option<u64>,
|
||||
) -> serde_json::Value {
|
||||
match req {
|
||||
|
|
@ -396,6 +479,7 @@ fn handle_request(
|
|||
}
|
||||
d.heartbeat_gen += 1;
|
||||
d.shell_alive = true;
|
||||
d.shell_directives = directives.and_then(|s| s.try_clone().ok());
|
||||
let gen = d.heartbeat_gen;
|
||||
*heartbeat = Some(gen);
|
||||
|
||||
|
|
@ -550,6 +634,9 @@ fn handle_request(
|
|||
"dim_grace_secs": p.dim_grace.as_secs(),
|
||||
"evidence_ttl_secs": p.evidence_ttl.as_secs(),
|
||||
"dim_warning": p.dim_warning,
|
||||
"lock_ack_budget_secs": p.lock_ack_budget.as_secs(),
|
||||
"unlocked_blank_after_secs":
|
||||
p.unlocked_blank_after.map(|v| v.as_secs()).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
Request::SetPolicy {
|
||||
|
|
@ -558,6 +645,8 @@ fn handle_request(
|
|||
dim_grace_secs,
|
||||
evidence_ttl_secs,
|
||||
dim_warning,
|
||||
lock_ack_budget_secs,
|
||||
unlocked_blank_after_secs,
|
||||
} => {
|
||||
// 0 means "never" — the desk-clock case, and iOS's Auto-Lock has
|
||||
// the same option for the same reason.
|
||||
|
|
@ -585,6 +674,18 @@ fn handle_request(
|
|||
if let Some(v) = dim_warning {
|
||||
p.dim_warning = v;
|
||||
}
|
||||
// A zero ack budget would blank before the lock could possibly
|
||||
// land, which is precisely the ordering this closes. Refuse it.
|
||||
if let Some(v) = lock_ack_budget_secs {
|
||||
if v == 0 {
|
||||
drop(d);
|
||||
return refusal("lock_ack_budget_secs must be at least 1");
|
||||
}
|
||||
p.lock_ack_budget = Duration::from_secs(v);
|
||||
}
|
||||
if let Some(v) = unlocked_blank_after_secs {
|
||||
p.unlocked_blank_after = as_budget(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":
|
||||
|
|
@ -592,6 +693,9 @@ fn handle_request(
|
|||
"dim_grace_secs": p.dim_grace.as_secs(),
|
||||
"evidence_ttl_secs": p.evidence_ttl.as_secs(),
|
||||
"dim_warning": p.dim_warning,
|
||||
"lock_ack_budget_secs": p.lock_ack_budget.as_secs(),
|
||||
"unlocked_blank_after_secs":
|
||||
p.unlocked_blank_after.map(|v| v.as_secs()).unwrap_or(0),
|
||||
});
|
||||
info!("[device-state] policy set: {applied}");
|
||||
d.device_state
|
||||
|
|
@ -621,6 +725,7 @@ mod tests {
|
|||
controller: None,
|
||||
heartbeat_gen: 0,
|
||||
shell_alive: false,
|
||||
shell_directives: None,
|
||||
pam_service: "test".to_string(),
|
||||
device_state: DeviceStateMachine::new(),
|
||||
}),
|
||||
|
|
@ -635,6 +740,7 @@ mod tests {
|
|||
let first = handle_request(
|
||||
Request::ShellReady,
|
||||
&shared,
|
||||
None,
|
||||
&mut authority_heartbeat,
|
||||
);
|
||||
assert_eq!(first["ok"], true);
|
||||
|
|
@ -644,6 +750,7 @@ mod tests {
|
|||
let second = handle_request(
|
||||
Request::ShellReady,
|
||||
&shared,
|
||||
None,
|
||||
&mut utility_heartbeat,
|
||||
);
|
||||
assert_eq!(second["ok"], false);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import QtQuick
|
|||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs
|
||||
import qs.modules.common.functions
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
|
@ -140,6 +141,15 @@ Singleton {
|
|||
console.log("[sessiond-bridge] unparseable reply: " + message);
|
||||
return;
|
||||
}
|
||||
// Not every line is a reply. The authority pushes directives
|
||||
// down this connection: it owns the decision, the shell owns
|
||||
// the surface. A blank that needs a lock first arrives here
|
||||
// (LOCK-DPMS-LESSONS §1 — lock, then off), and the daemon is
|
||||
// holding the panel dark until this is answered.
|
||||
if (reply.directive !== undefined) {
|
||||
root.handleDirective(reply);
|
||||
return;
|
||||
}
|
||||
if (root.pendingReady) {
|
||||
// The only request we await a response for.
|
||||
const cb = root.pendingReady;
|
||||
|
|
@ -159,6 +169,25 @@ Singleton {
|
|||
}
|
||||
}
|
||||
|
||||
// Carry out an authority directive. The shell is the executor here, not a
|
||||
// peer deciding whether it agrees: sessiond is the session authority and
|
||||
// it has already withheld the panel waiting for this.
|
||||
//
|
||||
// Unknown directives are LOUD. A newer daemon asking for something this
|
||||
// shell cannot do is a real divergence, and silently dropping it would
|
||||
// leave the daemon waiting out its ack budget and then blanking unlocked.
|
||||
function handleDirective(msg) {
|
||||
if (msg.directive === "lock") {
|
||||
console.log("[sessiond-bridge] authority directive: lock ("
|
||||
+ (msg.why || "no reason given") + ")");
|
||||
Session.lock();
|
||||
return;
|
||||
}
|
||||
console.error("[sessiond-bridge] UNKNOWN authority directive: "
|
||||
+ JSON.stringify(msg)
|
||||
+ " — this shell is older than the daemon driving it");
|
||||
}
|
||||
|
||||
// The compositor acknowledged OUR lock surface — tell sessiond the
|
||||
// handoff is complete. Gated on secure, not the request, per doctrine.
|
||||
Connections {
|
||||
|
|
|
|||
Loading…
Reference in a new issue