From ab0c174948e57822cd0cd1fe07762d67a5f6ed73 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Fri, 2 Oct 2026 02:34:35 +0100 Subject: [PATCH] Reduce idle shell polling and stabilize overview damage --- Cargo.lock | 1 + compositor/src/overview.rs | 17 +- compositor/src/render/overview.rs | 16 +- docs/performance-baseline.md | 57 +++ .../tests/test_overview_damage_isolated.py | 142 +++++++ shell/ferese-shell/Cargo.toml | 1 + shell/ferese-shell/src/status.rs | 9 +- shell/ferese-shell/src/status/audio_cache.rs | 170 ++++++++ .../src/status/audio_cache/signals.rs | 395 ++++++++++++++++++ 9 files changed, 797 insertions(+), 11 deletions(-) create mode 100644 scripts/tests/test_overview_damage_isolated.py create mode 100644 shell/ferese-shell/src/status/audio_cache.rs create mode 100644 shell/ferese-shell/src/status/audio_cache/signals.rs diff --git a/Cargo.lock b/Cargo.lock index 7555f0c..08caaaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1788,6 +1788,7 @@ dependencies = [ "iced_accessibility", "jiff", "libcosmic", + "pipewire", "serde", "serde_json", "tempfile", diff --git a/compositor/src/overview.rs b/compositor/src/overview.rs index 251aa21..5d13529 100644 --- a/compositor/src/overview.rs +++ b/compositor/src/overview.rs @@ -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 { preview_layout(bounds, windows, OVERVIEW_MARGIN, OVERVIEW_GAP) + .into_iter() + .collect() } -fn preview_layout(bounds: Rect, windows: &[(WindowId, Rect)], margin: f64, gap: f64) -> HashMap { +// 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::>(); let layout = preview_layout(bounds, &windows, 4.0, 4.0); - for rect in layout.values() { + assert_eq!( + layout.iter().map(|(id, _)| *id).collect::>(), + windows.iter().map(|(id, _)| *id).collect::>() + ); + + 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); diff --git a/compositor/src/render/overview.rs b/compositor/src/render/overview.rs index db5b005..eedf555 100644 --- a/compositor/src/render/overview.rs +++ b/compositor/src/render/overview.rs @@ -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::>(); + 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, diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 26ca461..450c1c0 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -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, diff --git a/scripts/tests/test_overview_damage_isolated.py b/scripts/tests/test_overview_damage_isolated.py new file mode 100644 index 0000000..710e441 --- /dev/null +++ b/scripts/tests/test_overview_damage_isolated.py @@ -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() diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index 211003d..bac516f 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -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"] } diff --git a/shell/ferese-shell/src/status.rs b/shell/ferese-shell/src/status.rs index 88dfdbb..24931d6 100644 --- a/shell/ferese-shell/src/status.rs +++ b/shell/ferese-shell/src/status.rs @@ -1,4 +1,5 @@ //! Bounded, off-UI-thread adapters. A missing service is `None`, never a fake state. +mod audio_cache; mod bluetooth; mod dbus_cache; mod network; @@ -155,12 +156,14 @@ impl Service { let mut session_bus = StatusBus::new(false); let mut network = dbus_cache::Cache::new("org.freedesktop.NetworkManager"); let mut bluetooth = dbus_cache::Cache::new("org.bluez"); + let mut audio = audio_cache::Cache::new(polling.clone()); // A poll overlapping a write is discarded below. Wait for the // write to finish before retrying instead of launching commands // repeatedly while a slow control operation is still running. while let Some(before) = wait_for_poll(&polling) { let mut snapshot = poll(&mut system_bus, &mut session_bus); + snapshot.audio = audio.read(before); snapshot.network = network.read(before, network::read); snapshot.bluetooth = bluetooth.read(before, bluetooth::read); let state = polling.0.lock().unwrap(); @@ -175,7 +178,9 @@ impl Service { generation: state.0, error: state.2.clone(), })); - let _ = polling.1.wait_timeout(state, Duration::from_secs(2)); + if !audio.changed() { + let _ = polling.1.wait_timeout(state, Duration::from_secs(2)); + } } } }); @@ -361,7 +366,7 @@ fn poll(system_bus: &mut StatusBus, session_bus: &mut StatusBus) -> Snapshot { Snapshot { network: None, bluetooth: None, - audio: audio(), + audio: None, battery: battery(), power_profiles: power_profiles(system_bus), brightness: brightness(), diff --git a/shell/ferese-shell/src/status/audio_cache.rs b/shell/ferese-shell/src/status/audio_cache.rs new file mode 100644 index 0000000..96474f6 --- /dev/null +++ b/shell/ferese-shell/src/status/audio_cache.rs @@ -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