Reduce idle shell polling and stabilize overview damage
This commit is contained in:
1 parent
6f2951c6b0
commit
ab0c174948
9 files changed
+796
-10
No files matched your search
Generated
+1
@@ -1788,6 +1788,7 @@ dependencies = [
|
||||
"iced_accessibility",
|
||||
"jiff",
|
||||
"libcosmic",
|
||||
"pipewire",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
|
||||
@@ -740,7 +740,7 @@ impl Ferese {
|
||||
(rect.width - 12.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 {
|
||||
workspace: workspace.id,
|
||||
index: view.index,
|
||||
@@ -889,11 +889,15 @@ impl Ferese {
|
||||
|
||||
fn overview_layout(bounds: Rect, windows: &[(WindowId, Rect)]) -> HashMap<WindowId, Rect> {
|
||||
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() {
|
||||
return HashMap::new();
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
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)))
|
||||
.collect::<Vec<_>>();
|
||||
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.y + rect.height <= bounds.y + bounds.height);
|
||||
assert!(rect.x + rect.width <= bounds.x + bounds.width);
|
||||
|
||||
@@ -136,14 +136,17 @@ pub(super) fn overview_strip_elements(
|
||||
}
|
||||
// Cached title textures are independent of window content and stay readable
|
||||
// when thumbnails are small. Their pills use the shell's material and radius.
|
||||
for id in frame.windows.keys() {
|
||||
if !state.window_belongs_to_output(*id, output) {
|
||||
// Match the grid's stable front-to-back order. A sampled frame's HashMap
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
};
|
||||
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;
|
||||
};
|
||||
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),
|
||||
scale,
|
||||
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,
|
||||
programs.clone(),
|
||||
ConstrainScaleBehavior::Stretch,
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
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()
|
||||
@@ -22,3 +22,4 @@ tempfile.workspace = true
|
||||
tokio = { version = "1", features = ["time", "rt", "sync"] }
|
||||
wayland-client.workspace = true
|
||||
zbus = "5.19.0"
|
||||
pipewire = { version = "0.10.1", features = ["v0_3_65"] }
|
||||
@@ -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,9 +178,11 @@ impl Service {
|
||||
generation: state.0,
|
||||
error: state.2.clone(),
|
||||
}));
|
||||
if !audio.changed() {
|
||||
let _ = polling.1.wait_timeout(state, Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
thread::spawn(move || {
|
||||
@@ -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(®istry, 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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user