From 979afbd213338ca3f188827d422f2952f4a0aa9e Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sun, 25 Oct 2026 14:54:50 +0100 Subject: [PATCH] Reduce idle CPU use and prioritize compositor rendering (#62) Avoid background title updates when no shell window list is visible, and skip output and capture invalidation for titles outside compositor decorations. Preserve focused titles, fresh window lists and capture privacy. Request high EGL context priority with fallback, cover rendering and native title behavior in CI, and keep audio recording flowing while video capture is idle. --- .github/workflows/workspace-checks.yml | 8 +- compositor/src/backends/direct.rs | 5 +- compositor/src/backends/direct/capture.rs | 23 +- compositor/src/backends/direct/context.rs | 134 +++++++++ compositor/src/handlers/xdg_shell.rs | 19 +- compositor/src/input/launcher_touch_tests.rs | 6 +- compositor/src/resume/tests.rs | 5 + compositor/src/shell_control.rs | 74 +++-- compositor/src/state/reconciliation.rs | 103 ++++++- .../src/state/window_lifecycle_tests.rs | 99 +++++++ docs/README.md | 1 - docs/development.md | 1 - docs/rendering-performance.md | 274 ------------------ portal/src/recorder.rs | 52 +++- protocols/ferese-shell-v1.xml | 16 +- scripts/ci/run-native-checks.py | 1 + scripts/tests/fixtures/window-titles.c | 118 ++++++++ .../tests/test_portal_background_isolated.py | 4 +- .../test_shell_title_updates_isolated.py | 183 ++++++++++++ shell/ferese-shell/src/control.rs | 49 +++- shell/ferese-shell/src/main.rs | 13 + 21 files changed, 876 insertions(+), 312 deletions(-) create mode 100644 compositor/src/backends/direct/context.rs delete mode 100644 docs/rendering-performance.md create mode 100644 scripts/tests/fixtures/window-titles.c create mode 100644 scripts/tests/test_shell_title_updates_isolated.py diff --git a/.github/workflows/workspace-checks.yml b/.github/workflows/workspace-checks.yml index 2707f3a..1cf95ea 100644 --- a/.github/workflows/workspace-checks.yml +++ b/.github/workflows/workspace-checks.yml @@ -52,11 +52,13 @@ jobs: FERESE_TEST_PORTAL_INTERFACES: ${{ github.workspace }}/build/portal-contracts/data FERESE_TEST_PORTAL_BACKGROUND: "1" run: python3 -m unittest discover -s scripts/tests -v - - name: Test capture privacy, resize dependencies and cursor composition + - name: Test EGL initialization, capture privacy and cursor composition env: LIBGL_ALWAYS_SOFTWARE: "1" EGL_PLATFORM: surfaceless run: | + cargo +1.95.0 test --locked -p ferese backends::direct::context::tests -- --include-ignored --nocapture + cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::unchanged_capture_skips_preparation_without_losing_source_updates -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture @@ -89,8 +91,8 @@ jobs: sudo apt-get update sudo apt-get install -y weston xwayland x11-utils xauth dbus-daemon \ build-essential pkg-config clang libclang-dev libxcb1-dev libxcb-cursor-dev libxkbcommon-dev \ - libwayland-dev libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \ - libegl1 libegl-mesa0 libgl1-mesa-dri fonts-dejavu-core + libwayland-dev wayland-protocols libgbm1 libseat1 libinput10 libpipewire-0.3-0t64 \ + libegl1 libegl-mesa0 libgl1-mesa-dri mesa-vulkan-drivers fonts-dejavu-core - name: Create the shared X11 socket directory run: sudo install -d -m 1777 -o root -g root /tmp/.X11-unix - uses: actions/download-artifact@v8 diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index f4fc002..1f99cfb 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -6,6 +6,7 @@ use std::path::Path; use std::time::{Duration, Instant}; pub(crate) mod capture; +mod context; mod crtc_assignment; mod diagnostics; pub(crate) mod feedback; @@ -29,7 +30,7 @@ use smithay::backend::allocator::{Buffer, Fourcc}; use smithay::backend::drm::compositor::{DrmCompositor, FrameFlags, PrimaryPlaneElement}; use smithay::backend::drm::exporter::gbm::GbmFramebufferExporter; use smithay::backend::drm::{DrmDevice, DrmDeviceFd, DrmEvent, DrmEventTime, DrmNode, NodeType}; -use smithay::backend::egl::{EGLContext, EGLDisplay}; +use smithay::backend::egl::EGLDisplay; use smithay::backend::input::InputEvent; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::renderer::element::{RenderElement, UnderlyingStorage}; @@ -1004,7 +1005,7 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result() + .unwrap() + .lock() + .unwrap() + .title = Some("Metadata without new pixels".into()); + }); + ::title_changed( + &mut state, + toplevel.clone(), + ); + prepare(&mut state, &mut renderer, &mut target); + assert_eq!( + target.as_ref().unwrap().preparations, + 1, + "a title change must reuse the captured pixels" + ); + assert_eq!(pixels(&mut renderer, &mut target), original); super::super::render_cursor(&mut state); prepare(&mut state, &mut renderer, &mut target); assert_eq!(target.as_ref().unwrap().preparations, 1); diff --git a/compositor/src/backends/direct/context.rs b/compositor/src/backends/direct/context.rs new file mode 100644 index 0000000..8a410cd --- /dev/null +++ b/compositor/src/backends/direct/context.rs @@ -0,0 +1,134 @@ +use std::fmt::Display; + +use smithay::backend::egl::context::ContextPriority; +use smithay::backend::egl::{EGLContext, EGLDisplay, Error}; + +pub(super) fn try_new(display: &EGLDisplay) -> Result { + // Smithay omits the hint without EGL_IMG_context_priority and logs the + // queried priority when available. A successful request need not grant High. + try_with_high_priority(|priority| match priority { + Some(priority) => EGLContext::new_with_priority(display, priority), + None => EGLContext::new(display), + }) +} + +fn try_with_high_priority( + mut create: impl FnMut(Option) -> Result, +) -> Result { + create(Some(ContextPriority::High)).or_else(|error| { + tracing::warn!( + %error, + "EGL context creation with a high-priority hint failed; retrying without the hint" + ); + create(None) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_high_without_recreating_a_successful_context() { + let mut requests = Vec::new(); + let context = try_with_high_priority(|priority| { + requests.push(priority); + Ok::<_, &str>("context") + }); + + assert_eq!(context, Ok("context")); + assert_eq!(requests, [Some(ContextPriority::High)]); + } + + #[test] + fn retries_without_a_hint_when_priority_creation_fails() { + let mut requests = Vec::new(); + let context = try_with_high_priority(|priority| { + requests.push(priority); + match priority { + Some(_) => Err("priority rejected"), + None => Ok("fallback context"), + } + }); + + assert_eq!(context, Ok("fallback context")); + assert_eq!(requests, [Some(ContextPriority::High), None]); + } + + #[test] + fn propagates_fallback_failure_without_retrying_again() { + let mut requests = Vec::new(); + let context = try_with_high_priority(|priority| { + requests.push(priority); + match priority { + Some(_) => Err::<(), _>("priority rejected"), + None => Err("default creation failed"), + } + }); + + assert_eq!(context, Err("default creation failed")); + assert_eq!(requests, [Some(ContextPriority::High), None]); + } + + #[test] + #[ignore = "requires an EGL device (software Mesa is supported)"] + fn creates_a_usable_egl_context() { + use smithay::backend::allocator::Fourcc; + use smithay::backend::egl::EGLDevice; + use smithay::backend::renderer::gles::{GlesRenderer, GlesTexture}; + use smithay::backend::renderer::{Bind, Color32F, ExportMem, Frame, Offscreen, Renderer}; + use smithay::utils::{Physical, Rectangle, Transform}; + + let software_only = std::env::var("LIBGL_ALWAYS_SOFTWARE").as_deref() == Ok("1"); + let mut tested = 0; + for device in EGLDevice::enumerate().unwrap() { + let software = device + .extensions() + .iter() + .any(|name| name == "EGL_MESA_device_software"); + if software_only && !software { + continue; + } + eprintln!( + "EGL context test: software={software}, render_node={:?}", + device.render_device_path().ok() + ); + // SAFETY: the enumerated EGL device remains owned by the display. + let display = unsafe { EGLDisplay::new(device).unwrap() }; + let context = try_new(&display).unwrap(); + // SAFETY: this fresh context is not current on another thread. + let mut renderer = unsafe { GlesRenderer::new(context).unwrap() }; + let mut texture: GlesTexture = renderer + .create_buffer(Fourcc::Abgr8888, (8, 8).into()) + .unwrap(); + let rect = Rectangle::::from_size((8, 8).into()); + { + let mut target = renderer.bind(&mut texture).unwrap(); + { + let mut frame = renderer + .render(&mut target, (8, 8).into(), Transform::Normal) + .unwrap(); + frame.clear(Color32F::new(1., 0., 0., 1.), &[rect]).unwrap(); + frame.finish().unwrap().wait().unwrap(); + } + let mapping = renderer + .copy_framebuffer( + &target, + Rectangle::from_size((8, 8).into()), + Fourcc::Abgr8888, + ) + .unwrap(); + let pixels = renderer.map_texture(&mapping).unwrap(); + assert_eq!(pixels.len(), 8 * 8 * 4); + assert!( + pixels + .chunks_exact(4) + .all(|pixel| pixel == [255, 0, 0, 255]) + ); + } + renderer.egl_context().unbind().unwrap(); + tested += 1; + } + assert!(tested > 0, "no matching EGL device was exercised"); + } +} diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 58b84b2..5deb9a8 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -47,8 +47,23 @@ impl XdgShellHandler for Ferese { } self.refresh_idle_inhibition(); self.send_shell_snapshots(); - for output in self.surface_outputs(surface.wl_surface()) { - self.defer_output_redraw(output); + // Ordinary titles belong to the shell; only compositor-drawn labels + // need output damage. Privacy changes already invalidate above. + let tab_title = self + .windows + .id_for_surface(surface.wl_surface()) + .is_some_and(|id| { + self.workspaces + .workspace_for_window(id) + .is_some_and(|workspace| { + self.visible_tab_bars(workspace) + .any(|bar| bar.group.windows.contains(&id)) + }) + }); + if self.overview.is_presenting() || tab_title { + for output in self.surface_outputs(surface.wl_surface()) { + self.defer_output_redraw(output); + } } } } diff --git a/compositor/src/input/launcher_touch_tests.rs b/compositor/src/input/launcher_touch_tests.rs index d7a5bf7..96737e7 100644 --- a/compositor/src/input/launcher_touch_tests.rs +++ b/compositor/src/input/launcher_touch_tests.rs @@ -125,7 +125,11 @@ fn outside_launcher_touches_are_consumed_until_up_or_cancel() { .insert_client(server, Arc::new(crate::state::ClientState::default())) .unwrap(); let shell = client - .create_resource::(&state.display_handle, 10, ()) + .create_resource::( + &state.display_handle, + 10, + Default::default(), + ) .unwrap(); state.shell_resources.push(shell.downgrade()); state.process_input_event::(InputEvent::TouchDown { diff --git a/compositor/src/resume/tests.rs b/compositor/src/resume/tests.rs index 0c03e05..23eddd6 100644 --- a/compositor/src/resume/tests.rs +++ b/compositor/src/resume/tests.rs @@ -165,6 +165,11 @@ fn reconnect_reconciles_a_resume_missed_during_the_outage() { *address.lock().unwrap() = "unix:path=/nonexistent/ferese-test-bus".into(); bus.stop(); attempts.recv_timeout(Duration::from_secs(1)).unwrap(); + // The old listener has stopped, so no more subscription probes can arrive. + // Slow delivery can queue several before confirm_subscription returns. + for event in events.try_iter() { + assert_eq!(event, Refresh::Resumed); + } let replacement = Bus::new(); let manager = replacement.manager(); diff --git a/compositor/src/shell_control.rs b/compositor/src/shell_control.rs index bc55c37..d43116b 100644 --- a/compositor/src/shell_control.rs +++ b/compositor/src/shell_control.rs @@ -6,13 +6,26 @@ use ferese_protocols::shell::v1::server::{ferese_shell_manager_v1, ferese_shell_ use smithay::reexports::wayland_server::{ Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, }; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::Ferese; use crate::private_client::ClientCapabilities; use crate::state::ClientState; pub(crate) fn init_global(display: &DisplayHandle) { - display.create_global::(10, ()); + display.create_global::(11, ()); +} + +pub(crate) struct ShellData { + background_title_updates: AtomicBool, +} + +impl Default for ShellData { + fn default() -> Self { + Self { + background_title_updates: AtomicBool::new(true), + } + } } pub(crate) fn config_chunks(source: &str) -> Vec<&str> { @@ -70,7 +83,7 @@ impl Dispatch for Ferese { ) { match request { ferese_shell_manager_v1::Request::GetShell { id } => { - let shell = data_init.init(id, ()); + let shell = data_init.init(id, ShellData::default()); shell.capabilities(1); if shell.version() >= 2 @@ -88,20 +101,28 @@ impl Dispatch for Ferese { } } -impl Dispatch for Ferese { +impl Dispatch for Ferese { fn request( state: &mut Self, _client: &Client, shell: &FereseShellV1, request: ferese_shell_v1::Request, - _data: &(), + data: &ShellData, _display: &DisplayHandle, _data_init: &mut DataInit<'_, Self>, ) { - if state.session_lock.active() { - return; - } match request { + ferese_shell_v1::Request::SetBackgroundTitleUpdates { enabled } => { + if data + .background_title_updates + .swap(enabled != 0, Ordering::Relaxed) + != (enabled != 0) + && !state.session_lock.active() + { + state.send_shell_snapshot(shell); + } + } + _ if state.session_lock.active() => {} ferese_shell_v1::Request::Thumbnail { token, window_hi, @@ -222,7 +243,7 @@ impl Dispatch for Ferese { state: &mut Self, _client: smithay::reexports::wayland_server::backend::ClientId, shell: &FereseShellV1, - _data: &(), + _data: &ShellData, ) { if state.logout_owner.as_ref() == Some(&shell.id()) { state.cancel_logout_confirmation(); @@ -410,10 +431,17 @@ impl Ferese { self.last_shell_snapshot = None; return; } + let background_title_updates = self.shell_resources.iter().any(|resource| { + resource.upgrade().is_ok_and(|shell| { + shell + .data::() + .is_some_and(|data| data.background_title_updates.load(Ordering::Relaxed)) + }) + }); if self .last_shell_snapshot .as_ref() - .is_some_and(|snapshot| self.shell_snapshot_matches(snapshot)) + .is_some_and(|snapshot| self.shell_snapshot_matches(snapshot, background_title_updates)) { return; } @@ -428,7 +456,11 @@ impl Ferese { self.last_shell_snapshot = Some(snapshot); } - fn shell_snapshot_matches(&self, snapshot: &ShellSnapshot) -> bool { + fn shell_snapshot_matches( + &self, + snapshot: &ShellSnapshot, + background_title_updates: bool, + ) -> bool { if snapshot.workspace_preview_revision != self.workspace_preview_revision || snapshot.binding_mode.as_deref() != self.binding_modes.active() { @@ -495,7 +527,9 @@ impl Ferese { if previous.id != id || previous.workspace != workspace || previous.app_id != app_id - || previous.title != title + || ((background_title_updates + || state.contains(ferese_shell_v1::WindowState::Focused)) + && previous.title != title) || previous.state != state || previous.source_geometry != self.shell_window_geometry(id) { @@ -907,7 +941,7 @@ mod tests { for _ in 0..100 { // Building a snapshot for metadata changes must reuse layout geometry. assert_eq!(state.shell_snapshot(), original); - assert!(state.shell_snapshot_matches(&original)); + assert!(state.shell_snapshot_matches(&original, true)); } assert_eq!(state.preview_cache_stats(), initial_cache); @@ -915,25 +949,25 @@ mod tests { .workspaces .insert_window(WindowId(99), ferese_layout::Axis::Horizontal, 0.5) .unwrap(); - assert!(!state.shell_snapshot_matches(&original)); + assert!(!state.shell_snapshot_matches(&original, true)); let occupied = state.shell_snapshot(); assert_eq!(state.preview_cache_stats().0, initial_cache.0 + 1); - assert!(state.shell_snapshot_matches(&occupied)); + assert!(state.shell_snapshot_matches(&occupied, true)); let output_id = state.output_ids[&output]; let next = state.workspaces.create_workspace(); state .output_workspaces .assign_workspace(output_id, next) .unwrap(); - assert!(!state.shell_snapshot_matches(&occupied)); + assert!(!state.shell_snapshot_matches(&occupied, true)); let assigned = state.shell_snapshot(); state .output_workspaces .switch_workspace(output_id, next) .unwrap(); - assert!(!state.shell_snapshot_matches(&assigned)); + assert!(!state.shell_snapshot_matches(&assigned, true)); let selected = state.shell_snapshot(); - assert!(state.shell_snapshot_matches(&selected)); + assert!(state.shell_snapshot_matches(&selected, true)); assert_eq!( state.output_workspaces.active_workspace(output_id), Some(next) @@ -983,7 +1017,7 @@ mod tests { ) .unwrap(); assert!( - !state.shell_snapshot_matches(&selected), + !state.shell_snapshot_matches(&selected, true), "hidden previews must invalidate even with no visible window geometry to change" ); assert_ne!( @@ -991,7 +1025,7 @@ mod tests { hidden_before ); let refreshed = state.shell_snapshot(); - assert!(state.shell_snapshot_matches(&refreshed)); + assert!(state.shell_snapshot_matches(&refreshed, true)); state .reload_config_source( "layout { inner-gap 30; outer-gap 40; smart-gaps #false; }; input { repeat-rate 35; }".into(), @@ -1002,7 +1036,7 @@ mod tests { "unrelated settings do not invalidate layouts" ); state.unregister_output(&output); - assert!(!state.shell_snapshot_matches(&refreshed)); + assert!(!state.shell_snapshot_matches(&refreshed, true)); state.workspace_preview_rects(); assert_eq!( state.preview_cache_stats().1, diff --git a/compositor/src/state/reconciliation.rs b/compositor/src/state/reconciliation.rs index 0b70c1c..365d312 100644 --- a/compositor/src/state/reconciliation.rs +++ b/compositor/src/state/reconciliation.rs @@ -384,6 +384,103 @@ mod tests { result } + #[test] + fn shell_title_interest_suppresses_background_churn_and_refreshes_on_subscription() { + if !crate::startup_tests::private_runtime( + "state::reconciliation::tests::shell_title_interest_suppresses_background_churn_and_refreshes_on_subscription", + ) { + return; + } + use crate::shell_control::ShellData; + use crate::state::capture_privacy::tests::window; + use ferese_protocols::shell::v1::server::ferese_shell_v1::FereseShellV1; + + let mut event_loop = EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut event_loop); + state.begin_desktop_transition(); + let changes = state + .publish_desktop(vec![output("titles", 0, 1200)]) + .unwrap(); + state.finish_desktop_transition(changes); + let (background, _background_wire) = window(&mut state, &mut event_loop, 0xff112233); + let (focused, _focused_wire) = window(&mut state, &mut event_loop, 0xff334455); + let background = state.windows.ids()[&background]; + let focused = state.windows.ids()[&focused]; + assert!(state.activate_managed_window(focused)); + let (server, mut wire) = UnixStream::pair().unwrap(); + let client = state + .display_handle + .insert_client(server, Arc::new(ClientState::default())) + .unwrap(); + let shell = client + .create_resource::( + &state.display_handle, + 11, + ShellData::default(), + ) + .unwrap(); + state.shell_resources.push(shell.downgrade()); + state.send_shell_snapshots(); + events(&mut state, &mut wire); + let initial = state.shell_snapshot_serial; + state.windows.record_mut(background).unwrap().title = "Default subscription".into(); + state.send_shell_snapshots(); + assert!( + state.shell_snapshot_serial > initial, + "new controllers initially receive all titles" + ); + events(&mut state, &mut wire); + + let opcode = FereseShellV1::interface() + .requests + .iter() + .position(|request| request.name == "set_background_title_updates") + .unwrap() as u32; + request(&mut wire, shell.id().protocol_id(), opcode, &[0]); + event_loop + .dispatch(Duration::from_millis(10), &mut state) + .unwrap(); + events(&mut state, &mut wire); + let idle = state.shell_snapshot_serial; + for number in 0..100 { + state.windows.record_mut(background).unwrap().title = format!("spinner {number}"); + state.send_shell_snapshots(); + } + assert_eq!(state.shell_snapshot_serial, idle); + assert!(events(&mut state, &mut wire).is_empty()); + + request(&mut wire, shell.id().protocol_id(), opcode, &[1]); + event_loop + .dispatch(Duration::from_millis(10), &mut state) + .unwrap(); + assert!(state.shell_snapshot_serial > idle); + let snapshot = events(&mut state, &mut wire); + let bytes: Vec<_> = snapshot + .iter() + .flat_map(|(_, _, words)| words.iter().flat_map(|word| word.to_ne_bytes())) + .collect(); + assert!( + bytes + .windows(b"spinner 99".len()) + .any(|bytes| bytes == b"spinner 99"), + "opening a list must receive the latest suppressed title" + ); + + request(&mut wire, shell.id().protocol_id(), opcode, &[0]); + event_loop + .dispatch(Duration::from_millis(10), &mut state) + .unwrap(); + events(&mut state, &mut wire); + let before = state.shell_snapshot_serial; + state.windows.record_mut(focused).unwrap().title = "Focused title".into(); + state.send_shell_snapshots(); + assert!(state.shell_snapshot_serial > before); + let before = state.shell_snapshot_serial; + assert!(state.activate_managed_window(background)); + state.send_shell_snapshots(); + assert!(state.shell_snapshot_serial > before); + } + #[test] fn planned_geometry_matches_smithay_at_fractional_scale_and_rotation() { for transform in [Transform::Normal, Transform::_90, Transform::Flipped270] { @@ -453,7 +550,11 @@ mod tests { .insert_client(server, Arc::new(ClientState::default())) .unwrap(); let shell = client - .create_resource::(&state.display_handle, 6, ()) + .create_resource::( + &state.display_handle, + 6, + Default::default(), + ) .unwrap(); state.shell_resources.push(shell.downgrade()); state.send_shell_snapshots(); diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index 773b990..d8140e0 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -36,6 +36,105 @@ pub(super) fn fixture() -> ( (events, state, output) } +fn set_title( + events: &mut smithay::reexports::calloop::EventLoop<'static, Ferese>, + state: &mut Ferese, + wire: &mut std::os::unix::net::UnixStream, + title: &str, +) { + let mut text = title.as_bytes().to_vec(); + text.push(0); + let length = text.len() as u32; + text.resize(text.len().next_multiple_of(4), 0); + let mut args = vec![length]; + args.extend( + text.chunks_exact(4) + .map(|bytes| u32::from_ne_bytes(bytes.try_into().unwrap())), + ); + // The window fixture creates its xdg_toplevel as object 4; set_title is opcode 2. + request(wire, 4, 2, &args, None); + dispatch(events, state); +} + +#[test] +fn title_only_changes_do_not_schedule_plain_window_redraws() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::title_only_changes_do_not_schedule_plain_window_redraws", + ) { + return; + } + let (mut events, mut state, _output) = fixture(); + let (app, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&app]; + state.output_redraw_pending.clear(); + for number in 0..20 { + let title = format!("spinner {number}"); + set_title(&mut events, &mut state, &mut wire, &title); + assert_eq!(state.windows.record(id).unwrap().title, title); + assert!( + state.output_redraw_pending.is_empty(), + "metadata alone must not invalidate the output or its capture scene" + ); + } +} + +#[test] +fn title_changes_redraw_tab_headers_and_overview_labels() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::title_changes_redraw_tab_headers_and_overview_labels", + ) { + return; + } + let (mut events, mut state, output) = fixture(); + state.toggle_layout_mode(); + let (first, mut first_wire) = window(&mut state, &mut events, 0xff112233); + let (_second, _second_wire) = window(&mut state, &mut events, 0xff445566); + state.consume_focused_window(); + let workspace = state.workspaces.active_id(); + assert_eq!(state.visible_tab_bars(workspace).count(), 1); + assert!(state.space.element_location(&first).is_none()); + state.output_redraw_pending.clear(); + set_title( + &mut events, + &mut state, + &mut first_wire, + "Inactive tab title", + ); + assert!(state.output_redraw_pending.contains(&output)); + + state.leave_tab(); + state.set_overview_active(true); + assert!(state.overview.is_presenting()); + state.output_redraw_pending.clear(); + set_title(&mut events, &mut state, &mut first_wire, "Overview label"); + assert!(state.output_redraw_pending.contains(&output)); +} + +#[test] +fn title_privacy_transitions_still_invalidate_capture_and_output() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::title_privacy_transitions_still_invalidate_capture_and_output", + ) { + return; + } + let (mut events, mut state, output) = fixture(); + let (app, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&app]; + state + .reload_config_source( + "window-rule title=\"Private\" block-out-from-screencasts=#true".into(), + ) + .unwrap(); + for (title, protected) in [("Private", true), ("Public", false)] { + let epoch = state.capture_epoch; + state.output_redraw_pending.clear(); + set_title(&mut events, &mut state, &mut wire, title); + assert_eq!(state.capture_protected_windows.contains(&id), protected); + assert!(state.capture_epoch > epoch); + assert!(state.output_redraw_pending.contains(&output)); + } +} + #[test] fn tree_resize_does_not_hold_motion_in_an_unchanged_neighboring_branch() { if !crate::startup_tests::private_runtime( diff --git a/docs/README.md b/docs/README.md index e495c06..392da00 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,7 +45,6 @@ and [guides at that tag](https://github.com/ferese-wm/ferese/tree/v0.1.0-beta/do - [Publishing activities](development/activities.md): IPC schema, ownership, limits and templates. - [Rust style](development/rust-style.md): module boundaries, ownership, API contracts and enforced checks. - [Animation model](animation-model.md): geometry, spring motion, gestures and prediction. -- [Rendering diagnostics](rendering-performance.md): outcome counters, capture cost and repeatable hardware baselines. - [Output management tests](output-management-testing.md): profiles, hotplug and DRM failures. - [Client soak testing](native-soak-testing.md). - [Website development](../site/README.md). diff --git a/docs/development.md b/docs/development.md index 5417fd2..37dd57b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,7 +31,6 @@ host. Hardware checks remain separate. ## Test guides - [Live Activities checks](#live-activities-checks): core, CLI, shell hooks, installer setup and native rendering. -- [Rendering diagnostics](rendering-performance.md): per-output outcomes and repeatable hardware baselines. - [Output management tests](output-management-testing.md): profiles, hotplug, mirroring, lid handling and failed DRM commits. - [Client soak testing](native-soak-testing.md): repeated client lifecycle and resource growth. diff --git a/docs/rendering-performance.md b/docs/rendering-performance.md deleted file mode 100644 index a237a7b..0000000 --- a/docs/rendering-performance.md +++ /dev/null @@ -1,274 +0,0 @@ -# Rendering diagnostics and hardware baselines - -Set `FERESE_TRACE_PERFORMANCE=1` in the environment of a fresh compositor process -and enable the `ferese::render` info log target. For a manual DRM session, for -example: - -```sh -FERESE_TRACE_PERFORMANCE=1 RUST_LOG=ferese::render=info ferese -``` - -Use your normal supported session launch procedure. Do not start a second DRM -compositor inside a running desktop. Setting this variable on `feresectl` does -not enable counters in an already running compositor. To disable accounting, -remove the variable and restart; any value, including `0`, enables it. - -Existing `render performance` logs describe timings and composition damage. -The direct backend additionally emits `DRM frame outcomes` per physical output, -roughly every five seconds when render or retirement activity reaches a reporting -point. Idle outputs need not emit a report. Counters reset after each report; -retirement of a submission can occur in the following interval. Output recreation -starts a new series. Mirror targets have their own series. Nested sessions do not -provide DRM outcome counters. - -## Counter meanings - -| Fields | Meaning | -| --- | --- | -| `render_attempts` | Calls to Smithay `render_frame`, after scene construction; permission to attempt scanout does not prove that a client buffer was tested. | -| `primary_allowed`, `blocked_locked`, `blocked_animation`, `blocked_not_fullscreen` | Partition of attempts under the existing primary-plane policy. When multiple blockers apply, diagnostic precedence is lock, animation, then absence of fullscreen. | -| `render_failures`, `sync_failures`, `queue_failures` | Errors returned by rendering, explicit synchronization, or queueing respectively. Failed queues never increment submission outcomes. | -| `no_damage_results` | Smithay returned `is_empty`; no display frame was queued. Capture may still run. This is independent of primary selection. | -| `submissions` | Successful `queue_frame` calls, before completion; these are not presented-frame counts. | -| `scanout_submissions` | Queued result uses `PrimaryPlaneElement::Element`, Smithay's direct primary-plane selection. | -| `composed_submissions` | Queued result uses the swapchain with composition damage since the last successfully queued primary commit. | -| `reused_swapchain_submissions` | Queued swapchain result has no new composition damage. This can accompany cursor updates or forced commits; it does not prove a cursor-only update. | -| `allowed_composition_submissions` | Primary scanout permission was set, but the queued result uses a swapchain, including reuse. The public result does not expose exact candidate/driver rejection reasons. | -| `cursor_plane_submissions`, `no_cursor_plane_submissions` | Partition of successful queues by Smithay's cursor-plane assignment. No assignment can mean a hidden, absent, or composed cursor; it is not a hardware-cursor failure count. | -| `retired_frames`, `retirement_failures` | Successful retirement with feedback, or a retirement error. Only successful retirement counts as completed; no feedback and errors do not. A completion can lack a usable monotonic timestamp. These totals do not classify presented primary/cursor paths. | -| `capture_batches`, `capture_us` | Batches with pending monitor capture requests, and their separate CPU wall time through capture scene construction, recomposition and readback processing. A batch may process cursor-included and cursor-excluded requests. Failures remain included in this cost; asynchronous encoding is excluded. | - -Primary outcomes partition submissions; cursor outcomes independently partition -submissions. A scanout/submission ratio describes queued outcomes, not the fraction -of wall time an output spent scanning out an unchanged client. Zero composition damage does not establish direct scanout. A -successful submission establishes neither kernel presentation nor a performance -improvement. Existing presentation schedule logs provide timestamps when usable; enable -`RUST_LOG=ferese::render=debug` to include them. -Capture timing is separate from submitted render timing. Existing no-damage -render timing includes capture work; do not add those overlapping totals. -These diagnostics do not change scanout flags, feedback, clipping, blur, capture -privacy, locking, gamma processing or recovery. - -Counters are opt-in and use constant-size per-output storage. With diagnostics -disabled, capture accounting does not inspect pending requests or read a clock. -The implementation does not inspect additional surfaces or perform GPU timing. - -## Repeatable hardware baseline - -1. Record the exact commit/build, GPU and driver, kernel, output modes, refresh - rates, scale, transforms, mirror layout, power state, application versions and - relevant appearance settings. Save configuration and the full diagnostic log. -2. Use a real DRM session. Warm up each workload for 10 seconds, measure a fixed - 60-second window, and repeat it three times. Use matching time windows and - sum counters across intervals; record the actual workload duration separately. - Keep idle cases separate because they may not produce periodic reports. -3. Start with an opaque settled fullscreen client, then toggle fullscreen and - exercise animations and popups. Compare it with a non-fullscreen and a rounded - or translucent scene. Move/show/hide the cursor in each case. Expect permission - and observed outcomes to differ; record both without assigning unreported - hardware rejection reasons. -4. Repeat with monitor screenshots/sharing, cursor included and excluded, - protected windows, overlapping blurs and moving content beneath blur. Check - captured pixels and privacy, not just timings. Test lock/unlock with capture - active and verify no desktop capture survives lock acquisition. -5. Repeat on mixed outputs, mirrors, fractional scaling and rotation; exercise - hotplug, gamma/night light and the recovery checks in - [Output management tests](output-management-testing.md). Check visual and - capture correctness after fallback and after returning to scanout. -6. Compare successful queue outcomes, composition damage, CPU render timings, - capture timings and presentation schedule timestamps against the same baseline. - Measure GPU work or power with an appropriate external tool and record its - method. Report variation across repetitions. Software tests and nested sessions - cannot establish physical scanout, GPU savings or presentation latency. - -Follow-on instrumentation should measure blur invalidation, clean-cache reuse, -actual framebuffer recapture counts/cost, asynchronous capture encoding and -window-capture readback separately. Current effect metrics are placeholders; -`capture_us` covers monitor capture batches only. Exact cursor-only KMS updates -and detailed scanout rejection causes require additional upstream observability. -Broader primary-plane and overlay policies still need hardware validation. - -## Capture scene reuse - -With `FERESE_TRACE_PERFORMANCE=1`, both backends emit `capture scene outcomes` -through the `ferese::render` log target. Each report covers one retained output -view, with separate counts for cursor-hidden and cursor-embedded capture, and -resets its counters after roughly five seconds of capture activity. - -`preparations` counts successful filtered-scene preparations, `scene_reuses` -counts checks that skipped rebuilding that scene, and `compositions` counts -renders with nonempty capture damage. A pending damage-waiting frame may be -checked repeatedly without a delivery. A preparation can also find unchanged -pixels, so it need not increase the composition count. These counters describe the -capture source; they do not count PipeWire deliveries, measure GPU time or -describe window-only capture. For a mirror, they describe its filtered source -before fitting it to the target output. - -An unchanged view reuses its retained GPU image before constructing capture -render elements. Surface commits, layout changes, animation ticks and privacy -changes invalidate it, including the final animation frame. Cursor updates -invalidate only views that embed the cursor. Output identity, scale, transform -and geometry are checked as well, so a remapped or reconfigured output cannot -reuse the previous view by mistake. Capture requests alone do not invalidate -the image, and multiple mutations before a request uses it are coalesced. - -This is conservative invalidation: an unrelated redraw may still cause a -preparation, after which damage tracking can find unchanged pixels. It does not -reuse the display swapchain or partially update consumer buffers. DMA-BUF and -shared-memory delivery still write complete frames, which keeps a recycled -buffer from exposing pixels left by a previous use. - -## IPC snapshot CPU benchmark - -The ignored release test below measures construction of unchanged desktop event -snapshots with the default theme. It warms up the state, then reports three runs -of 2,000 snapshots. Keep the runtime and state directories private so the test -cannot connect to your desktop or write its configuration. - -```sh -ipc_benchmark_runtime=$(mktemp -d) -env -u FERESE_SOCKET \ - XDG_RUNTIME_DIR="$ipc_benchmark_runtime" \ - XDG_CONFIG_HOME="$ipc_benchmark_runtime" \ - XDG_STATE_HOME="$ipc_benchmark_runtime" \ - XDG_CACHE_HOME="$ipc_benchmark_runtime" \ - FERESE_STARTUP_TEST_CHILD=ipc_events::tests::profile_unchanged_event_snapshots \ - cargo test --release --locked -p ferese profile_unchanged_event_snapshots -- --ignored --nocapture -rm -rf -- "$ipc_benchmark_runtime" -``` - -Compare the reported `elapsed_us` values on the same machine and build profile. -These timings cover CPU snapshot construction, while the DRM procedure above -measures rendering and presentation under real desktop workloads. - -For populated desktops, replace `profile_unchanged_event_snapshots` with -`profile_populated_event_publication` in both the test filter and -`FERESE_STARTUP_TEST_CHILD`. This test creates one output and private Wayland -clients, then measures three runs of 2,000 unchanged publication checks with -0, 1, 20 and 100 windows. Setup and the initial subscription event happen before -timing. Window records stay typed through comparison and serialize only when -an event is sent, avoiding JSON objects for checks that produce no update. -The benchmark includes construction, comparison and disposal of each snapshot, -while socket serialization and subscriber processing are excluded. Keep CPU -affinity identical when comparing binaries on machines with mixed core types. - -## Shell snapshot handoff benchmark - -The shell's ignored release test measures delivery of a completed desktop -snapshot from the control thread to the UI. It uses a private socket pair and -tests snapshots with 1, 20 and 100 windows, reporting three runs of 2,000 -handoffs for each size. - -```sh -cargo test --release --locked -p ferese-shell profile_snapshot_handoff -- --ignored --nocapture -``` - -Snapshot preparation happens before timing; delivery, polling and destruction -are included. Compare runs on the same machine without concurrent builds. -The benchmark excludes protocol decoding, UI layout and drawing, so its results -describe this handoff's CPU cost rather than overall shell responsiveness. - -## Monitor capture benchmark - -Monitor captures keep their own damage history alongside the capture texture. -The first frame fills the texture, and later frames redraw only damaged regions -before readback. Cursor inclusion, disappearing surfaces and output changes are -resolved against the capture scene, independently of the displayed image. -Cursor-included and cursor-hidden streams retain separate targets, so alternating -between them does not invalidate otherwise unchanged captures. Mirrored captures -also track damage in their filtered source composition before fitting it to the -mirror output. - -Run the ignored release benchmark on a hardware EGL device: - -```sh -cargo test --release --locked -p ferese profile_output_capture -- --ignored --nocapture -``` - -It creates a synthetic 3840×2160 scene with overlapping translucent panes and -tests unchanged frames, cursor movement and full-background changes. Each case -warms up for 60 frames and reports three runs of 60 frames. With `readback=true`, -timing includes a full-frame GPU readback and a copy into CPU memory. With -`readback=false`, a one-pixel readback waits for GPU completion while avoiding -the full-frame transfer cost. These are elapsed times through the renderer, -rather than GPU timestamp measurements. - -Compare old and new release binaries in alternating runs on the same GPU, and -record other GPU workloads. The test captures no desktop content and excludes -PipeWire delivery, encoding and network transport, so it cannot establish an -end-to-end OBS or call-frame latency improvement. - -## Window capture conversion benchmark - -The one-shot window capture path converts premultiplied readback pixels to straight -ARGB. Streaming performs this conversion in its GPU transfer shader. Opaque pixels need only a channel swap, while partial -alpha uses a 64 KiB table compiled into read-only storage. The table preserves -the original rounded and saturated conversion without divisions in the frame -loop or runtime allocation for lookup data. - -```sh -cargo test --release --locked -p ferese profile_window_conversion -- --ignored --nocapture -``` - -The test measures 1080p and 4K frames with opaque content, translucent edges, -uniform translucency and varying alpha. Each case warms up for three frames, -then reports three runs of three frames. Input colours vary across the frame, -and the benchmark restores the premultiplied input before each conversion, -outside the timed interval. Compare release builds with matching CPU affinity -and record competing CPU workloads. - -These timings isolate pixel conversion. GPU readback, copying into client -buffers, PipeWire delivery and encoding require separate measurements; use -live thread CPU samples to identify how much conversion contributes to a -running sharing session. - - -## Native stream validation - -Streaming uses `ext-image-copy-capture-v1` with persistent capture sessions and a -four-buffer PipeWire pool. The compositor writes into those buffers directly, -using DMA-BUF when allocation and format negotiation permit it, or anonymous -shared memory otherwise. A pending capture waits for source damage, and each -completed buffer is delivered once. DMA-BUF frames become available only after -the exported GPU fence signals; cancellation, privacy changes and source removal -also invalidate pending delivery. - -The isolated window test captures synthetic clients through a real PipeWire -consumer, checks silence while content is static, then requests distinct updates. -It starts a callback-driven animation on an inactive workspace, reconnects -without changing the source, resizes while paused, verifies the replacement pool -and revokes the window's capture permission. A monitor stream -then exercises damage waiting and real updates through the filtered output path. -Set `FERESE_TEST_CAPTURE_TRANSPORT` to `shm` or `dma` to require that memory type -in the native consumer, rather than merely accepting an available transport. -DMA-BUF checks require a hardware-backed nested compositor and access to its -render node. - -Set `FERESE_TEST_CAPTURE_OBS_BUFFERS=1` with either transport to exercise OBS's -memory-type bitmask and mapped-buffer requirements. This catches negotiation -failures that a consumer offering just one memory type can miss. - -```sh -FERESE_TEST_WINDOW_CAPTURE=1 FERESE_TEST_CAPTURE_TRANSPORT=shm \ - python3 scripts/tests/test_window_capture_isolated.py -FERESE_TEST_WINDOW_CAPTURE=1 FERESE_TEST_CAPTURE_TRANSPORT=dma \ - python3 scripts/tests/test_window_capture_isolated.py -cargo test --locked -p ferese render::capture::stream_gpu_tests -- --ignored -``` - -The GPU tests compare all 65,536 alpha/channel combinations against the CPU -conversion and check all eight output transforms with both texture orientations. -Native streaming tests establish buffer transport and pixel correctness; measure -CPU, GPU and encoding workloads separately before attributing a change in total -streaming performance to capture alone. - -The recorder retains the last owned image and timestamps one final reference when -recording stops, so a static desktop keeps its full duration without requesting -or encoding duplicate frames throughout the idle interval. The regression test -covers an explicit stop, EOS from the capture source and continuous audio while -the video stays static: - -```sh -cargo test --locked -p xdg-desktop-portal-ferese --bin ferese-record \ - recorder::tests::encoder_preserves_the_duration_of_a_static_capture -- --ignored -``` diff --git a/portal/src/recorder.rs b/portal/src/recorder.rs index c535ab9..02582af 100644 --- a/portal/src/recorder.rs +++ b/portal/src/recorder.rs @@ -550,6 +550,11 @@ fn encode( let mut finishing = None; let started = Instant::now(); let mut failure = None; + // Aggregator-based muxers schedule live audio without waiting for video. + // CollectPads-based muxers need an occasional frame to release audio. + let needs_video_heartbeat = gst::glib::Type::from_name("GstAggregator") + .is_none_or(|aggregator| !mux.type_().is_a(aggregator)); + let mut last_heartbeat = gst::ClockTime::ZERO; loop { if stop.load(Ordering::Relaxed) && finishing.is_none() { @@ -599,6 +604,38 @@ fn encode( } } + if finishing.is_none() + && needs_video_heartbeat + && !audio_inputs.is_empty() + && let Some(now) = pipeline.current_running_time() + && now > last_heartbeat + gst::ClockTime::SECOND + { + let input = queue + .static_pad("sink") + .ok_or("Encoder queue has no input")?; + let _input_lock = input.stream_lock(); + let last = last_frame + .lock() + .expect("last recording frame mutex poisoned"); + if let Some((buffer, caps)) = last.as_ref() + && *caps == input.current_caps() + && buffer + .pts() + .is_some_and(|pts| now > pts + gst::ClockTime::SECOND) + { + // Reuse owned pixels at 1 Hz; never request another capture + // or let a sparse video track stall the bounded audio queues. + let mut image = buffer.clone(); + let header = image.make_mut(); + header.set_pts(now); + header.set_dts(None); + header.set_duration(None); + header.unset_flags(gst::BufferFlags::DISCONT); + let _ = input.chain(image); + last_heartbeat = now; + } + } + if finishing.is_some_and(|t: Instant| t.elapsed() > Duration::from_secs(10)) { return Err(failure.unwrap_or("Finishing the recording timed out".into())); } @@ -694,7 +731,8 @@ mod tests { #[ignore = "requires GStreamer video plugins"] fn encoder_preserves_the_duration_of_a_static_capture() { gst::init().unwrap(); - for (source_eos, with_audio) in [(false, false), (true, false), (false, true)] { + for (source_eos, with_audio) in [(false, false), (true, false), (false, true), (true, true)] + { let directory = tempfile::tempdir().unwrap(); let source = gst::ElementFactory::make("appsrc") .property("is-live", true) @@ -774,6 +812,8 @@ mod tests { } let end = Arc::new(std::sync::atomic::AtomicU64::new(0)); let observed = end.clone(); + let frames = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let observed_frames = frames.clone(); decoder .by_name("video") .unwrap() @@ -781,6 +821,7 @@ mod tests { .unwrap() .add_probe(gst::PadProbeType::BUFFER, move |_, info| { if let Some(buffer) = info.buffer() { + observed_frames.fetch_add(1, Ordering::Relaxed); let time = buffer.pts().unwrap_or(gst::ClockTime::ZERO) + buffer.duration().unwrap_or(gst::ClockTime::ZERO); observed.fetch_max(time.nseconds(), Ordering::Relaxed); @@ -806,7 +847,16 @@ mod tests { "static video interrupted audio: {} decoded bytes", audio_bytes.load(Ordering::Relaxed) ); + assert!( + end.load(Ordering::Relaxed) >= 3_500_000_000, + "static video did not cover the audio recording" + ); } + assert!( + frames.load(Ordering::Relaxed) <= 6, + "static capture encoded unnecessary duplicate frames: {}", + frames.load(Ordering::Relaxed) + ); assert!( end.load(Ordering::Relaxed) >= 1_000_000_000, "recording lost the idle interval: last decoded frame ends at {} ns", diff --git a/protocols/ferese-shell-v1.xml b/protocols/ferese-shell-v1.xml index 5834fd5..d1af6d0 100644 --- a/protocols/ferese-shell-v1.xml +++ b/protocols/ferese-shell-v1.xml @@ -22,7 +22,7 @@ DEALINGS IN THE SOFTWARE. - + This private global is visible only to clients granted the shell-control capability. It does not assign a Wayland surface role. @@ -35,7 +35,7 @@ - + Window, workspace, and output identifiers are unsigned 64-bit values encoded as high and low 32-bit words. Each snapshot atomically replaces @@ -244,5 +244,17 @@ + + + When enabled is zero, changes to unfocused window titles alone do not + trigger snapshots. Focused titles and all other window metadata still + trigger updates. Snapshots always contain the current titles of every + window, and changing this preference sends a fresh snapshot immediately + while the session is unlocked. + Updates are enabled initially. Disable them while no window list is + visible to avoid repainting the panel for background title animations. + + + diff --git a/scripts/ci/run-native-checks.py b/scripts/ci/run-native-checks.py index ed0669b..008a75d 100644 --- a/scripts/ci/run-native-checks.py +++ b/scripts/ci/run-native-checks.py @@ -60,6 +60,7 @@ def main(): cases = [ ("shell-flat", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "flat"}), ("shell-floating", "shell_motion", {"FERESE_TEST_SHELL_MOTION": "1", "FERESE_TEST_BAR_LAYOUT": "floating"}), + ("shell-titles", "shell_title_updates", {"FERESE_TEST_SHELL_TITLES": "1"}), ("invalid-config", "startup_config", {"FERESE_TEST_STARTUP_CONFIG": "1"}), ("activities", "activities", {"FERESE_TEST_ACTIVITIES": "1"}), ] diff --git a/scripts/tests/fixtures/window-titles.c b/scripts/tests/fixtures/window-titles.c new file mode 100644 index 0000000..31a869b --- /dev/null +++ b/scripts/tests/fixtures/window-titles.c @@ -0,0 +1,118 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include "xdg-shell-client-protocol.h" + +static struct wl_compositor *compositor; +static struct wl_shm *shm; +static struct xdg_wm_base *wm; + +static void ping(void *data, struct xdg_wm_base *base, uint32_t serial) { + (void)data; + xdg_wm_base_pong(base, serial); +} + +static const struct xdg_wm_base_listener wm_listener = {.ping = ping}; + +static void global(void *data, struct wl_registry *registry, uint32_t name, + const char *interface, uint32_t version) { + (void)data; + (void)version; + if (!strcmp(interface, "wl_compositor")) + compositor = wl_registry_bind(registry, name, &wl_compositor_interface, 4); + else if (!strcmp(interface, "wl_shm")) + shm = wl_registry_bind(registry, name, &wl_shm_interface, 1); + else if (!strcmp(interface, "xdg_wm_base")) { + wm = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1); + xdg_wm_base_add_listener(wm, &wm_listener, NULL); + } +} + +static void removed(void *data, struct wl_registry *registry, uint32_t name) { + (void)data; + (void)registry; + (void)name; +} + +static const struct wl_registry_listener registry_listener = {global, removed}; + +static void configure(void *data, struct xdg_surface *xdg, uint32_t serial) { + (void)data; + xdg_surface_ack_configure(xdg, serial); +} + +static const struct xdg_surface_listener surface_listener = {.configure = configure}; + +static void size(void *data, struct xdg_toplevel *top, int32_t width, int32_t height, + struct wl_array *states) { + (void)data; + (void)top; + (void)width; + (void)height; + (void)states; +} + +static void close_window(void *data, struct xdg_toplevel *top) { + (void)data; + (void)top; + exit(0); +} + +static const struct xdg_toplevel_listener top_listener = { + .configure = size, + .close = close_window, +}; + +int main(void) { + struct wl_display *display = wl_display_connect(NULL); + if (!display) return 1; + struct wl_registry *registry = wl_display_get_registry(display); + wl_registry_add_listener(registry, ®istry_listener, NULL); + if (wl_display_roundtrip(display) < 0 || !compositor || !shm || !wm) return 2; + int fd = memfd_create("window-titles", MFD_CLOEXEC); + if (fd < 0 || ftruncate(fd, 160 * 120 * 4)) return 3; + uint32_t *pixels = mmap(NULL, 160 * 120 * 4, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (pixels == MAP_FAILED) return 4; + for (size_t i = 0; i < 160 * 120; i++) pixels[i] = 0xff3182ce; + struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, 160 * 120 * 4); + struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, 160, 120, 160 * 4, WL_SHM_FORMAT_XRGB8888); + wl_shm_pool_destroy(pool); + munmap(pixels, 160 * 120 * 4); + close(fd); + struct xdg_toplevel *tops[2]; + for (int i = 0; i < 2; i++) { + struct wl_surface *window = wl_compositor_create_surface(compositor); + struct xdg_surface *xdg = xdg_wm_base_get_xdg_surface(wm, window); + xdg_surface_add_listener(xdg, &surface_listener, NULL); + tops[i] = xdg_surface_get_toplevel(xdg); + xdg_toplevel_add_listener(tops[i], &top_listener, NULL); + xdg_toplevel_set_app_id(tops[i], "ferese.test.titles"); + xdg_toplevel_set_title(tops[i], i ? "Focused window" : "Background window"); + xdg_toplevel_set_min_size(tops[i], 160, 120); + xdg_toplevel_set_max_size(tops[i], 160, 120); + wl_surface_commit(window); + if (wl_display_roundtrip(display) < 0) return 5; + xdg_surface_set_window_geometry(xdg, 0, 0, 160, 120); + wl_surface_attach(window, buffer, 0, 0); + wl_surface_damage(window, 0, 0, 160, 120); + wl_surface_commit(window); + if (wl_display_roundtrip(display) < 0) return 5; + } + puts("ready"); fflush(stdout); + char line[256]; + while (fgets(line, sizeof line, stdin)) { + int index = line[0] - '0'; + if (index < 0 || index > 1 || line[1] != ' ') return 6; + line[strcspn(line, "\n")] = 0; + xdg_toplevel_set_title(tops[index], line + 2); + if (wl_display_roundtrip(display) < 0) return 5; + puts("changed"); fflush(stdout); + } + wl_display_disconnect(display); + return 0; +} diff --git a/scripts/tests/test_portal_background_isolated.py b/scripts/tests/test_portal_background_isolated.py index 2bfe4fe..ab57e14 100644 --- a/scripts/tests/test_portal_background_isolated.py +++ b/scripts/tests/test_portal_background_isolated.py @@ -113,7 +113,9 @@ class BackgroundPortal(unittest.TestCase): def snapshot(peer, app): send(peer, {"version": 1, "generation": 0, "last": True, "type": "snapshot", "desktop": { - "outputs": [], "workspaces": [], "windows": [{"id": 1, "app_id": app, "focused": True}], + "outputs": [], "workspaces": [], "windows": [{ + "id": 1, "app_id": app, "title": app, "focused": True, + "workspace": 1, "floating": False, "fullscreen": False}], "focus": {"window": 1}, "config": {"revision": 0, "error": None}, "theme": None, "lock": {"phase": "unlocked", "sleeping": False}}}) diff --git a/scripts/tests/test_shell_title_updates_isolated.py b/scripts/tests/test_shell_title_updates_isolated.py new file mode 100644 index 0000000..6908ab9 --- /dev/null +++ b/scripts/tests/test_shell_title_updates_isolated.py @@ -0,0 +1,183 @@ +"""Keep a real GPU panel idle during background title changes. + +FERESE_TEST_SHELL_TITLES=1 python3 scripts/tests/test_shell_title_updates_isolated.py +Requires a Wayland host, cc, wayland-scanner, and dbus-daemon. +""" +import json +import os +from pathlib import Path +import re +import select +import shlex +import signal +import subprocess +import tempfile +import time +import unittest + +from session_socket import ipc_environment, ipc_socket +from test_shell_motion_isolated import panel_config + + +@unittest.skipUnless(os.environ.get("FERESE_TEST_SHELL_TITLES") == "1", "opt-in nested shell test") +class ShellTitleUpdatesTest(unittest.TestCase): + def test_background_titles_only_redraw_visible_window_lists(self): + repo = Path(__file__).resolve().parents[2] + binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/release/ferese") + shell = repo / os.environ.get("FERESE_TEST_SHELL", "target/release/ferese-shell") + ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/release/feresectl") + children = [] + with tempfile.TemporaryDirectory(prefix="ferese-shell-titles-") as temporary: + root = Path(temporary) + protocols = subprocess.check_output( + ["pkg-config", "--variable=pkgdatadir", "wayland-protocols"], text=True).strip() + xml = str(Path(protocols) / "stable/xdg-shell/xdg-shell.xml") + for kind, output in [("client-header", "xdg-shell-client-protocol.h"), + ("private-code", "xdg-shell-protocol.c")]: + subprocess.run(["wayland-scanner", kind, xml, str(root / output)], check=True) + flags = shlex.split(subprocess.check_output( + ["pkg-config", "--cflags", "--libs", "wayland-client"], text=True)) + subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I", str(root), + str(repo / "scripts/tests/fixtures/window-titles.c"), + str(root / "xdg-shell-protocol.c"), "-o", str(root / "client"), *flags], check=True) + runtime = root / "runtime" + runtime.mkdir(mode=0o700) + config = root / "config/ferese" + config.mkdir(parents=True) + (config / "config.kdl").write_text( + 'status { keybinding-guide #false; }\n' + 'window-rule app-id="ferese.test.titles" floating=#true\n' + panel_config("flat")) + display = Path(os.environ["WAYLAND_DISPLAY"]) + if not display.is_absolute(): + display = Path(os.environ["XDG_RUNTIME_DIR"]) / display + env = {key: value for key, value in os.environ.items() if not key.startswith("FERESE_")} + env.update(XDG_RUNTIME_DIR=str(runtime), WAYLAND_DISPLAY=str(display), + XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"), + XDG_CACHE_HOME=str(root / "cache"), XDG_DATA_HOME=str(root / "data"), + FERESE_COMPOSITOR_WALLPAPER="1", RUST_LOG="ferese=info") + env.pop("WAYLAND_SOCKET", None) + bus_config = root / "bus.conf" + bus_config.write_text( + 'sessionunix:tmpdir=/tmp' + '' + '') + log_path = root / "session.log" + + def launch(command, **kwargs): + process = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs) + children.append(process) + return process + + def wait_for(predicate): + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + value = predicate() + if value: + return value + time.sleep(.025) + self.fail("Timed out:\n" + log_path.read_text()[-8000:]) + + def control(*args): + return subprocess.check_output([str(ctl), *args], env=ipc_environment(env), text=True, timeout=5) + + def snapshots(): + return log_path.read_text().count(".snapshot_end(") + + def title(index, value): + client.stdin.write(f"{index} {value}\n") + client.stdin.flush() + self.assertTrue(select.select([client.stdout], [], [], 5)[0], "title client did not reply") + self.assertEqual(client.stdout.readline().strip(), "changed") + + def burst(index, prefix): + for number in range(20): + title(index, f"{prefix} {number}") + time.sleep(.05) + time.sleep(.2) + + def wait_title(value): + wait_for(lambda: any(window["title"] == value for window in json.loads(control("-j", "windows")))) + + def focused_title(): + return next((window["title"] for window in json.loads(control("-j", "windows")) + if window["focused"]), None) + + def watch(enabled): + matches = re.findall(r"\.set_background_title_updates\(([01])\)", log_path.read_text()) + return matches and matches[-1] == str(int(enabled)) + + try: + with log_path.open("w") as log: + bus = launch(["dbus-daemon", "--config-file=" + str(bus_config), "--nofork", "--print-address=1"], + stdout=subprocess.PIPE, stderr=log, text=True) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + compositor = launch([str(binary), "--backend", "nested", "--grant-effects", "--grant-shell-control", + "--", "env", "WAYLAND_DEBUG=client", "ICED_BACKEND=wgpu", str(shell)], + stdout=log, stderr=log) + wait_for(lambda: ipc_socket(runtime).is_socket() and watch(False)) + child_display = wait_for(lambda: next((p for p in runtime.glob("wayland-*") if p.is_socket()), None)) + env["WAYLAND_DISPLAY"] = str(child_display) + client = launch([str(root / "client")], stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=log, text=True) + self.assertTrue(select.select([client.stdout], [], [], 10)[0], "window client did not map") + self.assertEqual(client.stdout.readline().strip(), "ready") + wait_for(lambda: len(json.loads(control("-j", "windows"))) == 2) + wait_for(lambda: focused_title() == "Focused window") + time.sleep(.5) + before = snapshots() + burst(0, "Hidden spinner") + wait_title("Hidden spinner 19") + self.assertEqual(snapshots(), before, "background titles repainted the closed panel") + print("Background: 20 actual title changes produced no shell snapshots", flush=True) + + before = snapshots() + title(1, "Current focused title") + wait_title("Current focused title") + wait_for(lambda: snapshots() > before) + self.assertIn('"Current focused title"', log_path.read_text()) + + control("focus-last-window") + wait_for(lambda: focused_title() == "Hidden spinner 19") + wait_for(lambda: '"Hidden spinner 19"' in log_path.read_text()) + control("focus-last-window") + wait_for(lambda: focused_title() == "Current focused title") + time.sleep(.2) + + before = snapshots() + control("launcher", "toggle") + wait_for(lambda: watch(True) and snapshots() > before) + self.assertIn('"Hidden spinner 19"', log_path.read_text()) + before = snapshots() + burst(0, "Visible list") + wait_title("Visible list 19") + wait_for(lambda: snapshots() > before) + self.assertIn('"Visible list 19"', log_path.read_text()) + print("Launcher: opening fetched the latest title; live updates continued", flush=True) + + control("launcher", "toggle") + wait_for(lambda: watch(False)) + time.sleep(.5) + before = snapshots() + burst(0, "Closed list") + wait_title("Closed list 19") + self.assertEqual(snapshots(), before, "closing the launcher left a title subscription active") + self.assertIsNone(compositor.poll(), log_path.read_text()[-8000:]) + self.assertNotIn("panicked at", log_path.read_text()) + finally: + if destination := os.environ.get("FERESE_TEST_LOG"): + Path(destination).write_text(log_path.read_text() if log_path.exists() else "") + for process in reversed(children): + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/shell/ferese-shell/src/control.rs b/shell/ferese-shell/src/control.rs index bbed5a5..6c09da2 100644 --- a/shell/ferese-shell/src/control.rs +++ b/shell/ferese-shell/src/control.rs @@ -1,3 +1,4 @@ +use std::cell::Cell; use std::error::Error; use std::os::fd::{FromRawFd, RawFd}; use std::os::unix::net::UnixStream; @@ -77,6 +78,7 @@ pub(crate) struct ShellControl { shell: FereseShellV1, updates: Receiver, wake: Wake, + background_title_updates: Cell, } pub(crate) struct ControlPoll { @@ -149,8 +151,9 @@ impl ShellControl { let connection = control_connection()?; let (globals, mut queue) = registry_queue_init::(&connection)?; let qh = queue.handle(); - let manager = globals.bind::(&qh, 7..=10, ())?; + let manager = globals.bind::(&qh, 11..=11, ())?; let shell = manager.get_shell(&qh, ()); + shell.set_background_title_updates(0); let (sender, updates) = mpsc::channel(); let wake = Wake(Arc::new(tokio::sync::Notify::new())); let mut state = ControlState::new(UpdateSender { @@ -172,9 +175,17 @@ impl ShellControl { shell, updates, wake, + background_title_updates: Cell::new(false), }) } + pub(crate) fn set_background_title_updates(&self, enabled: bool) { + if self.background_title_updates.replace(enabled) != enabled { + self.shell.set_background_title_updates(u32::from(enabled)); + let _ = self.connection.flush(); + } + } + pub(crate) fn subscription(&self) -> cosmic::iced::Subscription<()> { cosmic::iced::Subscription::run_with(self.wake.clone(), Wake::stream) } @@ -597,6 +608,17 @@ mod tests { ControlState, wayland_client::EventQueue, UnixStream, + ) { + control_fixture_version(6) + } + + fn control_fixture_version( + version: u32, + ) -> ( + ShellControl, + ControlState, + wayland_client::EventQueue, + UnixStream, ) { let (socket, mut peer) = UnixStream::pair().unwrap(); peer.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); @@ -604,7 +626,7 @@ mod tests { let queue = connection.new_event_queue::(); let qh = queue.handle(); let registry = connection.display().get_registry(&qh, ()); - let manager = registry.bind::(1, 6, &qh, ()); + let manager = registry.bind::(1, version, &qh, ()); let shell = manager.get_shell(&qh, ()); let (sender, updates) = mpsc::channel(); let wake = Wake(Arc::new(tokio::sync::Notify::new())); @@ -623,10 +645,33 @@ mod tests { shell, updates, wake, + background_title_updates: Cell::new(true), }; (control, state, queue, peer) } + #[test] + fn background_title_subscription_sends_only_changed_preferences() { + let (control, _state, _queue, mut peer) = control_fixture_version(11); + for enabled in [true, false, false, true, true] { + control.set_background_title_updates(enabled); + } + for enabled in [0_u32, 1] { + let (object, opcode, args) = read_request(&mut peer); + let request = &FereseShellV1::interface().requests[usize::from(opcode)]; + assert_eq!(object, control.shell.id().protocol_id()); + assert_eq!(request.name, "set_background_title_updates"); + assert_eq!(args, enabled.to_ne_bytes()); + } + peer.set_read_timeout(Some(Duration::from_millis(20))) + .unwrap(); + let error = peer.read(&mut [0; 8]).unwrap_err(); + assert!(matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + )); + } + fn dispatch_event( control: &ShellControl, state: &mut ControlState, diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 7cfaa9f..06c2f64 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1109,6 +1109,19 @@ impl cosmic::Application for FereseShell { } }; self.flush_config_error(); + if let Some(control) = &self.control { + control.set_background_title_updates( + self.launcher.is_some() + || self.outputs.iter().any(|output| { + output.runtime.hub.as_ref().is_some_and(|hub| { + hub.borrow().hub.mode() + == ferese_panel::morph::Mode::Expanded( + ferese_panel::morph::Content::Workspaces, + ) + }) + }), + ); + } task }