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 }