Watch
1
0
Fork
You've already forked souveraine
0

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:
Fimeg 2026-08-09 19:31:33 -04:00
commit b2774c8aac
4 changed files with 721 additions and 9 deletions

View file

@ -22,8 +22,8 @@ use tracing::{info, warn};
use crate::sessiond::bearer::{Bearer, BearerEvidence}; use crate::sessiond::bearer::{Bearer, BearerEvidence};
use crate::sessiond::protocol::{ use crate::sessiond::protocol::{
Button, ButtonEdge, ButtonGesture, InputTrigger, SensorSource, SensorValue, TouchGesture, Button, ButtonEdge, ButtonGesture, InputTrigger, PowerVerb, SensorSource, SensorValue,
UsbMode, TouchGesture, UsbMode,
}; };
/// How long a locked, lit panel waits for input before it blanks. /// 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 /// changes what every other peripheral means. Casey, 2026-08-05: "that
/// whole power usb state probably belongs in the power switch options". /// whole power usb state probably belongs in the power switch options".
PowerMenu, 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. /// Change the port's gadget posture through usb-signaller.
/// ///
/// The mode daemon is the mechanism and souveraine-upower is its adjacent /// 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 /// continuous gesture produces no events at all, so a machine that stamped
/// "last input" itself would blank in the middle of a swipe. /// "last input" itself would blank in the middle of a swipe.
idle_since: Option<Instant>, 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. /// Per-source freshness, for the staleness rule.
evidence_seen: EvidenceSeen, evidence_seen: EvidenceSeen,
/// Per-source health — whether evidence is arriving at all, which is a /// Per-source health — whether evidence is arriving at all, which is a
@ -1582,6 +1599,7 @@ impl DeviceStateMachine {
forensic: ForensicLog::new(), forensic: ForensicLog::new(),
panel_on: true, panel_on: true,
idle_since: None, idle_since: None,
power_requested: None,
evidence_seen: EvidenceSeen::default(), evidence_seen: EvidenceSeen::default(),
source_health: SourceHealthTable::default(), source_health: SourceHealthTable::default(),
started_at: Instant::now(), started_at: Instant::now(),
@ -2503,6 +2521,53 @@ impl DeviceStateMachine {
vec![Action::UsbMode(mode)] 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 /// 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. /// input, so a dt2w wake starts the blank budget from the wake itself.
/// ///
@ -3175,6 +3240,10 @@ impl DeviceStateMachine {
"idle_secs": self.idle_secs(), "idle_secs": self.idle_secs(),
"dimmed": self.dimmed, "dimmed": self.dimmed,
"blank_requested": self.blank_requested, "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 // Freshness means "reported within the TTL", not "has ever been
// heard from". The `is_some()` version read as fresh for the whole // heard from". The `is_some()` version read as fresh for the whole
// life of the daemon, because `expire_stale_evidence` only clears // life of the daemon, because `expire_stale_evidence` only clears
@ -3466,6 +3535,42 @@ mod tests {
assert_eq!(actions, vec![Action::PowerMenu]); 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] #[test]
fn a_three_finger_tap_on_the_wallpaper_raises_nothing() { fn a_three_finger_tap_on_the_wallpaper_raises_nothing() {
// The no-window case, decided rather than discovered on device: a tap // The no-window case, decided rather than discovered on device: a tap

View file

@ -64,9 +64,9 @@ pub enum RefusalCode {
/// it. This is the one a chain most often wants: it means *not now*, and /// 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. /// the state that forbade it is worth reading before deciding what next.
RefusedByState, RefusedByState,
/// The caller may not do this. Reserved — nothing issues it yet, because /// The caller may not do this. Power policy issues it when login1 answers
/// caller identity is uid 1000 for everything (audit P3). It exists so /// `no`; capability tokens will eventually make it caller-specific across
/// that when capability tokens land, callers already branch on it. /// the rest of the surface too.
NotPermitted, NotPermitted,
/// A dependency this op needs is absent — no compositor, no PAM stack, no /// A dependency this op needs is absent — no compositor, no PAM stack, no
/// sensor. Not the caller's fault and possibly transient. /// sensor. Not the caller's fault and possibly transient.
@ -255,6 +255,17 @@ pub const VERBS: &[VerbDoc] = &[
refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable], refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
example: r#"{"op":"set_usb_mode","mode":"hid"}"#, 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. /// A request plus the caller's declared intent.
@ -433,6 +444,61 @@ pub enum Request {
/// recycled the tunnel 652 times. /// recycled the tunnel 652 times.
bearer_settle_secs: Option<u64>, 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. /// 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 // And the other direction: every variant must be advertised. Bump this
// deliberately when a verb is added, having added its VerbDoc. // 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!( assert_eq!(
VERBS.len(), VERBS.len(),
19, 20,
"a Request variant was added without a VerbDoc" "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] #[test]
fn the_envelope_is_backward_compatible() { fn the_envelope_is_backward_compatible() {
// Every line written before intent existed must still parse, or the // Every line written before intent existed must still parse, or the

View file

@ -25,8 +25,8 @@ use crate::sessiond::idle;
use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome}; use crate::sessiond::lock::{self, LockController, Msg, SessionOutcome};
use crate::sessiond::lockhint; use crate::sessiond::lockhint;
use crate::sessiond::protocol::{ use crate::sessiond::protocol::{
Envelope, InputTrigger, Phase, RefusalCode, Request, SensorSource, SensorValue, UsbMode, Envelope, InputTrigger, Phase, PowerVerb, RefusalCode, Request, SensorSource, SensorValue,
LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES, VERBS, UsbMode, LOCKED_ACK_TIMEOUT_SECS, MAX_REQUEST_BYTES, VERBS,
}; };
/// How often the machine's clock runs. One second is fine: the shortest /// 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", "power-menu-open",
), ),
Action::UsbMode(_) => unreachable!("handled above; expressed through usb-signaller"), Action::UsbMode(_) => unreachable!("handled above; expressed through usb-signaller"),
Action::Power(_) => unreachable!("power requests use the result-bearing power executor"),
Action::Blank => { Action::Blank => {
// The invariant, enforced where it cannot be reasoned around: the // The invariant, enforced where it cannot be reasoned around: the
// panel does not go dark unless logind says this session is // 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 { fn refuse(code: RefusalCode, reason: &str) -> serde_json::Value {
serde_json::json!({ "ok": false, "code": code.as_str(), "reason": reason }) 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<()> { fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> {
writer.write_all(value.to_string().as_bytes())?; writer.write_all(value.to_string().as_bytes())?;
writer.write_all(b"\n")?; writer.write_all(b"\n")?;
@ -1482,6 +1694,7 @@ fn handle_request(
), ),
} }
} }
Request::Power { verb } => handle_power_request(shared, verb, &SystemPowerBackend),
Request::Bearer => { Request::Bearer => {
let d = shared.lock(); let d = shared.lock();
let b = &d.device_state.bearer; let b = &d.device_state.bearer;
@ -1668,6 +1881,7 @@ fn handle_request(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::cell::Cell;
fn idle_shared() -> Arc<Shared> { fn idle_shared() -> Arc<Shared> {
Arc::new(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] #[test]
fn second_shell_ready_cannot_steal_authority_lease() { fn second_shell_ready_cannot_steal_authority_lease() {
let shared = idle_shared(); let shared = idle_shared();

View file

@ -55,6 +55,146 @@ Singleton {
console.log("[sessiond-bridge] inactive outside authority scope") 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 // Announce the shell. cb(mustLock) fires exactly once: mustLock true
// means sessiond was holding and the session IS locked the shell must // means sessiond was holding and the session IS locked the shell must
// raise its own lock surface immediately. // raise its own lock surface immediately.