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
+796 -10

No files matched your search

Generated
+1
View File
@@ -1788,6 +1788,7 @@ dependencies = [
"iced_accessibility", "iced_accessibility",
"jiff", "jiff",
"libcosmic", "libcosmic",
"pipewire",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
+13 -4
View File
@@ -740,7 +740,7 @@ impl Ferese {
(rect.width - 12.0).max(1.0), (rect.width - 12.0).max(1.0),
(rect.height - 26.0).max(1.0), (rect.height - 26.0).max(1.0),
); );
let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0).into_iter().collect(); let windows = preview_layout(preview_bounds, &windows, 4.0, 4.0);
WorkspaceCard { WorkspaceCard {
workspace: workspace.id, workspace: workspace.id,
index: view.index, index: view.index,
@@ -889,11 +889,15 @@ impl Ferese {
fn overview_layout(bounds: Rect, windows: &[(WindowId, Rect)]) -> HashMap<WindowId, Rect> { fn overview_layout(bounds: Rect, windows: &[(WindowId, Rect)]) -> HashMap<WindowId, Rect> {
preview_layout(bounds, windows, OVERVIEW_MARGIN, OVERVIEW_GAP) preview_layout(bounds, windows, OVERVIEW_MARGIN, OVERVIEW_GAP)
.into_iter()
.collect()
} }
fn preview_layout(bounds: Rect, windows: &[(WindowId, Rect)], margin: f64, gap: f64) -> HashMap<WindowId, Rect> { // Preserve input order for rendering: the damage tracker includes each
// instance's z index, even when neighboring previews do not overlap.
fn preview_layout(bounds: Rect, windows: &[(WindowId, Rect)], margin: f64, gap: f64) -> Vec<(WindowId, Rect)> {
if windows.is_empty() { if windows.is_empty() {
return HashMap::new(); return Vec::new();
} }
let margin = margin.min(bounds.width * 0.1).min(bounds.height * 0.1); let margin = margin.min(bounds.width * 0.1).min(bounds.height * 0.1);
@@ -1242,7 +1246,12 @@ mod tests {
.map(|id| (WindowId(id), Rect::new(0.0, 0.0, 1000.0, 800.0))) .map(|id| (WindowId(id), Rect::new(0.0, 0.0, 1000.0, 800.0)))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let layout = preview_layout(bounds, &windows, 4.0, 4.0); let layout = preview_layout(bounds, &windows, 4.0, 4.0);
for rect in layout.values() { assert_eq!(
layout.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
windows.iter().map(|(id, _)| *id).collect::<Vec<_>>()
);
for (_, rect) in layout {
assert!((rect.width / rect.height - 1.25).abs() < 0.001); assert!((rect.width / rect.height - 1.25).abs() < 0.001);
assert!(rect.y + rect.height <= bounds.y + bounds.height); assert!(rect.y + rect.height <= bounds.y + bounds.height);
assert!(rect.x + rect.width <= bounds.x + bounds.width); assert!(rect.x + rect.width <= bounds.x + bounds.width);
+11 -5
View File
@@ -136,14 +136,17 @@ pub(super) fn overview_strip_elements(
} }
// Cached title textures are independent of window content and stay readable // Cached title textures are independent of window content and stay readable
// when thumbnails are small. Their pills use the shell's material and radius. // when thumbnails are small. Their pills use the shell's material and radius.
for id in frame.windows.keys() { // Match the grid's stable front-to-back order. A sampled frame's HashMap
if !state.window_belongs_to_output(*id, output) { // has a fresh seed; iterating it changes z indices and damages still captions.
let caption_ids = state.windows.ordered_ids().rev().collect::<Vec<_>>();
for id in caption_ids {
if !state.window_belongs_to_output(id, output) {
continue; continue;
} }
let Some(rect) = frame.windows.get(id).map(|sample| sample.rect) else { let Some(rect) = frame.windows.get(&id).map(|sample| sample.rect) else {
continue; continue;
}; };
let Some((buffer, size)) = state.overview_window_label(*id, scale, rect.width) else { let Some((buffer, size)) = state.overview_window_label(id, scale, rect.width) else {
continue; continue;
}; };
let width = (f64::from(size.w) / scale + 16.0).min(rect.width); let width = (f64::from(size.w) / scale + 16.0).min(rect.width);
@@ -227,7 +230,10 @@ pub(super) fn overview_strip_elements(
RoundedRect::new(*rect, output_geometry.loc, scale, 4.0), RoundedRect::new(*rect, output_geometry.loc, scale, 4.0),
scale, scale,
alpha, alpha,
true, // The damage tracker tracks each miniature's crop,
// scale, alpha and geometry. Its corner radius is fixed,
// so an unchanged clip needs no extra full damage.
false,
output, output,
programs.clone(), programs.clone(),
ConstrainScaleBehavior::Stretch, ConstrainScaleBehavior::Stretch,
+57
View File
@@ -70,6 +70,63 @@ These are deterministic scheduling counts, not live DRM CPU, latency or battery
measurements. Verify those separately with the same hardware workload before measurements. Verify those separately with the same hardware workload before
making claims about power savings. making claims about power savings.
## Settled overview damage
On 2026-10-02, a release-build comparison against `6f2951c` used four parked
foot terminals in a private nested desktop: 1418 × 1692 pixels, 2× scale, 60 Hz,
Intel Core Ultra 7 258V and Mesa 26.2.3. Ten-second process samples measured
5.9% CPU before and 1.9% after stabilizing caption/thumbnail drawing order and
removing forced full damage from unchanged rounded thumbnails.
A settled five-second interval changed from 300 damaged frames to zero.
The nested backend still makes about 300 no-damage render attempts per interval;
this change avoids unchanged submissions rather than changing nested scheduling.
These short samples exclude host compositor CPU and do not measure battery
discharge or direct DRM performance.
The opt-in regression checks a full settled interval, a real client content
commit, and settling again. It fails against the original release.
```sh
FERESE_TEST_OVERVIEW_DAMAGE=1 FERESE_TEST_BINARY=target/release/ferese \
FERESE_TEST_CTL=target/release/feresectl \
python3 scripts/tests/test_overview_damage_isolated.py
```
## Idle shell audio polling
On 2026-10-02, the installed release shell was compared with the audio-event
cache in private nested desktops on the same machine. Each release had ten
seconds to settle, followed by three twenty-second samples without `perf`.
CPU includes waited child processes; one core is 100%.
| Measurement | Periodic audio queries | Persistent audio watcher |
| --- | --- | --- |
| Combined CPU, sample range | 2.25–3.15% | 0.90% |
| Combined CPU, median | 3.10% | 0.90% |
| Shell CPU, median | 0.75% | 0.75% |
| Child-process CPU, median | 2.35% | 0.15% |
| Shell RSS | 32.3–32.4 MiB | 34.9 MiB |
A separate twenty-second `perf` sample recorded twenty `wpctl` executions before
and zero afterward. The new connection observes sink properties, device routes,
and default-sink metadata. Relevant events and control actions invalidate the
cached reading; the existing `wpctl` reader preserves volume and device semantics.
Disconnected subscriptions retain bounded polling, and missing sinks retry.
Brightness and other status polling remain unchanged.
These are short nested observations, exclude the host compositor, and do not
measure battery discharge. Raw measurements and profiles were saved locally in
`/tmp/ferese-shell-profile-20261002`.
The subscription regression starts its own PipeWire server, changes default
metadata, mute and volume, removes a sink, restarts the server, and checks shutdown.
It does not change the desktop's audio server.
```sh
cargo test --release --locked -p ferese-shell native_subscription -- --ignored
```
## Historical results ## Historical results
On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale, On 2026-09-27 at `08a5866`, two nested runs used a 1422 × 1696 output at 2× scale,
@@ -0,0 +1,142 @@
"""Check settled overview damage against real clients on a private nested desktop.
FERESE_TEST_OVERVIEW_DAMAGE=1 FERESE_TEST_BINARY=target/release/ferese \
FERESE_TEST_CTL=target/release/feresectl python3 scripts/tests/test_overview_damage_isolated.py
Requires a Wayland host, foot and dbus-daemon. Never connects clients to the host.
"""
import errno
import json
import os
from pathlib import Path
import re
import signal
import subprocess
import tempfile
import time
import unittest
@unittest.skipUnless(os.environ.get("FERESE_TEST_OVERVIEW_DAMAGE") == "1", "nested damage test is opt-in")
class OverviewDamageTest(unittest.TestCase):
def test_stationary_overview_stops_damage_but_live_content_still_repaints(self):
repo = Path(__file__).resolve().parents[2]
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
control = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
children = []
with tempfile.TemporaryDirectory(prefix="ferese-overview-damage-") as temporary:
root = Path(temporary)
runtime = root / "runtime"
runtime.mkdir(mode=0o700)
config = root / "config/ferese"
config.mkdir(parents=True)
(config / "config.kdl").write_text("animations { enabled #true; }\n")
display = Path(os.environ["WAYLAND_DISPLAY"])
if not display.is_absolute():
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
env = dict(os.environ, WAYLAND_DISPLAY=str(display), XDG_RUNTIME_DIR=str(runtime),
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
FERESE_TRACE_PERFORMANCE="1", RUST_LOG="ferese=info")
env.pop("WAYLAND_SOCKET", None)
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
log_path = root / "compositor.log"
def launch(command, **kwargs):
child = subprocess.Popen(command, env=env, start_new_session=True, **kwargs)
children.append(child)
return child
def command(*args):
return json.loads(subprocess.check_output([str(control), *args], env=env, timeout=5))
def wait_for(predicate, seconds=12):
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
result = predicate()
if result is not None and result is not False:
return result
time.sleep(.025)
self.fail("Timed out waiting for compositor state:\n" + log_path.read_text())
def reports():
return [dict((key, int(value)) for key, value in re.findall(r"(\w+)=(\d+)", line))
for line in log_path.read_text().splitlines() if "render performance" in line]
def stationary_interval():
# The first report includes time before this call. The second
# covers a full five seconds of the requested stationary scene.
count = len(reports())
wait_for(lambda: len(reports()) >= count + 2)
return reports()[-1]
writer = None
with log_path.open("w") as log:
try:
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"],
stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
launch([str(binary), "--backend", "nested"], stdout=log, stderr=log)
wait_for(lambda: (runtime / "ferese/control.sock").is_socket())
env["WAYLAND_DISPLAY"] = str(next(
path for path in runtime.glob("wayland-*") if not path.name.endswith(".lock")
))
fifo = root / "content"
os.mkfifo(fifo)
for index in range(4):
app = f"ferese.test.overview.{index}"
client = (["sh", "-c", 'cat "$1"; sleep 120', "sh", str(fifo)]
if index == 0 else ["sleep", "120"])
launch(["foot", "--app-id", app, *client], stdout=log, stderr=log)
wait_for(lambda: any(window["app_id"] == app and window["width"] > 0
for window in command("get-windows")))
def open_writer():
try:
return os.open(fifo, os.O_WRONLY | os.O_NONBLOCK)
except OSError as error:
if error.errno != errno.ENXIO: # Reader has not opened yet.
raise
return None
writer = wait_for(open_writer)
command("toggle-overview")
time.sleep(2)
quiet = stationary_interval()
self.assertEqual(quiet["frames"], 0, str(quiet))
self.assertEqual(quiet["damaged_pixels"], 0, str(quiet))
self.assertGreater(quiet["no_damage_frames"], 0, str(quiet))
count = len(reports())
os.write(writer, b"Live overview content must still update.\n")
wait_for(lambda: len(reports()) > count)
changed = reports()[-1]
self.assertGreater(changed["frames"], 0, str(changed))
self.assertGreater(changed["damaged_pixels"], 0, str(changed))
# The content commit must not start another perpetual repaint.
quiet = stationary_interval()
self.assertEqual(quiet["frames"], 0, str(quiet))
self.assertGreater(quiet["no_damage_frames"], 0, str(quiet))
finally:
if writer is not None:
os.close(writer)
for child in reversed(children):
if child.poll() is None:
os.killpg(child.pid, signal.SIGTERM)
try:
child.wait(timeout=3)
except subprocess.TimeoutExpired:
os.killpg(child.pid, signal.SIGKILL)
child.wait(timeout=3)
if child.stdout is not None:
child.stdout.close()
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -22,3 +22,4 @@ tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt", "sync"] } tokio = { version = "1", features = ["time", "rt", "sync"] }
wayland-client.workspace = true wayland-client.workspace = true
zbus = "5.19.0" zbus = "5.19.0"
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
+6 -1
View File
@@ -1,4 +1,5 @@
//! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state. //! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state.
mod audio_cache;
mod bluetooth; mod bluetooth;
mod dbus_cache; mod dbus_cache;
mod network; mod network;
@@ -155,12 +156,14 @@ impl Service {
let mut session_bus = StatusBus::new(false); let mut session_bus = StatusBus::new(false);
let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager"); let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager");
let mut bluetooth = dbus_cache::Cache::new("org.bluez"); 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 // A poll overlapping a write is discarded below. Wait for the
// write to finish before retrying instead of launching commands // write to finish before retrying instead of launching commands
// repeatedly while a slow control operation is still running. // 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 mut snapshot = poll(&mut system_bus, &mut session_bus);
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);
let state = polling.0.lock().unwrap(); let state = polling.0.lock().unwrap();
@@ -175,9 +178,11 @@ impl Service {
generation: state.0, generation: state.0,
error: state.2.clone(), error: state.2.clone(),
})); }));
if !audio.changed() {
let _ = polling.1.wait_timeout(state, Duration::from_secs(2)); let _ = polling.1.wait_timeout(state, Duration::from_secs(2));
} }
} }
}
}); });
thread::spawn(move || { thread::spawn(move || {
@@ -361,7 +366,7 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot {
Snapshot { Snapshot {
network: None, network: None,
bluetooth: None, bluetooth: None,
audio: audio(), audio: None,
battery: battery(), battery: battery(),
power_profiles: power_profiles(system_bus), power_profiles: power_profiles(system_bus),
brightness: brightness(), 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);
}
}