Wake shell status from service and hardware events

This commit is contained in:
Samuel Onoja committed 2026-10-02 08:33:37 +01:00
1 parent 8bb0b1da9c
commit 570984a39b
9 files changed
+380 -98

No files matched your search

Generated
+1
View File
@@ -1787,6 +1787,7 @@ dependencies = [
"ferese-theme", "ferese-theme",
"iced_accessibility", "iced_accessibility",
"jiff", "jiff",
"libc",
"libcosmic", "libcosmic",
"pipewire", "pipewire",
"serde", "serde",
+1
View File
@@ -12,6 +12,7 @@ ferese-config = { path = "../../crates/ferese-config" }
ferese-core = { path = "../../crates/ferese-core" } ferese-core = { path = "../../crates/ferese-core" }
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
jiff.workspace = true jiff.workspace = true
libc.workspace = true
# Keep GPU rendering available as a fallback/override. The shell defaults to # Keep GPU rendering available as a fallback/override. The shell defaults to
# tiny-skia to avoid a second GPU initialization during desktop startup. # tiny-skia to avoid a second GPU initialization during desktop startup.
libcosmic = { workspace = true, features = ["wgpu", "a11y"] } libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
+3 -10
View File
@@ -355,16 +355,9 @@ impl cosmic::Application for FereseShell {
Subscription::none() Subscription::none()
}, },
self.notifications.subscription().map(Message::NotificationEvent), self.notifications.subscription().map(Message::NotificationEvent),
if self.notifications.has_toasts() { self.notifications
cosmic::iced::time::every(Duration::from_millis(if self.notifications.animating() { .tick_subscription()
16 .map(|_| Message::NotificationTick),
} else {
250
}))
.map(|_| Message::NotificationTick)
} else {
Subscription::none()
},
event::listen_with(|event, _status, id| match &event { event::listen_with(|event, _status, id| match &event {
Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Frame(..))) => EFFECT_FRAME_PENDING
.load(Ordering::Relaxed) .load(Ordering::Relaxed)
+45 -2
View File
@@ -392,6 +392,8 @@ impl Center {
} }
pub fn hover(&mut self, id: u32, hovered: bool) { pub fn hover(&mut self, id: u32, hovered: bool) {
self.tick();
if hovered { if hovered {
self.hovered = Some(id); self.hovered = Some(id);
} else if self.hovered == Some(id) { } else if self.hovered == Some(id) {
@@ -535,8 +537,30 @@ impl Center {
cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream) cosmic::iced::Subscription::run_with(self.service.events.clone(), Events::stream)
} }
pub fn has_toasts(&self) -> bool { fn expiry_deadline(&self) -> Option<Instant> {
!self.toasts.is_empty() self.toasts
.iter()
.filter(|toast| !toast.hovered && toast.closing.is_none())
.filter_map(|toast| toast.remaining.map(|remaining| self.last_tick + remaining))
.min()
}
pub fn tick_subscription(&self) -> cosmic::iced::Subscription<()> {
if self.animating() {
return cosmic::iced::time::every(Duration::from_millis(16)).map(|_| ());
}
fn deadline_stream(deadline: &Instant) -> impl cosmic::iced::futures::Stream<Item = ()> + use<> {
let deadline = *deadline;
cosmic::iced::futures::stream::once(async move {
tokio::time::sleep(deadline.saturating_duration_since(Instant::now())).await;
})
}
self.expiry_deadline()
.map_or_else(cosmic::iced::Subscription::none, |deadline| {
cosmic::iced::Subscription::run_with(deadline, deadline_stream)
})
} }
pub fn handle_event(&mut self, event: Event) { pub fn handle_event(&mut self, event: Event) {
@@ -710,6 +734,25 @@ mod tests {
assert_eq!(center.entries.len(), 4); assert_eq!(center.entries.len(), 4);
} }
#[test]
fn expiry_deadlines_skip_hovered_and_persistent_toasts() {
let (mut center, _) = fixture();
assert!(center.expiry_deadline().is_none());
center.receive(notice(1));
assert_eq!(
center.expiry_deadline(),
Some(center.last_tick + Duration::from_secs(6))
);
center.hover(1, true);
assert!(center.expiry_deadline().is_none());
center.hover(1, false);
assert!(center.expiry_deadline().is_some());
center.toasts[0].remaining = None;
assert!(center.expiry_deadline().is_none());
}
#[test] #[test]
fn hover_pauses_expiry_and_replacements_reset_the_timer() { fn hover_pauses_expiry_and_replacements_reset_the_timer() {
let (mut center, commands) = fixture(); let (mut center, commands) = fixture();
+140 -69
View File
@@ -2,13 +2,14 @@
mod audio_cache; mod audio_cache;
mod bluetooth; mod bluetooth;
mod dbus_cache; mod dbus_cache;
mod hardware;
mod network; mod network;
use std::path::Path; use std::path::Path;
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::sync::mpsc::SyncSender; use std::sync::mpsc::SyncSender;
use std::sync::{Arc, Condvar, Mutex, mpsc}; use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::time::Duration; use std::time::{Duration, Instant};
use std::{env, fs, thread}; use std::{env, fs, thread};
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
@@ -130,7 +131,7 @@ impl Updates {
} }
} }
type PollState = (Mutex<(u64, bool, Option<ActionError>, bool)>, Condvar); type PollState = (Mutex<(u64, bool, Option<ActionError>, bool, u64)>, Condvar);
fn wait_for_poll(shared: &PollState) -> Option<u64> { fn wait_for_poll(shared: &PollState) -> Option<u64> {
let state = shared let state = shared
@@ -140,6 +141,12 @@ fn wait_for_poll(shared: &PollState) -> Option<u64> {
(!state.3).then_some(state.0) (!state.3).then_some(state.0)
} }
fn wake_status(shared: &PollState) {
let mut state = shared.0.lock().unwrap_or_else(|error| error.into_inner());
state.4 = state.4.wrapping_add(1);
shared.1.notify_one();
}
impl Service { impl Service {
pub fn start(settings: Option<Vec<String>>) -> Self { pub fn start(settings: Option<Vec<String>>) -> Self {
let live_settings = Arc::new(Mutex::new(settings)); let live_settings = Arc::new(Mutex::new(settings));
@@ -148,21 +155,46 @@ impl Service {
let (updates, rx) = tokio::sync::watch::channel(None); let (updates, rx) = tokio::sync::watch::channel(None);
// Polling and writes have separate workers: a missing D-Bus service must // Polling and writes have separate workers: a missing D-Bus service must
// never hold up volume/brightness changes. Publish only coherent polls. // never hold up volume/brightness changes. Publish only coherent polls.
let shared = Arc::new((Mutex::new((0u64, false, None::<ActionError>, false)), Condvar::new())); let shared = Arc::new((Mutex::new((0u64, false, None::<ActionError>, false, 0)), Condvar::new()));
let polling = shared.clone(); let polling = shared.clone();
thread::spawn(move || { thread::spawn(move || {
let mut system_bus = StatusBus::new(true); let mut network = dbus_cache::Cache::with_wake("org.freedesktop.NetworkManager", polling.clone());
let mut session_bus = StatusBus::new(false); let mut bluetooth = dbus_cache::Cache::with_wake("org.bluez", polling.clone());
let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager"); let mut battery_cache = dbus_cache::Cache::with_wake("org.freedesktop.UPower", polling.clone());
let mut bluetooth = dbus_cache::Cache::new("org.bluez"); let mut profiles = dbus_cache::Cache::with_wake("org.freedesktop.UPower.PowerProfiles", polling.clone());
let mut legacy_profiles = dbus_cache::Cache::with_wake("net.hadess.PowerProfiles", polling.clone());
let mut login = dbus_cache::Cache::with_wake("org.freedesktop.login1", polling.clone());
let mut audio = audio_cache::Cache::new(polling.clone()); let mut audio = audio_cache::Cache::new(polling.clone());
let mut hardware = hardware::Monitor::start(polling.clone()).ok();
// A poll overlapping a write is discarded below. Wait for the
// write to finish before retrying instead of launching commands
// repeatedly while a slow control operation is still running.
while let Some(before) = wait_for_poll(&polling) { while let Some(before) = wait_for_poll(&polling) {
let mut snapshot = poll(&mut system_bus, &mut session_bus); let revision = polling.0.lock().unwrap().4;
if hardware.as_ref().is_none_or(|monitor| !monitor.alive()) {
hardware = hardware::Monitor::start(polling.clone()).ok();
}
let capabilities = login.read(before, read_capabilities).unwrap_or_default();
let primary_profiles = profiles.read(before, |connection| {
read_power_profiles(
connection,
"org.freedesktop.UPower.PowerProfiles",
"/org/freedesktop/UPower/PowerProfiles",
)
.map(Some)
});
let fallback_profiles = legacy_profiles.read(before, |connection| {
read_power_profiles(connection, "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles").map(Some)
});
let mut snapshot = Snapshot {
battery: battery_cache.read(before, read_battery).or_else(battery),
power_profiles: primary_profiles.or(fallback_profiles),
brightness: brightness(),
poweroff: capabilities.0,
reboot: capabilities.1,
suspend: capabilities.2,
..Snapshot::default()
};
snapshot.audio = audio.read(before); snapshot.audio = audio.read(before);
snapshot.network = network.read(before, network::read); snapshot.network = network.read(before, network::read);
snapshot.bluetooth = bluetooth.read(before, bluetooth::read); snapshot.bluetooth = bluetooth.read(before, bluetooth::read);
@@ -178,8 +210,35 @@ impl Service {
generation: state.0, generation: state.0,
error: state.2.clone(), error: state.2.clone(),
})); }));
let retry = [
network.next_retry(),
bluetooth.next_retry(),
battery_cache.next_retry(),
profiles.next_retry(),
legacy_profiles.next_retry(),
login.next_retry(),
audio.next_retry(),
hardware
.as_ref()
.is_none_or(|monitor| !monitor.alive())
.then(|| Instant::now() + Duration::from_secs(5)),
]
.into_iter()
.flatten()
.min();
let unchanged = |state: &mut (u64, bool, Option<ActionError>, bool, u64)| {
!state.3 && state.0 == before && state.4 == revision
};
if !audio.changed() { if !audio.changed() {
let _ = polling.1.wait_timeout(state, Duration::from_secs(2)); if let Some(deadline) = retry {
let _ = polling.1.wait_timeout_while(
state,
deadline.saturating_duration_since(Instant::now()),
unchanged,
);
} else {
drop(polling.1.wait_while(state, unchanged));
}
} }
} }
} }
@@ -207,6 +266,7 @@ impl Service {
let mut state = shared.0.lock().unwrap(); let mut state = shared.0.lock().unwrap();
state.1 = false; state.1 = false;
state.2 = error; state.2 = error;
state.4 = state.4.wrapping_add(1);
shared.1.notify_one(); shared.1.notify_one();
} }
} }
@@ -329,6 +389,7 @@ impl StatusBus {
} }
} }
#[cfg(test)]
fn can_power(&mut self, method: &str) -> bool { fn can_power(&mut self, method: &str) -> bool {
self.query(|connection| { self.query(|connection| {
connection connection
@@ -345,6 +406,7 @@ impl StatusBus {
.is_some_and(|value| value == "yes" || value == "challenge") .is_some_and(|value| value == "yes" || value == "challenge")
} }
#[cfg(test)]
fn notification_service_owned(&mut self) -> bool { fn notification_service_owned(&mut self) -> bool {
self.query(|connection| { self.query(|connection| {
connection connection
@@ -362,19 +424,49 @@ impl StatusBus {
} }
} }
fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot { fn read_capabilities(connection: &zbus::blocking::Connection) -> zbus::Result<Option<(bool, bool, bool)>> {
Snapshot { let can = |method| -> zbus::Result<bool> {
network: None, let value: String = connection
bluetooth: None, .call_method(
audio: None, Some("org.freedesktop.login1"),
battery: battery(), "/org/freedesktop/login1",
power_profiles: power_profiles(system_bus), Some("org.freedesktop.login1.Manager"),
brightness: brightness(), method,
notifications: notifications(session_bus), &(),
poweroff: system_bus.can_power("CanPowerOff"), )?
reboot: system_bus.can_power("CanReboot"), .body()
suspend: system_bus.can_power("CanSuspend"), .deserialize()?;
Ok(value == "yes" || value == "challenge")
};
Ok(Some((can("CanPowerOff")?, can("CanReboot")?, can("CanSuspend")?)))
}
fn read_battery(connection: &zbus::blocking::Connection) -> zbus::Result<Option<Battery>> {
let proxy = zbus::blocking::Proxy::new(
connection,
"org.freedesktop.UPower",
"/org/freedesktop/UPower/devices/DisplayDevice",
"org.freedesktop.UPower.Device",
)?;
if !proxy.get_property::<bool>("IsPresent")? {
return Ok(None);
} }
let percent = proxy.get_property::<f64>("Percentage")?;
let status = match proxy.get_property::<u32>("State")? {
1 => "Charging",
2 => "Discharging",
3 => "Empty",
4 => "Full",
5 => "Pending charge",
6 => "Pending discharge",
_ => "Unknown",
};
Ok(Some(Battery {
percent: percent.round().clamp(0., 100.) as u8,
status: status.into(),
}))
} }
pub fn parse_audio(value: &str) -> Option<(u8, bool)> { pub fn parse_audio(value: &str) -> Option<(u8, bool)> {
@@ -429,6 +521,7 @@ fn battery() -> Option<Battery> {
}) })
} }
#[cfg(test)]
fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> { fn power_profiles(bus: &mut StatusBus) -> Option<PowerProfiles> {
bus.query(|connection| { bus.query(|connection| {
read_power_profiles( read_power_profiles(
@@ -462,38 +555,24 @@ fn read_power_profiles(
} }
fn brightness() -> Option<u8> { fn brightness() -> Option<u8> {
parse_brightness(&run("brightnessctl", &["--class=backlight", "--machine-readable", "info"]).ok()?) let mut devices = fs::read_dir("/sys/class/backlight")
.ok()?
.flatten()
.map(|entry| entry.path())
.collect::<Vec<_>>();
devices.sort();
devices.iter().find_map(|path| {
let current = read(path, "brightness")?.parse::<f64>().ok()?;
let max = read(path, "max_brightness")?.parse::<f64>().ok()?;
brightness_percent(current, max)
})
} }
fn parse_brightness(value: &str) -> Option<u8> { fn brightness_percent(current: f64, max: f64) -> Option<u8> {
// device,class,current,percentage,max. Compute the ratio ourselves to (max.is_finite() && max > 0.0 && current.is_finite() && current >= 0.0)
// preserve the previous rounding rather than using the CLI's percentage.
let mut fields = value.trim().split(',');
let _device = fields.next()?;
if fields.next()? != "backlight" {
return None;
}
let current = fields.next()?.parse::<f64>().ok()?;
let _percentage = fields.next()?;
let max = fields.next()?.parse::<f64>().ok()?;
(fields.next().is_none() && max.is_finite() && max > 0.0 && current.is_finite() && current >= 0.0)
.then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8) .then(|| (100.0 * current / max).round().clamp(0.0, 100.0) as u8)
} }
fn notifications(bus: &mut StatusBus) -> Option<Notifications> {
// Check service ownership first: swaync-client otherwise waits indefinitely.
if !bus.notification_service_owned() {
return None;
}
let count = run("swaync-client", &["--count"]).ok()?.parse().ok()?;
let dnd = match run("swaync-client", &["--get-dnd"]).ok()?.as_str() {
"true" => true,
"false" => false,
_ => return None,
};
Some(Notifications { count, dnd })
}
pub(super) fn execute_power(action: Action, force: bool) -> Result<(), String> { pub(super) fn execute_power(action: Action, force: bool) -> Result<(), String> {
let action = match action { let action = match action {
Action::Poweroff => "poweroff", Action::Poweroff => "poweroff",
@@ -531,8 +610,7 @@ fn execute(action: &Action, settings: Option<&[String]>) -> Result<(), String> {
"brightnessctl", "brightnessctl",
&["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))], &["--class=backlight", "set", &format!("{}%", value.clamp(&1, &100))],
), ),
Action::Dnd(on) => run("swaync-client", &[if *on { "--dnd-on" } else { "--dnd-off" }]), Action::Dnd(_) | Action::Notifications => return Err("Native notifications are unavailable".into()),
Action::Notifications => run("swaync-client", &["--open-panel"]),
Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => { Action::PowerProfile(profile) if matches!(*profile, "power-saver" | "balanced" | "performance") => {
run("powerprofilesctl", &["set", profile]) run("powerprofilesctl", &["set", profile])
} }
@@ -721,20 +799,13 @@ mod tests {
} }
#[test] #[test]
fn brightness_info_preserves_ratio_rounding_and_rejects_bad_devices() { fn brightness_ratio_rounds_and_rejects_invalid_values() {
assert_eq!(parse_brightness("intel,backlight,72,18%,400"), Some(18)); assert_eq!(brightness_percent(72.0, 400.0), Some(18));
assert_eq!(parse_brightness("panel,backlight,2,66%,3\n"), Some(67)); assert_eq!(brightness_percent(2.0, 3.0), Some(67));
assert_eq!(parse_brightness("panel,backlight,0,0%,400"), Some(0)); assert_eq!(brightness_percent(0.0, 400.0), Some(0));
for value in [
"", for (current, max) in [(1.0, 0.0), (-1.0, 100.0), (f64::NAN, 100.0), (1.0, f64::INFINITY)] {
"panel,leds,1,1%,100", assert_eq!(brightness_percent(current, max), None);
"panel,backlight,1,0%,0",
"panel,backlight,NaN,0%,100",
"panel,backlight,1,0%,inf",
"panel,backlight,-1,0%,100",
"panel,backlight,1,0%,100,extra",
] {
assert_eq!(parse_brightness(value), None, "{value}");
} }
} }
@@ -802,7 +873,7 @@ mod tests {
#[test] #[test]
fn polling_waits_for_a_write_and_uses_the_completed_generation() { fn polling_waits_for_a_write_and_uses_the_completed_generation() {
let shared = Arc::new((Mutex::new((7, true, None, false)), Condvar::new())); let shared = Arc::new((Mutex::new((7, true, None, false, 0)), Condvar::new()));
let worker_state = shared.clone(); let worker_state = shared.clone();
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
let worker = thread::spawn(move || { let worker = thread::spawn(move || {
@@ -824,7 +895,7 @@ mod tests {
#[test] #[test]
fn shutdown_unblocks_polling_even_during_a_write() { fn shutdown_unblocks_polling_even_during_a_write() {
let shared = Arc::new((Mutex::new((7, true, None, false)), Condvar::new())); let shared = Arc::new((Mutex::new((7, true, None, false, 0)), Condvar::new()));
let worker_state = shared.clone(); let worker_state = shared.clone();
let (sender, receiver) = mpsc::channel(); let (sender, receiver) = mpsc::channel();
let worker = thread::spawn(move || { let worker = thread::spawn(move || {
+5 -1
View File
@@ -49,6 +49,10 @@ impl Cache {
self.value.clone() self.value.clone()
} }
pub(super) fn next_retry(&self) -> Option<Instant> {
(!self.signals.alive() || self.value.is_none()).then_some(self.retry_at)
}
pub(super) fn changed(&self) -> bool { pub(super) fn changed(&self) -> bool {
self.signals.revision() != self.revision self.signals.revision() != self.revision
} }
@@ -62,7 +66,7 @@ mod tests {
fn cache() -> Cache { fn cache() -> Cache {
Cache { Cache {
signals: Signals::test(Arc::new((Mutex::new((0, false, None, false)), Condvar::new()))), signals: Signals::test(Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()))),
revision: 0, revision: 0,
action: u64::MAX, action: u64::MAX,
value: None, value: None,
@@ -23,8 +23,7 @@ impl State {
pub(super) fn changed(&self) { pub(super) fn changed(&self) {
self.revision.fetch_add(1, Ordering::AcqRel); self.revision.fetch_add(1, Ordering::AcqRel);
// Synchronize with the poller's predicate check and condvar wait. // Synchronize with the poller's predicate check and condvar wait.
let _guard = self.wake.0.lock().unwrap_or_else(|error| error.into_inner()); super::super::wake_status(&self.wake);
self.wake.1.notify_one();
} }
pub(super) fn set_alive(&self, alive: bool) { pub(super) fn set_alive(&self, alive: bool) {
@@ -324,7 +323,7 @@ context.objects = [
.stderr(Stdio::null()); .stderr(Stdio::null());
let mut server = Server(command.spawn().unwrap()); let mut server = Server(command.spawn().unwrap());
wait_for(|| root.path().join("pipewire-0").exists()); wait_for(|| root.path().join("pipewire-0").exists());
let wake = Arc::new((Mutex::new((0, false, None, false)), Condvar::new())); let wake = Arc::new((Mutex::new((0, false, None, false, 0)), Condvar::new()));
let signals = Signals::start_remote(wake, Some(root.path().join("pipewire-0").to_str().unwrap().into())); let signals = Signals::start_remote(wake, Some(root.path().join("pipewire-0").to_str().unwrap().into()));
wait_for(|| signals.alive()); wait_for(|| signals.alive());
std::thread::sleep(Duration::from_millis(500)); std::thread::sleep(Duration::from_millis(500));
+62 -13
View File
@@ -7,7 +7,7 @@ use cosmic::iced::futures::channel::oneshot;
use cosmic::iced::futures::{FutureExt, StreamExt}; use cosmic::iced::futures::{FutureExt, StreamExt};
use zbus::blocking::{Connection, MessageIterator}; use zbus::blocking::{Connection, MessageIterator};
use super::StatusBus; use super::{PollState, StatusBus, wake_status};
struct Signals { struct Signals {
revision: Arc<AtomicU64>, revision: Arc<AtomicU64>,
@@ -16,7 +16,7 @@ struct Signals {
} }
impl Signals { impl Signals {
fn new(connection: &Connection, service: &str) -> zbus::Result<Self> { fn new(connection: &Connection, service: &str, wake: Option<Arc<PollState>>) -> zbus::Result<Self> {
// Install both rules before the snapshot, including when the service is // Install both rules before the snapshot, including when the service is
// currently absent. Owner changes invalidate paths after a restart. // currently absent. Owner changes invalidate paths after a restart.
let changes = MessageIterator::for_match_rule( let changes = MessageIterator::for_match_rule(
@@ -51,6 +51,7 @@ impl Signals {
// this before entering our async runtime. // this before entering our async runtime.
let changes = changes.into_inner(); let changes = changes.into_inner();
let owners = owners.into_inner(); let owners = owners.into_inner();
let event_wake = wake.clone();
runtime.block_on(async move { runtime.block_on(async move {
let mut events = cosmic::iced::futures::stream::select(changes, owners); let mut events = cosmic::iced::futures::stream::select(changes, owners);
let mut stopped = stopped.fuse(); let mut stopped = stopped.fuse();
@@ -59,6 +60,7 @@ impl Signals {
_ = stopped => break, _ = stopped => break,
event = events.next() => { event = events.next() => {
changed.fetch_add(1, Ordering::Release); changed.fetch_add(1, Ordering::Release);
if let Some(wake) = &event_wake { wake_status(wake); }
if !matches!(event, Some(Ok(_))) { break; } if !matches!(event, Some(Ok(_))) { break; }
} }
} }
@@ -69,6 +71,9 @@ impl Signals {
}); });
} }
running.store(false, Ordering::Release); running.store(false, Ordering::Release);
if let Some(wake) = &wake {
wake_status(wake);
}
}); });
Ok(Self { Ok(Self {
revision, revision,
@@ -86,6 +91,8 @@ pub(super) struct Cache<T> {
action: u64, action: u64,
value: Option<T>, value: Option<T>,
retry_at: Instant, retry_at: Instant,
retry: bool,
wake: Option<Arc<PollState>>,
} }
impl<T: Clone> Cache<T> { impl<T: Clone> Cache<T> {
@@ -98,33 +105,59 @@ impl<T: Clone> Cache<T> {
action: u64::MAX, action: u64::MAX,
value: None, value: None,
retry_at: Instant::now(), retry_at: Instant::now(),
retry: false,
wake: None,
} }
} }
pub(super) fn with_wake(service: &'static str, wake: Arc<PollState>) -> Self {
let mut cache = Self::new(service);
cache.wake = Some(wake);
cache
}
pub(super) fn next_retry(&self) -> Option<Instant> {
(self.action != u64::MAX && (self.signals.is_none() || self.retry)).then_some(self.retry_at)
}
pub(super) fn read(&mut self, action: u64, read: fn(&Connection) -> zbus::Result<Option<T>>) -> Option<T> { pub(super) fn read(&mut self, action: u64, read: fn(&Connection) -> zbus::Result<Option<T>>) -> Option<T> {
if self.signals.as_ref().is_some_and(|s| !s.alive.load(Ordering::Acquire)) { if self.signals.as_ref().is_some_and(|s| !s.alive.load(Ordering::Acquire)) {
self.signals = None; self.signals = None;
self.bus.connection = None; self.bus.connection = None;
self.value = None; self.value = None;
self.retry_at = Instant::now();
} }
if self.signals.is_none() { if self.signals.is_none() && Instant::now() >= self.retry_at {
self.signals = self.bus.query(|connection| Signals::new(connection, self.service)); self.signals = self
.bus
.query(|connection| Signals::new(connection, self.service, self.wake.clone()));
self.revision = 0; self.revision = 0;
} }
// If subscription setup fails, read fresh rather than trusting a cache // If subscription setup fails, read fresh rather than trusting a cache
// with no invalidation. Retry setup on the next regular status poll. // with no invalidation. Retry setup at the bounded recovery deadline.
let revision = self.signals.as_ref().map(|s| s.revision.load(Ordering::Acquire)); let revision = self.signals.as_ref().map(|s| s.revision.load(Ordering::Acquire));
if revision.is_none() if (revision.is_none() && Instant::now() >= self.retry_at)
|| revision != Some(self.revision) || revision.is_some_and(|revision| revision != self.revision)
|| action != self.action || action != self.action
|| (self.value.is_none() && Instant::now() >= self.retry_at) || (self.retry && Instant::now() >= self.retry_at)
{ {
// Keep signal subscriptions across service errors. A dead bus is // Keep signal subscriptions across service errors. A dead bus is
// detected by the listener; a transient query failure retries later. // detected by the listener; a transient query failure retries later.
self.value = self let result = self.bus.query(|connection| {
.bus let owned = connection
.query(|connection| Ok(connection.clone())) .call_method(
.and_then(|connection| read(&connection).ok().flatten()); Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"NameHasOwner",
&(self.service,),
)?
.body()
.deserialize::<bool>()?;
if owned { read(connection) } else { Ok(None) }
});
self.retry = result.is_none();
self.value = result.flatten();
self.retry_at = Instant::now() + Duration::from_secs(5); self.retry_at = Instant::now() + Duration::from_secs(5);
self.action = action; self.action = action;
self.revision = revision.unwrap_or(0); self.revision = revision.unwrap_or(0);
@@ -178,6 +211,7 @@ mod tests {
assert_eq!(cache.read(0, read_counter), Some(1)); assert_eq!(cache.read(0, read_counter), Some(1));
} }
assert_eq!(count.load(Ordering::Relaxed), 1); assert_eq!(count.load(Ordering::Relaxed), 1);
assert!(cache.next_retry().is_none(), "healthy subscriptions must not poll");
assert_eq!(cache.read(1, read_counter), Some(2)); assert_eq!(cache.read(1, read_counter), Some(2));
server server
.emit_signal(None::<&str>, "/test", "org.ferese.StatusCounter", "Changed", &()) .emit_signal(None::<&str>, "/test", "org.ferese.StatusCounter", "Changed", &())
@@ -193,6 +227,7 @@ mod tests {
assert!(Instant::now() < deadline, "owner loss kept stale data"); assert!(Instant::now() < deadline, "owner loss kept stale data");
std::thread::sleep(Duration::from_millis(1)); std::thread::sleep(Duration::from_millis(1));
} }
assert!(cache.next_retry().is_none(), "absent services wait for owner changes");
server.request_name("org.ferese.StatusCounter").unwrap(); server.request_name("org.ferese.StatusCounter").unwrap();
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
while cache.read(1, read_counter).is_none() { while cache.read(1, read_counter).is_none() {
@@ -207,6 +242,9 @@ mod tests {
.is_none() .is_none()
); );
assert!(cache.read(2, read_counter).is_none()); assert!(cache.read(2, read_counter).is_none());
// The production cache reconnects to the system bus after an error;
// keep this recovery attempt on the isolated test bus instead.
cache.bus.connection = Some(bus.connect());
cache.retry_at = Instant::now(); cache.retry_at = Instant::now();
assert!(cache.read(2, read_counter).is_some()); assert!(cache.read(2, read_counter).is_some());
} }
@@ -218,7 +256,11 @@ mod tests {
let client = bus.connect(); let client = bus.connect();
let server = bus.connect(); let server = bus.connect();
server.request_name("org.ferese.StatusTest").unwrap(); server.request_name("org.ferese.StatusTest").unwrap();
let signals = Signals::new(&client, "org.ferese.StatusTest").unwrap(); let wake = Arc::new((
std::sync::Mutex::new((0, false, None, false, 0)),
std::sync::Condvar::new(),
));
let signals = Signals::new(&client, "org.ferese.StatusTest", Some(wake.clone())).unwrap();
let wait = |before| { let wait = |before| {
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
while signals.revision.load(Ordering::Acquire) == before && Instant::now() < deadline { while signals.revision.load(Ordering::Acquire) == before && Instant::now() < deadline {
@@ -230,6 +272,13 @@ mod tests {
server server
.emit_signal(None::<&str>, "/test", "org.ferese.StatusTest", "Changed", &()) .emit_signal(None::<&str>, "/test", "org.ferese.StatusTest", "Changed", &())
.unwrap(); .unwrap();
let (state, timeout) = wake
.1
.wait_timeout_while(wake.0.lock().unwrap(), Duration::from_secs(2), |state| state.4 == 0)
.unwrap();
assert!(!timeout.timed_out(), "service signal must wake the status worker");
assert!(state.4 > 0);
drop(state);
wait(before); wait(before);
let before = signals.revision.load(Ordering::Acquire); let before = signals.revision.load(Ordering::Acquire);
server.release_name("org.ferese.StatusTest").unwrap(); server.release_name("org.ferese.StatusTest").unwrap();
+121
View File
@@ -0,0 +1,121 @@
//! Kernel battery/backlight events, with a cancellation fd and no polling timer.
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::net::UnixStream;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use super::{PollState, wake_status};
pub(super) struct Monitor {
_stop: UnixStream,
alive: Arc<AtomicBool>,
}
fn relevant(message: &[u8]) -> bool {
message
.split(|byte| *byte == 0)
.any(|field| field == b"SUBSYSTEM=backlight" || field == b"SUBSYSTEM=power_supply")
}
impl Monitor {
pub fn alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
pub fn start(wake: Arc<PollState>) -> std::io::Result<Self> {
// SAFETY: socket has no borrowed pointer arguments; successful fd is owned below.
let fd = unsafe {
libc::socket(
libc::AF_NETLINK,
libc::SOCK_DGRAM | libc::SOCK_CLOEXEC,
libc::NETLINK_KOBJECT_UEVENT,
)
};
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
// SAFETY: the successful socket call returned a unique owned descriptor.
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
// SAFETY: zero is a valid initialization for sockaddr_nl including padding.
let mut address: libc::sockaddr_nl = unsafe { std::mem::zeroed() };
address.nl_family = libc::AF_NETLINK as u16;
address.nl_groups = 1;
// SAFETY: the address pointer and its length describe the initialized struct.
if unsafe {
libc::bind(
fd.as_raw_fd(),
(&raw const address).cast(),
std::mem::size_of_val(&address) as _,
)
} < 0
{
return Err(std::io::Error::last_os_error());
}
let (stop, cancelled) = UnixStream::pair()?;
let alive = Arc::new(AtomicBool::new(true));
let running = alive.clone();
std::thread::Builder::new()
.name("ferese-hardware-events".into())
.spawn(move || {
let mut descriptors = [
libc::pollfd {
fd: fd.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: cancelled.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
];
let mut buffer = [0u8; 65536];
loop {
// SAFETY: both descriptors remain owned by this thread; poll writes within the array.
let result = unsafe { libc::poll(descriptors.as_mut_ptr(), 2, -1) };
if result < 0 && std::io::Error::last_os_error().kind() == std::io::ErrorKind::Interrupted {
continue;
}
if result < 0
|| descriptors[1].revents != 0
|| descriptors[0].revents & (libc::POLLERR | libc::POLLHUP | libc::POLLNVAL) != 0
{
break;
}
let mut length = std::mem::size_of_val(&address) as libc::socklen_t;
// SAFETY: the owned fd, writable byte buffer and sockaddr storage are valid for their lengths.
let count = unsafe {
libc::recvfrom(
fd.as_raw_fd(),
buffer.as_mut_ptr().cast(),
buffer.len(),
0,
(&raw mut address).cast(),
&mut length,
)
};
if count < 0 {
break;
}
if address.nl_pid == 0 && relevant(&buffer[..count as usize]) {
wake_status(&wake);
}
}
running.store(false, Ordering::Release);
wake_status(&wake);
})?;
Ok(Self { _stop: stop, alive })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_battery_and_backlight_events_refresh_status() {
assert!(relevant(b"change@/devices/panel\0SUBSYSTEM=backlight\0"));
assert!(relevant(b"SUBSYSTEM=power_supply\0ACTION=change\0"));
assert!(!relevant(b"SUBSYSTEM=input\0"));
assert!(!relevant(b"OTHER=SUBSYSTEM=backlight\0"));
}
}