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",
|
"iced_accessibility",
|
||||||
"jiff",
|
"jiff",
|
||||||
"libcosmic",
|
"libcosmic",
|
||||||
|
"pipewire",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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"] }
|
||||||
@@ -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(®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