From b2774c8aac472c637de2af6bc13334db1f2dde52 Mon Sep 17 00:00:00 2001 From: Fimeg Date: Sun, 9 Aug 2026 19:31:33 -0400 Subject: [PATCH] sessiond: a power seam that cannot report an outcome it does not have Power is the one irreversible transition and it bypassed the authority entirely: both menus called Session.poweroff() straight into systemctl, so there was no Action, no trail entry and no refusal path. The daemon gains the verb and the shell gains a transport for it. The request rides its own short-lived connection, never the heartbeat, because suspend can hold the daemon's handler until resume while the heartbeat must stay free to carry locked_ack and the EOF that means shell death. A disconnect after the request was sent reports outcome_unknown rather than guessing. Session.qml keeps the legacy executor until this is exercised on hardware. Unbuilt and unrun; CI is the first reader. --- src/sessiond/device_state.rs | 109 ++++- src/sessiond/protocol.rs | 109 ++++- src/sessiond/server.rs | 372 +++++++++++++++++- .../quickshell/services/SessiondBridge.qml | 140 +++++++ 4 files changed, 721 insertions(+), 9 deletions(-) diff --git a/src/sessiond/device_state.rs b/src/sessiond/device_state.rs index a87fe05..27279d2 100644 --- a/src/sessiond/device_state.rs +++ b/src/sessiond/device_state.rs @@ -22,8 +22,8 @@ use tracing::{info, warn}; use crate::sessiond::bearer::{Bearer, BearerEvidence}; use crate::sessiond::protocol::{ - Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture, - UsbMode, + Button, ButtonEdge, ButtonGesture, InputTrigger, PowerVerb, SensorSource, SensorValue, + TouchGesture, UsbMode, }; /// How long a locked, lit panel waits for input before it blanks. @@ -440,6 +440,15 @@ pub enum Action { /// changes what every other peripheral means. Casey, 2026-08-05: "that /// whole power usb state probably belongs in the power switch options". PowerMenu, + /// End the session's power state: off, restart, or asleep. + /// + /// The counterpart to every other row here, on the one transition that + /// cannot be walked back. It arrives as an `Action` for the same reason + /// `Volume` and `WindowSheet` do — §12 forbids the small actor that reads a + /// signal and calls a tool — but the stakes are the argument's strongest + /// case rather than its weakest: a poweroff executed outside this table + /// leaves no entry anywhere, and there is no later moment to notice. + Power(PowerVerb), /// Change the port's gadget posture through usb-signaller. /// /// The mode daemon is the mechanism and souveraine-upower is its adjacent @@ -1443,6 +1452,14 @@ pub struct DeviceStateMachine { /// 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, + /// The power verb already in flight, if any. + /// + /// Held from admission until the mechanism returns. A second request is + /// refused rather than executed so that "poweroff, then reboot" cannot + /// leave the machine having asked for both. The latch is not sleep state: + /// a zero exit only says logind accepted the command, never that the + /// physical transition was observed. + power_requested: Option, /// Per-source freshness, for the staleness rule. evidence_seen: EvidenceSeen, /// Per-source health — whether evidence is arriving at all, which is a @@ -1582,6 +1599,7 @@ impl DeviceStateMachine { forensic: ForensicLog::new(), panel_on: true, idle_since: None, + power_requested: None, evidence_seen: EvidenceSeen::default(), source_health: SourceHealthTable::default(), started_at: Instant::now(), @@ -2503,6 +2521,53 @@ impl DeviceStateMachine { vec![Action::UsbMode(mode)] } + /// Ask to power the machine off, restart it, or put it to sleep. + /// + /// Not gated on the lock. Powering off a locked phone is a thing people do + /// deliberately, and a refusal here would send them to the hardware button + /// they are already holding — the same argument `PowerMenu.qml` makes for + /// showing the sheet while locked. + /// + /// No state transition either. `Suspending` is entered from logind's + /// `PrepareForSleep`, which is the event that actually means it; setting it + /// here would be a second writer to the state the protocol owns, and a + /// suspend that logind then refuses would leave the machine believing it + /// had gone to sleep (doctrine §4). + pub fn request_power(&mut self, verb: PowerVerb, why: &str) -> Result, String> { + if let Some(in_flight) = self.power_requested { + let refusal = format!("{} is already in flight", in_flight.as_str()); + self.record_decision( + "power-request-refused", + serde_json::json!({ "verb": verb.as_str(), "in_flight": in_flight.as_str() }), + &refusal, + ); + return Err(refusal); + } + self.power_requested = Some(verb); + self.record_decision( + "power-request", + serde_json::json!({ "verb": verb.as_str() }), + why, + ); + Ok(vec![Action::Power(verb)]) + } + + /// Release the in-flight latch after the mechanism returned. + /// + /// This is deliberately symmetric across success and failure. `systemctl + /// suspend` and `hibernate` return to the same daemon after resume, while a + /// poweroff/reboot command can return zero before shutdown finishes or is + /// later cancelled. Leaving the latch set on any return would turn an + /// accepted request into a permanent refusal for the rest of the uptime. + /// A stale completion cannot clear a newer request for a different verb. + pub fn power_request_finished(&mut self, verb: PowerVerb) -> bool { + if self.power_requested == Some(verb) { + self.power_requested = None; + return true; + } + false + } + /// 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. /// @@ -3175,6 +3240,10 @@ impl DeviceStateMachine { "idle_secs": self.idle_secs(), "dimmed": self.dimmed, "blank_requested": self.blank_requested, + // Command admission, not physical state. Actual sleep/shutdown + // state is learned from logind events rather than inferred from a + // request or a successful systemctl exit. + "power_requested": self.power_requested.map(PowerVerb::as_str), // Freshness means "reported within the TTL", not "has ever been // heard from". The `is_some()` version read as fresh for the whole // life of the daemon, because `expire_stale_evidence` only clears @@ -3466,6 +3535,42 @@ mod tests { assert_eq!(actions, vec![Action::PowerMenu]); } + #[test] + fn a_power_request_is_latched_reported_and_refuses_a_second_verb() { + let mut sm = DeviceStateMachine::new(); + + assert_eq!( + sm.request_power(PowerVerb::Suspend, "test request") + .unwrap(), + vec![Action::Power(PowerVerb::Suspend)] + ); + assert_eq!(sm.to_ipc_json()["power_requested"], "suspend"); + + let refusal = sm + .request_power(PowerVerb::Reboot, "racing request") + .unwrap_err(); + assert_eq!(refusal, "suspend is already in flight"); + assert_eq!(sm.to_ipc_json()["power_requested"], "suspend"); + } + + #[test] + fn only_the_matching_terminal_outcome_releases_a_power_request() { + let mut sm = DeviceStateMachine::new(); + sm.request_power(PowerVerb::Hibernate, "test request") + .unwrap(); + + assert!(!sm.power_request_finished(PowerVerb::Poweroff)); + assert_eq!(sm.to_ipc_json()["power_requested"], "hibernate"); + + assert!(sm.power_request_finished(PowerVerb::Hibernate)); + assert_eq!(sm.to_ipc_json()["power_requested"], serde_json::Value::Null); + assert_eq!( + sm.request_power(PowerVerb::Poweroff, "retry after return") + .unwrap(), + vec![Action::Power(PowerVerb::Poweroff)] + ); + } + #[test] fn a_three_finger_tap_on_the_wallpaper_raises_nothing() { // The no-window case, decided rather than discovered on device: a tap diff --git a/src/sessiond/protocol.rs b/src/sessiond/protocol.rs index 1c35e1f..08b0ec7 100644 --- a/src/sessiond/protocol.rs +++ b/src/sessiond/protocol.rs @@ -64,9 +64,9 @@ pub enum RefusalCode { /// it. This is the one a chain most often wants: it means *not now*, and /// the state that forbade it is worth reading before deciding what next. RefusedByState, - /// The caller may not do this. Reserved — nothing issues it yet, because - /// caller identity is uid 1000 for everything (audit P3). It exists so - /// that when capability tokens land, callers already branch on it. + /// The caller may not do this. Power policy issues it when login1 answers + /// `no`; capability tokens will eventually make it caller-specific across + /// the rest of the surface too. NotPermitted, /// A dependency this op needs is absent — no compositor, no PAM stack, no /// sensor. Not the caller's fault and possibly transient. @@ -255,6 +255,17 @@ pub const VERBS: &[VerbDoc] = &[ refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable], example: r#"{"op":"set_usb_mode","mode":"hid"}"#, }, + VerbDoc { + op: "power", + mutates: true, + summary: "power the machine off, restart it, or put it to sleep", + refuses: &[ + RefusalCode::RefusedByState, + RefusalCode::NotPermitted, + RefusalCode::Unavailable, + ], + example: r#"{"op":"power","verb":"poweroff"}"#, + }, ]; /// A request plus the caller's declared intent. @@ -433,6 +444,61 @@ pub enum Request { /// recycled the tunnel 652 times. bearer_settle_secs: Option, }, + /// End the session's power state: off, restart, or asleep. + /// + /// The last device verb that was not here. Both menu surfaces called a QML + /// singleton that ran `systemctl poweroff` itself, which is §12's eighth + /// blind actor on the one transition that cannot be undone or observed + /// after the fact — no Action, no executor, no trail entry. + /// + /// logind keeps what it already owns. It answers `CanPowerOff` and carries + /// out the verb; doctrine §4 says suspend goes through logind and never + /// `/sys/power/state`, and that is unchanged. What moves is the decision to + /// ask and the record of its outcome. Actual sleep state remains logind + /// evidence; this request does not claim that a transition happened. + Power { verb: PowerVerb }, +} + +/// The power transitions sessiond will carry out. +/// +/// `logout` is deliberately absent: it ends a *session*, not a device power +/// state, and the machine has no cell for it. It stays the shell's, which is +/// also the only actor that knows what it would be tearing down. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PowerVerb { + Poweroff, + Reboot, + Suspend, + Hibernate, +} + +impl PowerVerb { + /// systemctl owns these verbs, not loginctl — `loginctl poweroff` exits 1 + /// with "Unknown command verb". Preferring loginctl silently broke every + /// power button on the device for a day (2026-07-20). + pub fn as_systemctl(self) -> &'static str { + match self { + Self::Poweroff => "poweroff", + Self::Reboot => "reboot", + Self::Suspend => "suspend", + Self::Hibernate => "hibernate", + } + } + + pub fn as_str(self) -> &'static str { + self.as_systemctl() + } + + /// The logind capability that gates this verb. + pub fn logind_capability(self) -> &'static str { + match self { + Self::Poweroff => "CanPowerOff", + Self::Reboot => "CanReboot", + Self::Suspend => "CanSuspend", + Self::Hibernate => "CanHibernate", + } + } } /// USB postures sessiond is prepared to own today. @@ -669,14 +735,47 @@ mod tests { // And the other direction: every variant must be advertised. Bump this // deliberately when a verb is added, having added its VerbDoc. - // 19 since USB posture became a read plus a request (2026-08-07). + // 20 since device power became a session-authority verb (2026-08-09). assert_eq!( VERBS.len(), - 19, + 20, "a Request variant was added without a VerbDoc" ); } + #[test] + fn every_power_verb_survives_the_wire_and_maps_to_its_mechanism() { + let cases = [ + ("poweroff", PowerVerb::Poweroff, "CanPowerOff"), + ("reboot", PowerVerb::Reboot, "CanReboot"), + ("suspend", PowerVerb::Suspend, "CanSuspend"), + ("hibernate", PowerVerb::Hibernate, "CanHibernate"), + ]; + + for (wire, expected, capability) in cases { + let raw = format!(r#"{{"op":"power","verb":"{wire}"}}"#); + let request: Request = serde_json::from_str(&raw).unwrap(); + assert!(matches!(&request, Request::Power { verb } if *verb == expected)); + assert_eq!(expected.as_str(), wire); + assert_eq!(expected.as_systemctl(), wire); + assert_eq!(expected.logind_capability(), capability); + assert_eq!(serde_json::to_string(&request).unwrap(), raw); + } + } + + #[test] + fn power_advertises_every_refusal_its_policy_can_make() { + let power = VERBS.iter().find(|verb| verb.op == "power").unwrap(); + assert_eq!( + power.refuses, + &[ + RefusalCode::RefusedByState, + RefusalCode::NotPermitted, + RefusalCode::Unavailable, + ] + ); + } + #[test] fn the_envelope_is_backward_compatible() { // Every line written before intent existed must still parse, or the diff --git a/src/sessiond/server.rs b/src/sessiond/server.rs index adf0a14..8d292a4 100644 --- a/src/sessiond/server.rs +++ b/src/sessiond/server.rs @@ -25,8 +25,8 @@ use crate::sessiond::idle; use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome}; use crate::sessiond::lockhint; use crate::sessiond::protocol::{ - Envelope, InputTrigger, Phase, RefusalCode, Request, SensorSource, SensorValue, UsbMode, - LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES, VERBS, + Envelope, InputTrigger, Phase, PowerVerb, RefusalCode, Request, SensorSource, SensorValue, + UsbMode, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES, VERBS, }; /// How often the machine's clock runs. One second is fine: the shortest @@ -416,6 +416,7 @@ fn execute(shared: &Arc, action: Action) { "power-menu-open", ), Action::UsbMode(_) => unreachable!("handled above; expressed through usb-signaller"), + Action::Power(_) => unreachable!("power requests use the result-bearing power executor"), Action::Blank => { // The invariant, enforced where it cannot be reasoned around: the // panel does not go dark unless logind says this session is @@ -924,10 +925,221 @@ fn run_executor(shared: &Arc, program: &str, args: &[&str], label: &str, } } +/// Every answer login1 documents for its `Can*` power methods. +/// +/// These are deliberately not flattened to a bool. `no` is a permission +/// refusal, `na` is missing mechanism, and an inhibitor is current machine +/// state; callers composing verbs need those to remain different answers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PowerCapability { + Yes, + No, + Challenge, + NotAvailable, + Inhibited, + InhibitorBlocked, + ChallengeInhibitorBlocked, +} + +impl PowerCapability { + fn parse_busctl(text: &str) -> Result { + // busctl prints `s "challenge"`. Take the second field and strip the + // quotes here rather than in the shell — an earlier version parsed it + // behind two escaping layers, matched a backslash-quote busctl never + // emits, and left every capability reading "unknown" on the phone. + let answer = text + .split_whitespace() + .nth(1) + .unwrap_or_default() + .trim_matches('"'); + match answer { + "yes" => Ok(Self::Yes), + "no" => Ok(Self::No), + "challenge" => Ok(Self::Challenge), + "na" => Ok(Self::NotAvailable), + "inhibited" => Ok(Self::Inhibited), + "inhibitor-blocked" => Ok(Self::InhibitorBlocked), + "challenge-inhibitor-blocked" => Ok(Self::ChallengeInhibitorBlocked), + _ => anyhow::bail!("unrecognised login1 power capability {answer:?}"), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::Yes => "yes", + Self::No => "no", + Self::Challenge => "challenge", + Self::NotAvailable => "na", + Self::Inhibited => "inhibited", + Self::InhibitorBlocked => "inhibitor-blocked", + Self::ChallengeInhibitorBlocked => "challenge-inhibitor-blocked", + } + } + + fn refusal_code(self) -> Option { + match self { + Self::Yes | Self::Challenge => None, + Self::No => Some(RefusalCode::NotPermitted), + Self::NotAvailable => Some(RefusalCode::Unavailable), + Self::Inhibited | Self::InhibitorBlocked | Self::ChallengeInhibitorBlocked => { + Some(RefusalCode::RefusedByState) + } + } + } +} + +/// Injectable boundary around the two process calls a power request needs. +/// Unit tests use a fake implementation, so no test can invoke a real power +/// mechanism even if it runs on a developer's live session. +trait PowerBackend { + fn capability(&self, verb: PowerVerb) -> Result; + fn execute(&self, verb: PowerVerb) -> Result<()>; +} + +struct SystemPowerBackend; + +impl PowerBackend for SystemPowerBackend { + fn capability(&self, verb: PowerVerb) -> Result { + let capability = verb.logind_capability(); + let output = std::process::Command::new("busctl") + .args([ + "--system", + "call", + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + capability, + ]) + .output() + .with_context(|| format!("running busctl {capability}"))?; + if !output.status.success() { + anyhow::bail!( + "busctl {capability} exited {}", + output.status.code().unwrap_or(-1) + ); + } + PowerCapability::parse_busctl(&String::from_utf8_lossy(&output.stdout)) + } + + fn execute(&self, verb: PowerVerb) -> Result<()> { + let status = std::process::Command::new("systemctl") + .arg(verb.as_systemctl()) + .status() + .with_context(|| format!("running systemctl {}", verb.as_systemctl()))?; + if !status.success() { + anyhow::bail!( + "systemctl {} exited {}", + verb.as_systemctl(), + status.code().unwrap_or(-1) + ); + } + Ok(()) + } +} + fn refuse(code: RefusalCode, reason: &str) -> serde_json::Value { serde_json::json!({ "ok": false, "code": code.as_str(), "reason": reason }) } +fn power_label(verb: PowerVerb) -> &'static str { + match verb { + PowerVerb::Poweroff => "power-off", + PowerVerb::Reboot => "power-reboot", + PowerVerb::Suspend => "power-suspend", + PowerVerb::Hibernate => "power-hibernate", + } +} + +/// Admit, gate, execute, and finish one power request. +/// +/// The latch is taken before the live capability probe, so two callers cannot +/// both pass policy and race different terminal actions. Every path after +/// admission releases it. A zero exit is reported as `accepted`: it proves the +/// mechanism returned success, not that shutdown or sleep was observed. +fn handle_power_request( + shared: &Arc, + verb: PowerVerb, + backend: &B, +) -> serde_json::Value { + let actions = { + let mut d = shared.lock(); + d.device_state + .request_power(verb, "power requested through the session authority") + }; + let action_verb = match actions { + Ok(actions) if actions == vec![Action::Power(verb)] => verb, + Ok(actions) => { + let reason = format!("power admission returned unexpected actions: {actions:?}"); + let mut d = shared.lock(); + d.device_state.power_request_finished(verb); + d.device_state + .record_error("device-state", power_label(verb), &reason); + return refuse(RefusalCode::Unavailable, &reason); + } + Err(reason) => return refuse(RefusalCode::RefusedByState, &reason), + }; + + let capability = match backend.capability(action_verb) { + Ok(capability) => capability, + Err(error) => { + let reason = format!( + "could not ask logind about {}: {error:#}", + action_verb.as_str() + ); + let mut d = shared.lock(); + d.device_state.power_request_finished(action_verb); + d.device_state + .record_error("device-state", "power-capability", &reason); + return refuse(RefusalCode::Unavailable, &reason); + } + }; + + if let Some(code) = capability.refusal_code() { + let reason = format!( + "logind says {}: {}", + action_verb.logind_capability(), + capability.as_str() + ); + let mut d = shared.lock(); + d.device_state.power_request_finished(action_verb); + d.device_state.record_decision( + "power-request-refused", + serde_json::json!({ + "verb": action_verb.as_str(), + "capability": capability.as_str(), + }), + &reason, + ); + return refuse(code, &reason); + } + + match backend.execute(action_verb) { + Ok(()) => { + let mut d = shared.lock(); + d.device_state.power_request_finished(action_verb); + d.device_state.record_decision( + "power-command-accepted", + serde_json::json!({ "verb": action_verb.as_str() }), + "the power mechanism returned success; physical state is not inferred", + ); + serde_json::json!({ + "ok": true, + "verb": action_verb.as_str(), + "status": "accepted", + }) + } + Err(error) => { + let reason = error.to_string(); + warn!("power command {} failed: {reason}", action_verb.as_str()); + let mut d = shared.lock(); + d.device_state.power_request_finished(action_verb); + d.device_state + .record_error("device-state", power_label(action_verb), &reason); + refuse(RefusalCode::Unavailable, &reason) + } + } +} + fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> { writer.write_all(value.to_string().as_bytes())?; writer.write_all(b"\n")?; @@ -1482,6 +1694,7 @@ fn handle_request( ), } } + Request::Power { verb } => handle_power_request(shared, verb, &SystemPowerBackend), Request::Bearer => { let d = shared.lock(); let b = &d.device_state.bearer; @@ -1668,6 +1881,7 @@ fn handle_request( #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; fn idle_shared() -> Arc { Arc::new(Shared { @@ -1686,6 +1900,160 @@ mod tests { }) } + struct FakePowerBackend { + capability: std::result::Result, + execution: std::result::Result<(), &'static str>, + capability_calls: Cell, + execution_calls: Cell, + inspect_latch: Option<(Arc, PowerVerb)>, + saw_latch_before_probe: Cell, + } + + impl FakePowerBackend { + fn new( + capability: std::result::Result, + execution: std::result::Result<(), &'static str>, + ) -> Self { + Self { + capability, + execution, + capability_calls: Cell::new(0), + execution_calls: Cell::new(0), + inspect_latch: None, + saw_latch_before_probe: Cell::new(false), + } + } + + fn inspecting(mut self, shared: &Arc, verb: PowerVerb) -> Self { + self.inspect_latch = Some((Arc::clone(shared), verb)); + self + } + } + + impl PowerBackend for FakePowerBackend { + fn capability(&self, _verb: PowerVerb) -> Result { + self.capability_calls.set(self.capability_calls.get() + 1); + if let Some((shared, expected)) = &self.inspect_latch { + let value = shared.lock().device_state.to_ipc_json()["power_requested"].clone(); + self.saw_latch_before_probe + .set(value == serde_json::json!(expected.as_str())); + } + self.capability.map_err(|reason| anyhow::anyhow!(reason)) + } + + fn execute(&self, _verb: PowerVerb) -> Result<()> { + self.execution_calls.set(self.execution_calls.get() + 1); + self.execution.map_err(|reason| anyhow::anyhow!(reason)) + } + } + + #[test] + fn login1_power_capability_parser_names_every_documented_answer() { + let cases = [ + ("yes", PowerCapability::Yes), + ("no", PowerCapability::No), + ("challenge", PowerCapability::Challenge), + ("na", PowerCapability::NotAvailable), + ("inhibited", PowerCapability::Inhibited), + ("inhibitor-blocked", PowerCapability::InhibitorBlocked), + ( + "challenge-inhibitor-blocked", + PowerCapability::ChallengeInhibitorBlocked, + ), + ]; + + for (wire, expected) in cases { + assert_eq!( + PowerCapability::parse_busctl(&format!("s \"{wire}\"\n")).unwrap(), + expected + ); + assert_eq!(expected.as_str(), wire); + } + assert!(PowerCapability::parse_busctl("").is_err()); + assert!(PowerCapability::parse_busctl("s \"future-answer\"").is_err()); + } + + #[test] + fn yes_and_challenge_execute_after_the_latch_is_taken() { + for capability in [PowerCapability::Yes, PowerCapability::Challenge] { + let shared = idle_shared(); + let backend = FakePowerBackend::new(Ok(capability), Ok(())) + .inspecting(&shared, PowerVerb::Suspend); + + let reply = handle_power_request(&shared, PowerVerb::Suspend, &backend); + + assert_eq!(reply["ok"], true); + assert_eq!(reply["status"], "accepted"); + assert_eq!(reply["verb"], "suspend"); + assert!( + reply.get("state").is_none(), + "request must not claim sleep state" + ); + assert!(backend.saw_latch_before_probe.get()); + assert_eq!(backend.capability_calls.get(), 1); + assert_eq!(backend.execution_calls.get(), 1); + assert_eq!( + shared.lock().device_state.to_ipc_json()["power_requested"], + serde_json::Value::Null + ); + } + } + + #[test] + fn capability_refusals_keep_permission_state_and_availability_distinct() { + let cases = [ + (PowerCapability::No, "not_permitted"), + (PowerCapability::NotAvailable, "unavailable"), + (PowerCapability::Inhibited, "refused_by_state"), + (PowerCapability::InhibitorBlocked, "refused_by_state"), + ( + PowerCapability::ChallengeInhibitorBlocked, + "refused_by_state", + ), + ]; + + for (capability, expected_code) in cases { + let shared = idle_shared(); + let backend = FakePowerBackend::new(Ok(capability), Ok(())); + + let reply = handle_power_request(&shared, PowerVerb::Poweroff, &backend); + + assert_eq!(reply["ok"], false); + assert_eq!(reply["code"], expected_code); + assert_eq!(backend.capability_calls.get(), 1); + assert_eq!(backend.execution_calls.get(), 0); + assert_eq!( + shared.lock().device_state.to_ipc_json()["power_requested"], + serde_json::Value::Null + ); + } + } + + #[test] + fn probe_and_command_errors_are_honest_and_release_the_latch_for_retry() { + let shared = idle_shared(); + let probe_failure = FakePowerBackend::new(Err("busctl unavailable"), Ok(())); + let reply = handle_power_request(&shared, PowerVerb::Reboot, &probe_failure); + assert_eq!(reply["ok"], false); + assert_eq!(reply["code"], "unavailable"); + assert_eq!(probe_failure.execution_calls.get(), 0); + + let command_failure = + FakePowerBackend::new(Ok(PowerCapability::Yes), Err("systemctl reboot exited 1")); + let reply = handle_power_request(&shared, PowerVerb::Reboot, &command_failure); + assert_eq!(reply["ok"], false); + assert_eq!(reply["code"], "unavailable"); + assert_eq!(command_failure.execution_calls.get(), 1); + + let retry = FakePowerBackend::new(Ok(PowerCapability::Yes), Ok(())); + let reply = handle_power_request(&shared, PowerVerb::Reboot, &retry); + assert_eq!( + reply["ok"], true, + "both terminal errors must release admission" + ); + assert_eq!(reply["status"], "accepted"); + } + #[test] fn second_shell_ready_cannot_steal_authority_lease() { let shared = idle_shared(); diff --git a/surfaces/quickshell/services/SessiondBridge.qml b/surfaces/quickshell/services/SessiondBridge.qml index 0265d57..2aea3da 100644 --- a/surfaces/quickshell/services/SessiondBridge.qml +++ b/surfaces/quickshell/services/SessiondBridge.qml @@ -55,6 +55,146 @@ Singleton { console.log("[sessiond-bridge] inactive outside authority scope") } + // One short-lived request connection for power authority. Never put this + // on `sock`: suspend can keep the daemon's synchronous request handler + // occupied until resume, while the heartbeat connection must remain free + // to carry locked_ack, directives and the EOF that means shell death. + // + // This is deliberately only the transport seam for now. Session.qml keeps + // its legacy executor until the packaged daemon and its polkit subject have + // been proven on each target. A locally accepted request returns pending; + // the callback and powerFinished carry the daemon's eventual verdict. + property var pendingPower: null + property int powerRequestSequence: 0 + signal powerFinished(string requestId, var reply) + + function requestPower(verb, callback) { + const powerVerb = String(verb ?? ""); + if (!["poweroff", "reboot", "suspend", "hibernate"].includes(powerVerb)) { + return { + ok: false, + code: "unsupported", + reason: "unsupported power verb: " + powerVerb + }; + } + if (root.pendingPower !== null) { + return { + ok: false, + code: "refused_by_state", + reason: "another power request is already in flight" + }; + } + + root.powerRequestSequence += 1; + const requestId = "power-" + Date.now() + "-" + root.powerRequestSequence; + root.pendingPower = { + requestId: requestId, + verb: powerVerb, + callback: typeof callback === "function" ? callback : null, + sent: false + }; + powerConnectTimeout.restart(); + powerSock.connected = true; + return { ok: true, status: "pending", request_id: requestId }; + } + + function _finishPower(reply) { + if (root.pendingPower === null) + return; + const pending = root.pendingPower; + root.pendingPower = null; + powerConnectTimeout.stop(); + + const result = {}; + for (const key in reply) + result[key] = reply[key]; + result.request_id = pending.requestId; + + // Clear our request before closing. The disconnect edge must not turn + // a parsed refusal/acceptance into a second outcome_unknown callback. + powerSock.connected = false; + root.powerFinished(pending.requestId, result); + if (pending.callback !== null) { + try { + pending.callback(result); + } catch (error) { + console.error("[sessiond-bridge] power callback failed: " + error); + } + } + } + + Timer { + id: powerConnectTimeout + interval: 1500 + repeat: false + onTriggered: { + if (root.pendingPower !== null && !root.pendingPower.sent) { + root._finishPower({ + ok: false, + code: "unavailable", + reason: "sessiond power socket unavailable" + }); + } + } + } + + Socket { + id: powerSock + path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock" + + onConnectionStateChanged: { + if (powerSock.connected && root.pendingPower !== null + && !root.pendingPower.sent) { + const pending = root.pendingPower; + root.pendingPower = { + requestId: pending.requestId, + verb: pending.verb, + callback: pending.callback, + sent: true + }; + powerConnectTimeout.stop(); + powerSock.write(JSON.stringify({ + op: "power", + verb: pending.verb + }) + "\n"); + powerSock.flush(); + } else if (!powerSock.connected && root.pendingPower !== null) { + const sent = root.pendingPower.sent; + root._finishPower(sent ? { + ok: false, + code: "outcome_unknown", + status: "outcome_unknown", + reason: "sessiond disconnected after the power request was sent" + } : { + ok: false, + code: "unavailable", + reason: "sessiond power socket unavailable" + }); + } + } + + parser: SplitParser { + splitMarker: "\n" + onRead: message => { + if (root.pendingPower === null) + return; + let reply; + try { + reply = JSON.parse(message); + } catch (error) { + root._finishPower({ + ok: false, + code: "outcome_unknown", + status: "outcome_unknown", + reason: "sessiond returned an unparseable power reply" + }); + return; + } + root._finishPower(reply); + } + } + } + // Announce the shell. cb(mustLock) fires exactly once: mustLock true // means sessiond was holding and the session IS locked — the shell must // raise its own lock surface immediately.