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.
This commit is contained in:
parent
c802f7db48
commit
b2774c8aac
4 changed files with 721 additions and 9 deletions
|
|
@ -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<Instant>,
|
||||
/// 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<PowerVerb>,
|
||||
/// 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<Vec<Action>, 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
|
||||
|
|
|
|||
|
|
@ -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<u64>,
|
||||
},
|
||||
/// 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
|
||||
|
|
|
|||
|
|
@ -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<Shared>, 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<Shared>, 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<Self> {
|
||||
// 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<RefusalCode> {
|
||||
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<PowerCapability>;
|
||||
fn execute(&self, verb: PowerVerb) -> Result<()>;
|
||||
}
|
||||
|
||||
struct SystemPowerBackend;
|
||||
|
||||
impl PowerBackend for SystemPowerBackend {
|
||||
fn capability(&self, verb: PowerVerb) -> Result<PowerCapability> {
|
||||
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<B: PowerBackend + ?Sized>(
|
||||
shared: &Arc<Shared>,
|
||||
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<Shared> {
|
||||
Arc::new(Shared {
|
||||
|
|
@ -1686,6 +1900,160 @@ mod tests {
|
|||
})
|
||||
}
|
||||
|
||||
struct FakePowerBackend {
|
||||
capability: std::result::Result<PowerCapability, &'static str>,
|
||||
execution: std::result::Result<(), &'static str>,
|
||||
capability_calls: Cell<usize>,
|
||||
execution_calls: Cell<usize>,
|
||||
inspect_latch: Option<(Arc<Shared>, PowerVerb)>,
|
||||
saw_latch_before_probe: Cell<bool>,
|
||||
}
|
||||
|
||||
impl FakePowerBackend {
|
||||
fn new(
|
||||
capability: std::result::Result<PowerCapability, &'static str>,
|
||||
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<Shared>, verb: PowerVerb) -> Self {
|
||||
self.inspect_latch = Some((Arc::clone(shared), verb));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl PowerBackend for FakePowerBackend {
|
||||
fn capability(&self, _verb: PowerVerb) -> Result<PowerCapability> {
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue