Reduce idle shell polling and stabilize overview damage

This commit is contained in:
Samuel Onoja committed 2026-10-02 02:34:35 +01:00
1 parent 6f2951c6b0
commit ab0c174948
9 files changed
+797 -11

No files matched your search

+7 -2
View File
@@ -1,4 +1,5 @@
//! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state.
mod audio_cache;
mod bluetooth;
mod dbus_cache;
mod network;
@@ -155,12 +156,14 @@ impl Service {
let mut session_bus = StatusBus::new(false);
let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager");
let mut bluetooth = dbus_cache::Cache::new("org.bluez");
let mut audio = audio_cache::Cache::new(polling.clone());
// 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) {
let mut snapshot = poll(&mut system_bus, &mut session_bus);
snapshot.audio = audio.read(before);
snapshot.network = network.read(before, network::read);
snapshot.bluetooth = bluetooth.read(before, bluetooth::read);
let state = polling.0.lock().unwrap();
@@ -175,7 +178,9 @@ impl Service {
generation: state.0,
error: state.2.clone(),
}));
let _ = polling.1.wait_timeout(state, Duration::from_secs(2));
if !audio.changed() {
let _ = polling.1.wait_timeout(state, Duration::from_secs(2));
}
}
}
});
@@ -361,7 +366,7 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot {
Snapshot {
network: None,
bluetooth: None,
audio: audio(),
audio: None,
battery: battery(),
power_profiles: power_profiles(system_bus),
brightness: brightness(),
@@ -0,0 +1,170 @@
//! Query WirePlumber only after audio changes, retaining its route and volume semantics.
mod signals;
use std::sync::Arc;
use std::time::{Duration, Instant};
use signals::Signals;
use super::{Audio, PollState};
pub(super) struct Cache {
signals: Signals,
revision: u64,
action: u64,
value: Option<Audio>,
retry_at: Instant,
}
impl Cache {
pub(super) fn new(wake: Arc<PollState>) -> Self {
Self {
signals: Signals::start(wake),
revision: 0,
action: u64::MAX,
value: None,
retry_at: Instant::now(),
}
}
pub(super) fn read(&mut self, action: u64) -> Option<Audio> {
self.read_at(action, Instant::now(), super::audio)
}
fn read_at(&mut self, action: u64, now: Instant, read: impl FnOnce() -> Option<Audio>) -> Option<Audio> {
let revision = self.signals.revision();
// Without a live subscription, keep the old bounded polling fallback.
// A missing default sink retries slowly, even if the server is connected.
if !self.signals.alive()
|| revision != self.revision
|| action != self.action
|| (self.value.is_none() && now >= self.retry_at)
{
self.value = read();
self.revision = revision;
self.action = action;
self.retry_at = now + Duration::from_secs(5);
}
self.value.clone()
}
pub(super) fn changed(&self) -> bool {
self.signals.revision() != self.revision
}
}
#[cfg(test)]
mod tests {
use std::sync::{Condvar, Mutex};
use super::*;
fn cache() -> Cache {
Cache {
signals: Signals::test(Arc::new((Mutex::new((0, false, None, false)), Condvar::new()))),
revision: 0,
action: u64::MAX,
value: None,
retry_at: Instant::now(),
}
}
fn sample() -> Option<Audio> {
Some(Audio {
volume: 48,
muted: false,
output: "Speakers".into(),
})
}
#[test]
fn unchanged_audio_never_starts_another_command() {
let mut cache = cache();
let now = Instant::now();
assert_eq!(cache.read_at(0, now, sample).unwrap().volume, 48);
for second in 1..=120 {
let audio = cache.read_at(0, now + Duration::from_secs(second), || panic!("idle query"));
assert_eq!(audio.unwrap().volume, 48);
}
}
#[test]
fn coalesced_events_refresh_volume_mute_and_output_once() {
let mut cache = cache();
let now = Instant::now();
cache.read_at(0, now, sample);
cache.signals.changed();
cache.signals.changed();
assert!(cache.changed());
let audio = cache
.read_at(0, now, || {
Some(Audio {
volume: 17,
muted: true,
output: "Headphones".into(),
})
})
.unwrap();
assert_eq!(audio.volume, 17);
assert!(audio.muted);
assert_eq!(audio.output, "Headphones");
assert!(!cache.changed());
cache.read_at(0, now, || panic!("duplicate query"));
}
#[test]
fn event_during_query_is_not_lost_before_waiting() {
let mut cache = cache();
let state = cache.signals.state.clone();
cache.read_at(0, Instant::now(), || {
state.changed();
sample()
});
assert!(cache.changed());
}
#[test]
fn writes_force_a_fresh_read_even_before_the_signal_arrives() {
let mut cache = cache();
let now = Instant::now();
cache.read_at(0, now, sample);
assert_eq!(
cache
.read_at(1, now, || Some(Audio {
volume: 30,
muted: false,
output: "Speakers".into(),
}))
.unwrap()
.volume,
30
);
}
#[test]
fn disconnect_removes_stale_audio_and_reconnect_refreshes_it() {
let mut cache = cache();
let now = Instant::now();
cache.read_at(0, now, sample);
cache.signals.state.set_alive(false);
assert!(cache.read_at(0, now, || None).is_none());
cache.signals.state.set_alive(true);
assert_eq!(cache.read_at(0, now, sample).unwrap().volume, 48);
}
#[test]
fn missing_default_sink_retries_after_five_seconds() {
let mut cache = cache();
let now = Instant::now();
cache.read_at(0, now, || None);
assert!(
cache
.read_at(0, now + Duration::from_secs(4), || panic!("early retry"))
.is_none()
);
assert!(cache.read_at(0, now + Duration::from_secs(5), sample).is_some());
}
}
@@ -0,0 +1,395 @@
//! One persistent connection observes sink, hardware-route and default-device changes.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
use pipewire as pw;
use pw::proxy::{Listener, ProxyT};
use pw::spa::param::ParamType;
use pw::types::ObjectType;
use super::super::PollState;
pub(super) struct State {
revision: AtomicU64,
alive: AtomicBool,
wake: Arc<PollState>,
}
impl State {
pub(super) fn changed(&self) {
self.revision.fetch_add(1, Ordering::AcqRel);
// Synchronize with the poller's predicate check and condvar wait.
let _guard = self.wake.0.lock().unwrap_or_else(|error| error.into_inner());
self.wake.1.notify_one();
}
pub(super) fn set_alive(&self, alive: bool) {
if self.alive.swap(alive, Ordering::AcqRel) != alive {
self.changed();
}
}
}
#[derive(Default)]
struct Stop {
stopped: Mutex<bool>,
wake: Condvar,
sender: Mutex<Option<pw::channel::Sender<()>>>,
}
pub(super) struct Signals {
pub(super) state: Arc<State>,
stop: Arc<Stop>,
}
impl Signals {
pub(super) fn start(wake: Arc<PollState>) -> Self {
Self::start_remote(wake, None)
}
fn start_remote(wake: Arc<PollState>, remote: Option<String>) -> Self {
let signals = Self::disconnected(wake);
let state = signals.state.clone();
let stop = signals.stop.clone();
let _ = std::thread::Builder::new()
.name("ferese-audio-events".into())
.spawn(move || {
pw::init();
loop {
if *stop.stopped.lock().unwrap() {
break;
}
let _ = monitor(state.clone(), &stop, remote.as_deref());
state.set_alive(false);
*stop.sender.lock().unwrap() = None;
let stopped = stop.stopped.lock().unwrap();
let (stopped, _) = stop
.wake
.wait_timeout_while(stopped, Duration::from_secs(5), |value| !*value)
.unwrap();
if *stopped {
break;
}
}
});
signals
}
fn disconnected(wake: Arc<PollState>) -> Self {
Self {
state: Arc::new(State {
revision: AtomicU64::new(0),
alive: AtomicBool::new(false),
wake,
}),
stop: Arc::new(Stop::default()),
}
}
pub(super) fn revision(&self) -> u64 {
self.state.revision.load(Ordering::Acquire)
}
pub(super) fn alive(&self) -> bool {
self.state.alive.load(Ordering::Acquire)
}
#[cfg(test)]
pub(super) fn test(wake: Arc<PollState>) -> Self {
let signals = Self::disconnected(wake);
signals.state.set_alive(true);
signals
}
#[cfg(test)]
pub(super) fn changed(&self) {
self.state.changed();
}
}
impl Drop for Signals {
fn drop(&mut self) {
*self.stop.stopped.lock().unwrap() = true;
self.stop.wake.notify_one();
if let Some(sender) = self.stop.sender.lock().unwrap().as_ref() {
let _ = sender.send(());
}
}
}
struct Bound {
_listener: Box<dyn Listener>,
_proxy: Box<dyn ProxyT>,
}
fn parameter_events(state: Arc<State>) -> impl Fn(i32, ParamType, u32, u32, Option<&pw::spa::pod::Pod>) {
let values = RefCell::new(HashMap::<u32, Vec<u8>>::new());
move |_, _, index, _, param| {
let bytes = param.map_or(&[][..], pw::spa::pod::Pod::as_bytes);
let mut values = values.borrow_mut();
if values.get(&index).is_none_or(|previous| previous.as_slice() != bytes) {
values.insert(index, bytes.to_vec());
state.changed();
}
}
}
fn bind(
registry: &pw::registry::Registry,
object: &pw::registry::GlobalObject<&pw::spa::utils::dict::DictRef>,
state: Arc<State>,
) -> Result<Option<Bound>, pw::Error> {
let class = object.props.and_then(|props| props.get("media.class"));
let bound = match object.type_ {
ObjectType::Node if class == Some("Audio/Sink") => {
let node: pw::node::Node = registry.bind(object)?;
let info_state = state.clone();
let listener = node
.add_listener_local()
.info(move |_| info_state.changed())
.param(parameter_events(state))
.register();
node.subscribe_params(&[ParamType::Props]);
Bound {
_listener: Box::new(listener),
_proxy: Box::new(node),
}
}
ObjectType::Device if class == Some("Audio/Device") => {
let device: pw::device::Device = registry.bind(object)?;
let listener = device.add_listener_local().param(parameter_events(state)).register();
device.subscribe_params(&[ParamType::Route]);
Bound {
_listener: Box::new(listener),
_proxy: Box::new(device),
}
}
ObjectType::Metadata if object.props.and_then(|props| props.get("metadata.name")) == Some("default") => {
let metadata: pw::metadata::Metadata = registry.bind(object)?;
let listener = metadata
.add_listener_local()
.property(move |subject, key, _, _| {
if subject == 0
&& matches!(key, None | Some("default.audio.sink" | "default.configured.audio.sink"))
{
state.changed();
}
0
})
.register();
Bound {
_listener: Box::new(listener),
_proxy: Box::new(metadata),
}
}
_ => return Ok(None),
};
Ok(Some(bound))
}
fn monitor(state: Arc<State>, stop: &Stop, remote: Option<&str>) -> Result<(), pw::Error> {
let mainloop = pw::main_loop::MainLoopRc::new(None)?;
let context = pw::context::ContextRc::new(&mainloop, None)?;
let properties = remote.map(|remote| pw::properties::properties! { *pw::keys::REMOTE_NAME => remote });
let core = context.connect_rc(properties)?;
let registry = core.get_registry_rc()?;
let objects = Rc::new(RefCell::new(HashMap::new()));
let weak_registry = registry.downgrade();
let weak_loop = mainloop.downgrade();
let add_objects = objects.clone();
let add_state = state.clone();
let remove_state = state.clone();
let _registry_listener = registry
.add_listener_local()
.global(move |object| {
if let Some(registry) = weak_registry.upgrade() {
match bind(&registry, object, add_state.clone()) {
Ok(Some(bound)) => {
add_objects.borrow_mut().insert(object.id, bound);
add_state.changed();
}
Ok(None) => {}
Err(_) => {
if let Some(mainloop) = weak_loop.upgrade() {
mainloop.quit();
}
}
}
}
})
.global_remove(move |id| {
if objects.borrow_mut().remove(&id).is_some() {
remove_state.changed();
}
})
.register();
let pending = core.sync(0)?;
let ready_state = state.clone();
let weak_loop = mainloop.downgrade();
let _core_listener = core
.add_listener_local()
.done(move |id, seq| {
if id == pw::core::PW_ID_CORE && seq == pending {
ready_state.set_alive(true);
}
})
.error(move |_, _, _, _| {
// A rejected subscription is also a loss of coverage. Fall back to
// bounded reads rather than trusting a partially observed graph.
state.set_alive(false);
if let Some(mainloop) = weak_loop.upgrade() {
mainloop.quit();
}
})
.register();
let (sender, receiver) = pw::channel::channel();
let weak_loop = mainloop.downgrade();
let _receiver = receiver.attach(mainloop.loop_(), move |_| {
if let Some(mainloop) = weak_loop.upgrade() {
mainloop.quit();
}
});
*stop.sender.lock().unwrap() = Some(sender);
if !*stop.stopped.lock().unwrap() {
mainloop.run();
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::process::{Child, Command, Stdio};
use std::time::Instant;
use super::*;
struct Server(Child);
impl Drop for Server {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn wait_for(mut predicate: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(10);
while !predicate() {
assert!(Instant::now() < deadline, "audio subscription timed out");
std::thread::sleep(Duration::from_millis(20));
}
}
#[test]
#[ignore = "requires pipewire, pw-cli and pw-metadata; starts a private audio server"]
fn native_subscription_handles_idle_changes_disconnect_reconnect_and_shutdown() {
let root = tempfile::tempdir().unwrap();
let config = root.path().join("pipewire/pipewire.conf.d");
std::fs::create_dir_all(&config).unwrap();
std::fs::write(
config.join("test.conf"),
r#"
context.objects = [
{ factory = metadata args = { metadata.name = default } }
{ factory = adapter args = {
factory.name = support.null-audio-sink
node.name = test-sink
media.class = Audio/Sink
audio.position = [ FL FR ]
} }
]
"#,
)
.unwrap();
let mut command = Command::new("pipewire");
command
.env("XDG_RUNTIME_DIR", root.path())
.env("PIPEWIRE_RUNTIME_DIR", root.path())
.env("XDG_CONFIG_HOME", root.path())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut server = Server(command.spawn().unwrap());
wait_for(|| root.path().join("pipewire-0").exists());
let wake = Arc::new((Mutex::new((0, false, None, false)), Condvar::new()));
let signals = Signals::start_remote(wake, Some(root.path().join("pipewire-0").to_str().unwrap().into()));
wait_for(|| signals.alive());
std::thread::sleep(Duration::from_millis(500));
let revision = signals.revision();
std::thread::sleep(Duration::from_secs(2));
assert_eq!(signals.revision(), revision, "idle audio emitted repeated updates");
let run = |program: &str, args: &[&str]| {
let output = Command::new(program)
.args(args)
.env("PIPEWIRE_RUNTIME_DIR", root.path())
.env("XDG_RUNTIME_DIR", root.path())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
output.stdout
};
run(
"pw-metadata",
&["-n", "default", "0", "default.audio.sink", r#"{"name":"test-sink"}"#],
);
wait_for(|| signals.revision() > revision);
let graph: serde_json::Value = serde_json::from_slice(&run("pw-dump", &[])).unwrap();
let id = graph
.as_array()
.unwrap()
.iter()
.find(|object| object["info"]["props"]["node.name"] == "test-sink")
.unwrap()["id"]
.to_string();
let revision = signals.revision();
run(
"pw-metadata",
&[
"-n",
"default",
"0",
"default.audio.source",
r#"{"name":"irrelevant-source"}"#,
],
);
std::thread::sleep(Duration::from_millis(200));
assert_eq!(
signals.revision(),
revision,
"source changes must not refresh sink status"
);
run("pw-cli", &["set-param", &id, "Props", "{ mute = true }"]);
wait_for(|| signals.revision() > revision);
let revision = signals.revision();
run(
"pw-cli",
&["set-param", &id, "Props", "{ channelVolumes = [ 0.125 0.125 ] }"],
);
wait_for(|| signals.revision() > revision);
let revision = signals.revision();
run("pw-cli", &["destroy", &id]);
wait_for(|| signals.revision() > revision);
server.0.kill().unwrap();
server.0.wait().unwrap();
wait_for(|| !signals.alive());
server = Server(command.spawn().unwrap());
wait_for(|| signals.alive());
let state = signals.state.clone();
drop(signals);
wait_for(|| !state.alive.load(Ordering::Acquire));
drop(server);
}
}