diff --git a/.github/workflows/compositor-lifecycle.yml b/.github/workflows/compositor-lifecycle.yml new file mode 100644 index 0000000..81b8e50 --- /dev/null +++ b/.github/workflows/compositor-lifecycle.yml @@ -0,0 +1,46 @@ +name: Compositor lifecycle and capture tests +on: + pull_request: + paths: + - "compositor/**" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/compositor-lifecycle.yml" + push: + branches: [main] + paths: + - "compositor/**" + - "crates/**" + - "Cargo.toml" + - "Cargo.lock" + - ".github/workflows/compositor-lifecycle.yml" + workflow_dispatch: +permissions: + contents: read +concurrency: + group: compositor-lifecycle-${{ github.ref }} + cancel-in-progress: true +jobs: + protocol: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Install compositor build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config dbus-daemon libwayland-dev \ + libxkbcommon-dev xkb-data libinput-dev libudev-dev libseat-dev libgbm-dev \ + libegl-dev libegl-mesa0 libgl1-mesa-dri libdrm-dev libfontconfig1-dev libfreetype-dev libexpat1-dev + - name: Install Rust + run: rustup toolchain install 1.95.0 --profile minimal + - name: Test compositor policies, protocols and worker lifecycles + run: cargo +1.95.0 test --locked -p ferese -- --nocapture + - name: Test capture privacy, resize dependencies and cursor composition + env: + LIBGL_ALWAYS_SOFTWARE: "1" + EGL_PLATFORM: surfaceless + run: | + 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 diff --git a/compositor/Cargo.toml b/compositor/Cargo.toml index e36acd2..570b04a 100644 --- a/compositor/Cargo.toml +++ b/compositor/Cargo.toml @@ -36,3 +36,5 @@ tempfile.workspace = true [features] # Opt-in counters/histograms with a per-client exit summary. resize-metrics = [] +# One-shot KMS recovery checks; never enabled by the installer. +drm-fault-injection = [] diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index b403787..ba9c2e7 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -10,6 +10,7 @@ mod lid; mod mirror; mod output_power; mod planes; +mod recovery; mod redraw; mod scheduling; mod topology; @@ -260,6 +261,8 @@ struct DirectOutput { capture_texture: Option, render_metrics: RenderMetrics, frame_pending: bool, + completion: recovery::Completion, + completion_timer: Option, animation_fallback: AnimationFallback, cursor_visible: bool, scheduler: crate::frame_scheduler::FrameScheduler, @@ -800,23 +803,43 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result { + #[cfg(feature = "drm-fault-injection")] + if state + .direct_backend + .as_ref() + .and_then(|backend| backend.devices.get(&node)) + .and_then(|device| device.outputs.get(&crtc)) + .is_some_and(|output| output.frame_pending) + && recovery::inject("missing-completion") + { + tracing::warn!(?node, ?crtc, "injecting missing DRM completion"); + return; + } + if let Some(metadata) = metadata { let mut retired = false; + let mut retirement_failed = false; let feedback = state .direct_backend .as_mut() .and_then(|backend| backend.devices.get_mut(&node)) .and_then(|device| device.outputs.get_mut(&crtc)) - .and_then(|output| match output.surface.frame_submitted() { - Ok(feedback) => { - retired = feedback.is_some(); - feedback + .and_then(|output| { + #[cfg(feature = "drm-fault-injection")] + if output.frame_pending && recovery::inject("retirement") { + tracing::warn!(?node, ?crtc, "injecting DRM retirement failure"); + retirement_failed = true; + return None; } - Err(error) => { + let (feedback, completed, error) = recovery::retirement(output.surface.frame_submitted()); + retired = completed; + if let Some(error) = error { tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame"); - None + retirement_failed = true; } + + feedback }); if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) { @@ -852,6 +875,7 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result Result bool { matches!( interface, @@ -1268,19 +1301,19 @@ fn render_output( primary.sync.wait()?; } - if output.mirror_source.is_some() { - capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output)?; - } else { - capture::capture_output( - state, - &mut device.renderer, - &mut output.capture_texture, - &output.output, - &frame, - )?; - } - if result.is_empty { + if output.mirror_source.is_some() { + capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output); + } else { + capture::capture_output( + state, + &mut device.renderer, + &mut output.capture_texture, + &output.output, + &frame, + ); + } + output .render_metrics .record_no_damage(render_started.elapsed(), missed_deadlines); @@ -1327,16 +1360,35 @@ fn render_output( }; let visibility = result.states.clone(); drop(result); - output.surface.queue_frame(presentation)?; - if output.mirror_source.is_none() { - state.display_presentation.queued(&output.output, &visibility); - } - output.primary_commit = primary_commit; - output - .render_metrics - .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects); - output.frame_pending = true; - output.lock_frame_pending = state.session_lock.active(); + + capture::after_queue(output.surface.queue_frame(presentation), || { + if output.mirror_source.is_none() { + state.display_presentation.queued(&output.output, &visibility); + } + output.primary_commit = primary_commit; + output + .render_metrics + .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects); + output.frame_pending = true; + if !recovery::arm_completion(&state.loop_handle, node, crtc, &mut output) { + recovery::reconcile_device(state, node); + } + + output.lock_frame_pending = state.session_lock.active(); + + if output.mirror_source.is_some() { + capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output); + } else { + capture::capture_output( + state, + &mut device.renderer, + &mut output.capture_texture, + &output.output, + &frame, + ); + } + })?; + Ok(true) })() }; @@ -2042,6 +2094,8 @@ fn create_direct_output( capture_texture: None, render_metrics, frame_pending: false, + completion: Default::default(), + completion_timer: None, animation_fallback: AnimationFallback::default(), cursor_visible: false, power: OutputPower::default(), diff --git a/compositor/src/backends/direct/capture.rs b/compositor/src/backends/direct/capture.rs index a3e8e31..325234a 100644 --- a/compositor/src/backends/direct/capture.rs +++ b/compositor/src/backends/direct/capture.rs @@ -3,6 +3,7 @@ use smithay::backend::renderer::element::RenderElement; use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture}; use smithay::backend::renderer::{Bind, Offscreen, Texture}; use smithay::output::Output; +use std::fmt::Display; use crate::Ferese; use crate::render::{capture_output_elements, redraw_output}; @@ -14,7 +15,7 @@ pub(crate) fn capture_output( texture: &mut Option, output: &Output, scene: &FrameScene, -) -> Result<(), GlesError> { +) { for include_cursor in [true, false] { if !state.has_pending_screencopy(output, include_cursor) { continue; @@ -23,11 +24,13 @@ pub(crate) fn capture_output( // Sample the same scene into an independent target: the displayed // primary buffer may omit both a scanned-out client and hardware cursor. let elements = capture_output_elements(state, renderer, output, include_cursor, scene); - let target = render_capture(renderer, texture, output, &elements)?; + let Some(target) = capture_request(state, output, include_cursor, || { + render_capture(renderer, texture, output, &elements) + }) else { + continue; + }; state.process_screencopies(renderer, &target, output, include_cursor); } - - Ok(()) } pub(super) fn capture_mirror( @@ -36,7 +39,7 @@ pub(super) fn capture_mirror( source: &Output, scene: &FrameScene, output: &mut super::DirectOutput, -) -> Result<(), GlesError> { +) { for include_cursor in [true, false] { if !state.has_pending_screencopy(&output.output, include_cursor) { continue; @@ -44,17 +47,46 @@ pub(super) fn capture_mirror( let elements = capture_output_elements(state, renderer, source, include_cursor, scene); // Never sample mirror_texture: it contains the unfiltered display. let canvas = output.mirror_canvas.as_ref().expect("mirror display composed"); - super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements)?; + let Some(()) = capture_request(state, &output.output, include_cursor, || { + super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements) + }) else { + continue; + }; let element = super::mirror::fitted_texture( output.mirror_capture_texture.as_ref().unwrap().clone(), &output.output, smithay::backend::renderer::element::Id::new(), Default::default(), ); - let target = render_capture(renderer, &mut output.capture_texture, &output.output, &[element])?; + let Some(target) = capture_request(state, &output.output, include_cursor, || { + render_capture(renderer, &mut output.capture_texture, &output.output, &[element]) + }) else { + continue; + }; state.process_screencopies(renderer, &target, &output.output, include_cursor); } - Ok(()) +} + +fn capture_request( + state: &mut Ferese, + output: &Output, + include_cursor: bool, + render: impl FnOnce() -> Result, +) -> Option { + match render() { + Ok(target) => Some(target), + Err(error) => { + tracing::warn!(?output, include_cursor, %error, "screencopy render failed"); + state.fail_screencopies(output, include_cursor); + None + } + } +} + +pub(super) fn after_queue(queued: Result, capture: impl FnOnce()) -> Result { + let queued = queued?; + capture(); + Ok(queued) } pub(super) fn render_capture<'a, E: RenderElement>( @@ -178,3 +210,93 @@ mod tests { assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into()); } } + +#[cfg(test)] +mod failure_tests { + use std::cell::Cell; + + use calloop::channel::channel; + use smithay::output::Output; + use smithay::utils::{Buffer, Rectangle}; + + use super::*; + + #[test] + fn capture_allocation_failure_fails_one_request_after_display_submission() { + if !crate::startup_tests::private_runtime( + "backends::direct::capture::failure_tests::capture_allocation_failure_fails_one_request_after_display_submission", + ) { + return; + } + + let mut events = calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + let output = Output::new( + "capture-failure".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + let (sender, receiver) = channel(); + state + .pending_screencopies + .push(crate::handlers::screencopy::PendingScreencopy::owned( + 41, + 0, + sender, + output.clone(), + Rectangle::::from_size((16, 16).into()), + )); + let other_output = Output::new( + "other-capture".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + let (other_sender, other_receiver) = channel(); + state + .pending_screencopies + .push(crate::handlers::screencopy::PendingScreencopy::owned( + 42, + 0, + other_sender, + other_output.clone(), + Rectangle::::from_size((16, 16).into()), + )); + + let submitted = Cell::new(false); + let queued = { + submitted.set(true); + Ok::<(), ()>(()) + }; + after_queue(queued, || { + assert!(submitted.get(), "capture must run after the display queue succeeds"); + assert!( + capture_request(&mut state, &output, false, || Err::<(), _>( + "injected allocation failure" + )) + .is_none() + ); + }) + .unwrap(); + + assert!(submitted.get(), "capture failure must not undo queued display work"); + assert_eq!(state.pending_screencopies.len(), 1); + assert_eq!(state.pending_screencopies[0].output, other_output); + let outcome = receiver.try_recv().unwrap(); + assert_eq!(outcome.request, 41); + assert_eq!(outcome.part, 0); + assert!(outcome.result.is_err()); + assert!(receiver.try_recv().is_err(), "request should fail exactly once"); + assert!( + other_receiver.try_recv().is_err(), + "other output's request must stay pending" + ); + } +} diff --git a/compositor/src/backends/direct/lid.rs b/compositor/src/backends/direct/lid.rs index 95a7a7c..a1e1bd5 100644 --- a/compositor/src/backends/direct/lid.rs +++ b/compositor/src/backends/direct/lid.rs @@ -8,6 +8,7 @@ pub(super) struct Probe { serial: u64, revision: u64, reactivate: bool, + wake: bool, } #[derive(Default)] @@ -33,12 +34,13 @@ impl LidState { changed } - fn begin(&mut self, reactivate: bool) -> Probe { + fn begin(&mut self, reactivate: bool, wake: bool) -> Probe { self.serial = self.serial.wrapping_add(1); let probe = Probe { serial: self.serial, revision: self.revision, reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate), + wake: wake || self.pending.is_some_and(|pending| pending.wake), }; self.pending = Some(probe); probe @@ -269,6 +271,15 @@ impl Reader { } pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) { + request(state, reactivate, true); +} + +/// Listener recovery reconciles hardware without resetting idle activity. +pub(super) fn request_reconcile(state: &mut Ferese) { + request(state, false, false); +} + +fn request(state: &mut Ferese, reactivate: bool, wake: bool) { let Some(backend) = state.direct_backend.as_mut() else { return; }; @@ -289,7 +300,7 @@ pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) { } backend.lid_reader.suspend_requested = false; backend.lid_reader.lease.lock().unwrap().suspend = false; - let probe = backend.lid.begin(reactivate); + let probe = backend.lid.begin(reactivate, wake); let sent = backend.lid_reader.requests.send(probe).is_ok(); let timer = state .loop_handle @@ -329,10 +340,13 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option) { // Waking a locked session may request redraws. Hold them until the fresh // device/connector snapshot has been reconciled. backend.lid_reader.suspend_requested = false; - backend.topology.reconciling = true; - state.reset_animation_clock(); - state.lock_input_activity(); - state.direct_backend.as_mut().unwrap().topology.reconciling = false; + if probe.wake { + backend.topology.reconciling = true; + state.reset_animation_clock(); + state.lock_input_activity(); + state.direct_backend.as_mut().unwrap().topology.reconciling = false; + } + reconcile_outputs(state, reactivate); } @@ -431,7 +445,7 @@ mod tests { fn newer_input_wins_over_a_delayed_resume_read() { let mut lid = LidState::default(); lid.observe(true); - let probe = lid.begin(true); + let probe = lid.begin(true, true); lid.observe(false); assert_eq!(lid.complete(probe, Some(true)), Some(true)); assert!(!lid.closed()); @@ -440,11 +454,11 @@ mod tests { #[test] fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() { let mut lid = LidState::default(); - let probe = lid.begin(false); + let probe = lid.begin(false, true); lid.complete(probe, None); assert_eq!(lid.value, None); lid.observe(true); - let probe = lid.begin(true); + let probe = lid.begin(true, true); lid.complete(probe, None); assert_eq!(lid.value, Some(true)); } @@ -452,21 +466,39 @@ mod tests { #[test] fn pause_and_newer_resume_invalidate_old_replies() { let mut lid = LidState::default(); - let old = lid.begin(true); + let old = lid.begin(true, true); lid.pause(); assert_eq!(lid.complete(old, Some(true)), None); - let current = lid.begin(true); + let current = lid.begin(true, true); assert_eq!(lid.complete(old, Some(true)), None); assert!(lid.pending()); assert_eq!(lid.complete(current, Some(false)), Some(true)); assert!(!lid.pending()); } + #[test] + fn listener_recovery_does_not_request_idle_wake_or_reactivation() { + let mut lid = LidState::default(); + let recovery = lid.begin(false, false); + assert!(!recovery.wake); + assert_eq!(lid.complete(recovery, Some(false)), Some(false)); + } + + #[test] + fn listener_recovery_preserves_a_pending_real_wake() { + let mut lid = LidState::default(); + let resume = lid.begin(true, true); + let recovery = lid.begin(false, false); + assert!(recovery.wake); + assert_eq!(lid.complete(resume, Some(false)), None); + assert_eq!(lid.complete(recovery, Some(false)), Some(true)); + } + #[test] fn system_wake_cannot_cancel_pending_session_reactivation() { let mut lid = LidState::default(); - lid.begin(true); - let probe = lid.begin(false); + lid.begin(true, true); + let probe = lid.begin(false, true); assert_eq!(lid.complete(probe, Some(false)), Some(true)); } } diff --git a/compositor/src/backends/direct/recovery.rs b/compositor/src/backends/direct/recovery.rs new file mode 100644 index 0000000..e0aad1a --- /dev/null +++ b/compositor/src/backends/direct/recovery.rs @@ -0,0 +1,165 @@ +//! Uncertain KMS completion requires device reconciliation, not buffer reuse. +use super::*; + +const COMPLETION_TIMEOUT: Duration = Duration::from_secs(2); + +#[cfg(feature = "drm-fault-injection")] +pub(super) fn inject(mode: &str) -> bool { + use std::sync::LazyLock; + use std::sync::atomic::{AtomicBool, Ordering}; + + static MODE: LazyLock> = LazyLock::new(|| std::env::var("FERESE_TEST_DRM_FAILURE").ok()); + static USED: AtomicBool = AtomicBool::new(false); + MODE.as_deref() == Some(mode) && !USED.swap(true, Ordering::AcqRel) +} + +#[derive(Default)] +pub(super) struct Completion { + pending: Option, +} + +impl Completion { + fn queued(&mut self, now: Instant) { + self.pending = Some(now); + } + + pub fn retired(&mut self) { + self.pending = None; + } + + fn remaining(&self, now: Instant) -> Option { + self.pending + .map(|queued| COMPLETION_TIMEOUT.saturating_sub(now.saturating_duration_since(queued))) + } +} + +// Keep the submission result and the recovery decision together. An error must +// not be mistaken for a completed frame with no presentation feedback. +pub(super) fn retirement(result: Result, E>) -> (Option, bool, Option) { + match result { + Ok(feedback) => { + let retired = feedback.is_some(); + (feedback, retired, None) + } + Err(error) => (None, false, Some(error)), + } +} + +pub(super) fn arm_completion( + handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>, + node: DrmNode, + crtc: crtc::Handle, + output: &mut DirectOutput, +) -> bool { + output.completion.queued(Instant::now()); + if output.completion_timer.is_some() { + return true; + } + + let identity = output.output.clone(); + match handle.insert_source(Timer::from_duration(COMPLETION_TIMEOUT), move |_, _, state| { + let Some(output) = state + .direct_backend + .as_mut() + .and_then(|backend| backend.devices.get_mut(&node)) + .and_then(|device| device.outputs.get_mut(&crtc)) + .filter(|output| output.output == identity) + else { + return TimeoutAction::Drop; + }; + + if let Some(remaining) = output.completion.remaining(Instant::now()) { + if !remaining.is_zero() { + return TimeoutAction::ToDuration(remaining); + } + + output.completion_timer = None; + tracing::error!(?node, ?crtc, "DRM frame completion timed out; reconciling device"); + reconcile_device(state, node); + } else { + output.completion_timer = None; + } + + TimeoutAction::Drop + }) { + Ok(token) => { + output.completion_timer = Some(token); + true + } + Err(error) => { + tracing::error!(%error, ?node, ?crtc, "could not arm DRM completion watchdog"); + false + } + } +} + +pub(super) fn reconcile_device(state: &mut Ferese, node: DrmNode) { + let Some(backend) = state.direct_backend.as_mut() else { + return; + }; + backend.topology.dirty = true; + if !backend.topology.invalidated.insert(node) { + return; + } + + // Defer teardown until the DRM notifier callback has returned. Inactive + // sessions retain the invalidation for fresh enumeration on activation. + let active = backend.active; + if active { + match state.loop_handle.insert_source(Timer::immediate(), |_, _, state| { + reconcile_outputs(state, false); + TimeoutAction::Drop + }) { + Ok(_) => (), + Err(error) => tracing::error!(%error, "could not schedule DRM device recovery"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_retirement_does_not_release_pending_buffer_ownership() { + let now = Instant::now(); + let mut completion = Completion::default(); + completion.queued(now); + let (feedback, retired, error) = retirement::<(), _>(Err("injected KMS retirement failure")); + assert_eq!(feedback, None); + assert!(!retired); + assert!(error.is_some(), "the production handler must reconcile the device"); + assert_eq!(completion.remaining(now), Some(COMPLETION_TIMEOUT)); + assert_eq!(retirement::<(), &str>(Ok(None)), (None, false, None)); + assert_eq!(retirement::<(), &str>(Ok(Some(()))), (Some(()), true, None)); + + // Only successful retirement or device teardown can end uncertainty. + completion = Completion::default(); + assert_eq!(completion.remaining(now), None); + } + + #[test] + fn missing_completion_remains_pending_until_hardware_reconciliation() { + let now = Instant::now(); + let mut completion = Completion::default(); + completion.queued(now); + assert_eq!( + completion.remaining(now + COMPLETION_TIMEOUT - Duration::from_millis(1)), + Some(Duration::from_millis(1)) + ); + // A timeout observes uncertainty; it never frees the current buffer. + assert_eq!(completion.remaining(now + COMPLETION_TIMEOUT), Some(Duration::ZERO)); + assert_eq!( + completion.remaining(now + COMPLETION_TIMEOUT + Duration::from_secs(1)), + Some(Duration::ZERO) + ); + completion.retired(); + assert_eq!(completion.remaining(now), None); + completion.queued(now + Duration::from_secs(3)); + assert_eq!( + completion.remaining(now + Duration::from_secs(4)), + Some(Duration::from_secs(1)), + "existing timer must respect the newer frame's deadline" + ); + } +} diff --git a/compositor/src/backends/direct/scheduling.rs b/compositor/src/backends/direct/scheduling.rs index 45fbe42..27e44b9 100644 --- a/compositor/src/backends/direct/scheduling.rs +++ b/compositor/src/backends/direct/scheduling.rs @@ -9,9 +9,14 @@ pub(super) fn cancel_output_timers( handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>, output: &mut DirectOutput, ) { - for token in [output.render_timer.take(), output.callback_timer.take()] - .into_iter() - .flatten() + output.completion.retired(); + for token in [ + output.render_timer.take(), + output.callback_timer.take(), + output.completion_timer.take(), + ] + .into_iter() + .flatten() { handle.remove(token); } diff --git a/compositor/src/daemon.rs b/compositor/src/daemon.rs index d266961..fb5f046 100644 --- a/compositor/src/daemon.rs +++ b/compositor/src/daemon.rs @@ -1,7 +1,6 @@ //! Session-owned services. Never grant shell/effects privileges to daemons. use std::cell::RefCell; use std::collections::HashMap; -use std::os::fd::{FromRawFd, OwnedFd}; use std::process::Child; use std::rc::Rc; use std::time::{Duration, Instant}; @@ -13,6 +12,7 @@ use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken} use crate::Ferese; use crate::config::DaemonConfig; use crate::private_client::ClientCapabilities; +use crate::process::{pidfd, spawn_client, terminate_child}; struct Service { config: DaemonConfig, @@ -27,16 +27,6 @@ pub(crate) struct Runner( Option<(RegistrationToken, Instant)>, ); -fn pidfd(pid: u32) -> std::io::Result { - // SAFETY: pidfd_open has no pointer arguments and returns a new owned fd. - let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; - if fd < 0 { - return Err(std::io::Error::last_os_error()); - } - // SAFETY: a successful syscall returned a unique valid descriptor. - Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) }) -} - impl Runner { pub fn start(state: &mut Ferese) -> Rc> { let runner = Rc::new(RefCell::new(Self::new( @@ -153,7 +143,7 @@ impl Runner { // TERM/grace/reaping must not pause the compositor's event loop. for mut removed in previous { if let Some(mut child) = removed.child.take() { - std::thread::spawn(move || crate::terminate_child(&mut child)); + std::thread::spawn(move || terminate_child(&mut child)); } } } @@ -195,11 +185,7 @@ impl Runner { } } if !service.finished && now >= service.next_start { - service.child = crate::spawn_client( - state, - service.config.command.iter().map(Into::into).collect(), - ClientCapabilities::default(), - ); + service.child = spawn_client(state, service.config.command.iter(), ClientCapabilities::default()); service.next_start = now + Duration::from_secs(5); if service.child.is_none() && !service.config.restart { service.finished = true; @@ -211,7 +197,7 @@ impl Runner { pub fn stop(&mut self) { for service in &mut self.0 { if let Some(mut child) = service.child.take() { - crate::terminate_child(&mut child); + terminate_child(&mut child); } service.finished = true; } diff --git a/compositor/src/grabs/mod.rs b/compositor/src/grabs/mod.rs index e382919..b01fa49 100644 --- a/compositor/src/grabs/mod.rs +++ b/compositor/src/grabs/mod.rs @@ -2,4 +2,4 @@ mod move_grab; mod resize_grab; pub use move_grab::MoveSurfaceGrab; -pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab, handle_resize_commit}; +pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab}; diff --git a/compositor/src/grabs/resize_grab.rs b/compositor/src/grabs/resize_grab.rs index b824cb0..f830dde 100644 --- a/compositor/src/grabs/resize_grab.rs +++ b/compositor/src/grabs/resize_grab.rs @@ -1,5 +1,3 @@ -use std::cell::RefCell; - use smithay::desktop::Window; use smithay::input::pointer::{ AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, @@ -98,10 +96,6 @@ impl ResizeSurfaceGrab { edges: ResizeEdge, initial_rect: Rectangle, ) -> Self { - ResizeState::with( - window.toplevel().expect("managed window has a toplevel").wl_surface(), - |state| *state = ResizeState::Resizing { edges, initial_rect }, - ); Self { start_data, window, @@ -128,8 +122,8 @@ impl PointerGrab for ResizeSurfaceGrab { let initial = self.initial_rect; let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. }; let raw_top = initial.loc.y as f64 + if self.edges.top() { delta.y } else { 0. }; - let raw_right = (initial.loc.x + initial.size.w) as f64 + if self.edges.right() { delta.x } else { 0. }; - let raw_bottom = (initial.loc.y + initial.size.h) as f64 + if self.edges.bottom() { delta.y } else { 0. }; + let raw_right = initial.loc.x as f64 + initial.size.w as f64 + if self.edges.right() { delta.x } else { 0. }; + let raw_bottom = initial.loc.y as f64 + initial.size.h as f64 + if self.edges.bottom() { delta.y } else { 0. }; let raw = ferese_layout::Rect::new( raw_left, raw_top, @@ -203,12 +197,6 @@ impl PointerGrab for ResizeSurfaceGrab { state.size = Some(self.last_size); }); surface.send_pending_configure(); - ResizeState::with(surface.wl_surface(), |state| { - *state = ResizeState::WaitingForFinalCommit { - edges: self.edges, - initial_rect: self.initial_rect, - }; - }); } } @@ -307,57 +295,10 @@ impl PointerGrab for ResizeSurfaceGrab { state.size = Some(self.initial_rect.size); }); surface.send_pending_configure(); - ResizeState::with(surface.wl_surface(), |state| *state = ResizeState::Idle); data.set_floating_window_geometry(&self.window, self.initial_rect.loc, self.initial_rect.size); } } -#[derive(Clone, Copy, Debug, Default)] -enum ResizeState { - #[default] - Idle, - Resizing { - edges: ResizeEdge, - initial_rect: Rectangle, - }, - WaitingForFinalCommit { - edges: ResizeEdge, - initial_rect: Rectangle, - }, -} - -impl ResizeState { - fn with(surface: &WlSurface, callback: impl FnOnce(&mut Self) -> T) -> T { - with_states(surface, |states| { - states.data_map.insert_if_missing(RefCell::::default); - callback( - &mut states - .data_map - .get::>() - .expect("resize state was inserted") - .borrow_mut(), - ) - }) - } - - fn commit(&mut self) -> Option<(ResizeEdge, Rectangle)> { - match *self { - Self::Idle => None, - Self::Resizing { edges, initial_rect } => Some((edges, initial_rect)), - Self::WaitingForFinalCommit { edges, initial_rect } => { - *self = Self::Idle; - Some((edges, initial_rect)) - } - } - } -} - -pub fn handle_resize_commit(surface: &WlSurface) { - // The compositor's visual rect owns the anchored edge. A delayed client - // commit must not move the window or change its stacking order. - ResizeState::with(surface, ResizeState::commit); -} - fn constrained_size( initial: Size, delta: Point, @@ -368,21 +309,34 @@ fn constrained_size( let mut width = initial.w; let mut height = initial.h; if edges.left() { - width -= delta.x as i32; + width = width.saturating_sub(delta.x as i32); } else if edges.right() { - width += delta.x as i32; - } - if edges.top() { - height -= delta.y as i32; - } else if edges.bottom() { - height += delta.y as i32; + width = width.saturating_add(delta.x as i32); } - let maximum_width = if maximum.w == 0 { i32::MAX } else { maximum.w }; - let maximum_height = if maximum.h == 0 { i32::MAX } else { maximum.h }; + if edges.top() { + height = height.saturating_sub(delta.y as i32); + } else if edges.bottom() { + height = height.saturating_add(delta.y as i32); + } + + // Protocol validation rejects these ranges, but a grab may still be alive + // when its client is disconnected. Keep this path safe for any cached state. + let minimum_width = minimum.w.max(1); + let minimum_height = minimum.h.max(1); + let maximum_width = if maximum.w <= 0 { + i32::MAX + } else { + maximum.w.max(minimum_width) + }; + let maximum_height = if maximum.h <= 0 { + i32::MAX + } else { + maximum.h.max(minimum_height) + }; ( - width.clamp(minimum.w.max(1), maximum_width), - height.clamp(minimum.h.max(1), maximum_height), + width.clamp(minimum_width, maximum_width), + height.clamp(minimum_height, maximum_height), ) .into() } @@ -395,10 +349,11 @@ fn resized_rect( let mut location = initial.loc; if edges.left() { - location.x += initial.size.w - size.w; + location.x = location.x.saturating_add(initial.size.w.saturating_sub(size.w)); } + if edges.top() { - location.y += initial.size.h - size.h; + location.y = location.y.saturating_add(initial.size.h.saturating_sub(size.h)); } Rectangle::new(location, size) @@ -469,6 +424,32 @@ mod tests { assert_eq!(size, (1_024, 768).into()); } + #[test] + fn invalid_and_extreme_client_limits_cannot_panic_or_overflow() { + for (minimum, maximum) in [ + ((800, 700), (400, 300)), + ((-1, -2), (-3, -4)), + ((i32::MAX, i32::MAX), (1, 1)), + ((0, 0), (0, 0)), + ] { + for delta in [(f64::MAX, f64::MAX), (-f64::MAX, -f64::MAX), (0.0, 0.0)] { + let size = constrained_size( + (800, 600).into(), + delta.into(), + ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight), + minimum.into(), + maximum.into(), + ); + assert!(size.w >= 1 && size.h >= 1); + let _ = resized_rect( + Rectangle::new((100, 80).into(), (800, 600).into()), + size, + ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft), + ); + } + } + } + #[test] fn resized_rect_keeps_the_opposite_edge_fixed() { let initial = Rectangle::new((100, 80).into(), (800, 600).into()); diff --git a/compositor/src/handlers/activation.rs b/compositor/src/handlers/activation.rs index 9b7605b..b4686e6 100644 --- a/compositor/src/handlers/activation.rs +++ b/compositor/src/handlers/activation.rs @@ -1,4 +1,9 @@ -use std::time::Duration; +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use smithay::input::Seat; +use smithay::reexports::wayland_server::{Resource, backend::ClientId}; +use smithay::utils::Serial; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::utils::SERIAL_COUNTER; @@ -9,6 +14,50 @@ use smithay::wayland::xdg_activation::{ use crate::Ferese; const TOKEN_MAX_AGE: Duration = Duration::from_secs(10); +const MAX_INTERACTIONS: usize = 64; + +pub(crate) struct InputHistory { + events: VecDeque<(Serial, C, Instant)>, +} + +impl Default for InputHistory { + fn default() -> Self { + Self { + events: VecDeque::new(), + } + } +} + +impl InputHistory { + fn record(&mut self, serial: Serial, client: C, time: Instant) { + while self.events.len() >= MAX_INTERACTIONS { + self.events.pop_front(); + } + + self.events.push_back((serial, client, time)); + } + + fn validate(&self, serial: Serial, client: &C, now: Instant) -> Option { + self.events.iter().rev().find_map(|(event, owner, time)| { + (*event == serial && owner == client && now.saturating_duration_since(*time) <= TOKEN_MAX_AGE) + .then_some(*time) + }) + } +} + +struct ApprovedInteraction(Instant); + +impl Ferese { + pub(crate) fn record_activation_input(&mut self, serial: Serial, surface: Option) { + if self.session_lock.active() || self.input_capture.active() { + return; + } + + if let Some(client) = surface.and_then(|surface| surface.client()) { + self.activation_inputs.record(serial, client.id(), Instant::now()); + } + } +} impl XdgActivationHandler for Ferese { fn activation_state(&mut self) -> &mut XdgActivationState { @@ -16,15 +65,46 @@ impl XdgActivationHandler for Ferese { } fn token_created(&mut self, _token: XdgActivationToken, data: XdgActivationTokenData) -> bool { - data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE + if self.session_lock.active() || self.input_capture.active() { + return false; + } + + let Some((serial, seat)) = &data.serial else { + return false; + }; + let Some(client) = &data.client_id else { + return false; + }; + if Seat::::from_resource(seat).as_ref() != Some(&self.seat) + || seat.client().is_none_or(|owner| owner.id() != *client) + || data + .surface + .as_ref() + .is_some_and(|surface| surface.client().is_none_or(|owner| owner.id() != *client)) + { + return false; + } + + let Some(time) = self.activation_inputs.validate(*serial, client, Instant::now()) else { + return false; + }; + data.user_data.insert_if_missing(|| ApprovedInteraction(time)); + true } fn request_activation(&mut self, token: XdgActivationToken, data: XdgActivationTokenData, surface: WlSurface) { + self.xdg_activation_state.remove_token(&token); if self.session_lock.active() || self.input_capture.captures(1) { return; } - let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE; - self.xdg_activation_state.remove_token(&token); + + // The recipient may be a different client: launcher-to-app token handoff. + // Retain the original interaction time rather than renewing it at commit. + let valid = data.timestamp.elapsed() <= TOKEN_MAX_AGE + && data + .user_data + .get::() + .is_some_and(|proof| proof.0.elapsed() <= TOKEN_MAX_AGE); if !valid { tracing::debug!("ignored stale or non-interactive activation request"); return; @@ -60,3 +140,163 @@ impl XdgActivationHandler for Ferese { }); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn token_creation_validates_real_seat_owner_and_input_age() { + use smithay::reexports::calloop::EventLoop; + use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat; + use std::io::{Read, Write}; + use std::os::unix::net::UnixStream; + use std::sync::Arc; + + if !crate::startup_tests::private_runtime( + "handlers::activation::tests::token_creation_validates_real_seat_owner_and_input_age", + ) { + return; + } + + let mut events = EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + let (server, mut wire) = UnixStream::pair().unwrap(); + let launcher = state + .display_handle + .insert_client(server, Arc::new(crate::state::ClientState::default())) + .unwrap(); + let send = |wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]| { + let bytes: Vec<_> = [object, ((args.len() as u32 + 2) * 4) << 16 | opcode] + .into_iter() + .chain(args.iter().copied()) + .flat_map(u32::to_ne_bytes) + .collect(); + wire.write_all(&bytes).unwrap(); + }; + send(&mut wire, 1, 1, &[2]); // wl_display.get_registry + events.dispatch(Duration::from_millis(1), &mut state).unwrap(); + state.display_handle.flush_clients().unwrap(); + wire.set_nonblocking(true).unwrap(); + let mut globals = Vec::new(); + let _ = wire.read_to_end(&mut globals); + wire.set_nonblocking(false).unwrap(); + let mut offset = 0; + let mut seat_name = None; + while offset < globals.len() { + let header = u32::from_ne_bytes(globals[offset + 4..offset + 8].try_into().unwrap()); + if u32::from_ne_bytes(globals[offset..offset + 4].try_into().unwrap()) == 2 && header & 0xffff == 0 { + let length = u32::from_ne_bytes(globals[offset + 12..offset + 16].try_into().unwrap()) as usize; + if &globals[offset + 16..offset + 16 + length] == b"wl_seat\0" { + seat_name = Some(u32::from_ne_bytes(globals[offset + 8..offset + 12].try_into().unwrap())); + } + } + + offset += (header >> 16) as usize; + } + + let mut args = vec![seat_name.expect("seat global"), 8]; + args.extend( + b"wl_seat\0" + .chunks_exact(4) + .map(|word| u32::from_ne_bytes(word.try_into().unwrap())), + ); + args.extend([7, 3]); + send(&mut wire, 2, 0, &args); + events.dispatch(Duration::from_millis(1), &mut state).unwrap(); + let seat: WlSeat = state + .seat + .client_seats(&launcher) + .into_iter() + .next() + .expect("bound seat"); + let (token, _) = state.xdg_activation_state.create_external_token(None); + let token = token.clone(); + let serial = SERIAL_COUNTER.next_serial(); + let data = || XdgActivationTokenData { + client_id: Some(launcher.id()), + serial: Some((serial, seat.clone())), + ..Default::default() + }; + assert!( + !state.token_created(token.clone(), data()), + "fabricated serial accepted" + ); + state.activation_inputs.record( + serial, + launcher.id(), + Instant::now() - TOKEN_MAX_AGE - Duration::from_millis(1), + ); + assert!( + !state.token_created(token.clone(), data()), + "fresh token renewed stale input" + ); + state.activation_inputs.record(serial, launcher.id(), Instant::now()); + let approved = data(); + assert!(state.token_created(token.clone(), approved.clone())); + assert!(approved.user_data.get::().is_some()); + + let (server, mut recipient_wire) = UnixStream::pair().unwrap(); + let recipient = state + .display_handle + .insert_client(server, Arc::new(crate::state::ClientState::default())) + .unwrap(); + let mut stolen = data(); + stolen.client_id = Some(recipient.id()); + assert!( + !state.token_created(token.clone(), stolen), + "other client claimed launcher's seat/input" + ); + // The proof is attached to the token, not the recipient identity. This + // is what lets a validated launcher hand it to a different application. + assert_ne!(approved.client_id, Some(recipient.id())); + assert!(approved.user_data.get::().unwrap().0.elapsed() < TOKEN_MAX_AGE); + + // Exercise the production handler with a real recipient toplevel. + // Creating the window needs no GPU or client buffer. + use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_wm_base::XdgWmBase; + use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor; + use smithay::wayland::shell::xdg::XdgWmBaseUserData; + let compositor = recipient + .create_resource::(&state.display_handle, 6, ()) + .unwrap(); + let shell = recipient + .create_resource::(&state.display_handle, 6, Default::default()) + .unwrap(); + send(&mut recipient_wire, compositor.id().protocol_id(), 0, &[2]); + send(&mut recipient_wire, shell.id().protocol_id(), 2, &[3, 2]); + send(&mut recipient_wire, 3, 1, &[4]); + events.dispatch(Duration::from_millis(1), &mut state).unwrap(); + let target = state + .space + .elements() + .find_map(|window| { + let surface = window.toplevel()?.wl_surface(); + surface + .client() + .is_some_and(|client| client.id() == recipient.id()) + .then(|| surface.clone()) + }) + .expect("recipient's toplevel"); + state.request_activation(token, approved, target.clone()); + assert_eq!(state.seat.get_keyboard().unwrap().current_focus(), Some(target)); + } + + #[test] + fn validates_owner_serial_and_original_interaction_age() { + let now = Instant::now(); + let mut history = InputHistory::default(); + let serial = Serial::from(12); + history.record(serial, "launcher", now); + assert_eq!(history.validate(serial, &"launcher", now), Some(now)); + assert_eq!(history.validate(Serial::from(13), &"launcher", now), None); + assert_eq!(history.validate(serial, &"other-client", now), None); + assert_eq!( + history.validate(serial, &"launcher", now + TOKEN_MAX_AGE + Duration::from_millis(1)), + None + ); + // An approved token retains the interaction time across client handoff. + let approved = ApprovedInteraction(history.validate(serial, &"launcher", now).unwrap()); + assert_eq!(approved.0, now); + } +} diff --git a/compositor/src/handlers/compositor.rs b/compositor/src/handlers/compositor.rs index 074eec8..2dbab21 100644 --- a/compositor/src/handlers/compositor.rs +++ b/compositor/src/handlers/compositor.rs @@ -5,7 +5,7 @@ use smithay::reexports::wayland_server::protocol::wl_buffer; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::wayland::buffer::BufferHandler; use smithay::wayland::compositor::{ - CompositorClientState, CompositorHandler, CompositorState, get_parent, is_sync_subsurface, + CompositorClientState, CompositorHandler, CompositorState, get_parent, get_role, is_sync_subsurface, }; use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier}; use smithay::wayland::shm::{ShmHandler, ShmState}; @@ -35,6 +35,14 @@ impl CompositorHandler for Ferese { } fn commit(&mut self, surface: &WlSurface) { + if get_role(surface) == Some(smithay::wayland::shell::xdg::XDG_TOPLEVEL_ROLE) + && let Some(window) = self.window_for_surface(surface) + && let Some(toplevel) = window.toplevel() + && !xdg_shell::validate_size_constraints(toplevel) + { + return; + } + self.update_surface_preferences(surface); self.capture_resize_before_commit(surface); self.capture_close_before_commit(surface); @@ -45,12 +53,7 @@ impl CompositorHandler for Ferese { while let Some(parent) = get_parent(&root) { root = parent; } - let window = { - self.space - .elements() - .find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root)) - .cloned() - }; + let window = self.window_for_surface(&root); if let Some(window) = window { window.on_commit(); @@ -76,7 +79,7 @@ impl CompositorHandler for Ferese { self.refresh_idle_inhibition(); layer_shell::handle_commit(self, surface); - xdg_shell::handle_commit(&mut self.popups, &mut self.space, surface); + xdg_shell::handle_commit(self, surface); crate::backends::direct::render_surface(self, surface); } diff --git a/compositor/src/handlers/mod.rs b/compositor/src/handlers/mod.rs index 608bec9..7565e69 100644 --- a/compositor/src/handlers/mod.rs +++ b/compositor/src/handlers/mod.rs @@ -1,4 +1,4 @@ -mod activation; +pub(crate) mod activation; mod compositor; mod input_method; mod layer_shell; @@ -60,6 +60,12 @@ impl SeatHandler for Ferese { } fn focus_changed(&mut self, seat: &Seat, focused: Option<&WlSurface>) { + // Smithay has already sent enter. last_enter uses a separate mutex; + // querying current_focus here would reenter the held keyboard lock. + if let Some(serial) = seat.get_keyboard().and_then(|keyboard| keyboard.last_enter()) { + self.record_activation_input(serial, focused.cloned()); + } + if let Some(id) = focused.and_then(|surface| self.windows.id_for_surface(surface)) { self.focus_history.record(id); } diff --git a/compositor/src/handlers/screencopy.rs b/compositor/src/handlers/screencopy.rs index 9330f3d..6080692 100644 --- a/compositor/src/handlers/screencopy.rs +++ b/compositor/src/handlers/screencopy.rs @@ -49,6 +49,7 @@ pub(crate) enum CaptureSink { part: usize, complete: PartSender, published: AtomicBool, + permit: Option, }, } @@ -59,6 +60,7 @@ impl CaptureSink { part, complete, published: AtomicBool::new(false), + permit: None, } } @@ -73,12 +75,14 @@ impl CaptureSink { part, complete, published, + permit, } => { if !published.swap(true, Ordering::AcqRel) { let _ = complete.send(PartOutcome { request: *request, part: *part, result: Err("Screenshot part failed".to_string()), + permit: permit.clone(), }); } } @@ -93,6 +97,7 @@ impl CaptureSink { part, complete, published, + permit, } = self else { return false; @@ -105,12 +110,20 @@ impl CaptureSink { request: *request, part: *part, result: Ok(buffer), + permit: permit.clone(), }) .is_ok() } } impl PendingScreencopy { + pub(crate) fn with_permit(mut self, permit: Option) -> Self { + if let CaptureSink::Owned { permit: stored, .. } = &mut self.sink { + *stored = permit; + } + self + } + pub(crate) fn owned( request: u64, part: usize, @@ -333,6 +346,19 @@ impl Ferese { .iter() .any(|capture| capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)) } + + pub(crate) fn fail_screencopies(&mut self, output: &Output, overlay_cursor: bool) { + let mut remaining = Vec::new(); + for capture in std::mem::take(&mut self.pending_screencopies) { + if capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor) { + capture.sink.fail(); + } else { + remaining.push(capture); + } + } + + self.pending_screencopies = remaining; + } } fn cursor_overlay_matches(requested: bool, rendered: bool) -> bool { diff --git a/compositor/src/handlers/screenshot.rs b/compositor/src/handlers/screenshot.rs index e1d023b..6a114b2 100644 --- a/compositor/src/handlers/screenshot.rs +++ b/compositor/src/handlers/screenshot.rs @@ -2,6 +2,10 @@ use std::path::{Path, PathBuf}; use std::sync::mpsc::SyncSender as ReplySender; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use std::time::{Duration, Instant}; use ferese_ipc::Response; @@ -315,6 +319,7 @@ pub(crate) struct PartOutcome { pub(crate) request: u64, pub(crate) part: usize, pub(crate) result: Result, + pub(crate) permit: Option, } // A calloop channel so publishing a part also wakes the event loop, whichever @@ -352,11 +357,16 @@ struct Request { parts: Vec<(PartSpec, PartState)>, received: usize, deadline: Instant, + permit: Option, } pub(crate) enum Action { None, - Encode { request: u64, frames: Vec }, + Encode { + request: u64, + frames: Vec, + permit: BudgetPermit, + }, // The path was already sent to the caller; it must not be cleaned up. Delivered(PathBuf), // The request is gone, so this file is ours to remove. @@ -365,6 +375,45 @@ pub(crate) enum Action { const MAX_OUTSTANDING: usize = 8; const MAX_PENDING_BYTES: usize = 256 * 1024 * 1024; + +#[derive(Default, Debug)] +struct Budget { + used: AtomicUsize, +} + +#[derive(Debug)] +struct Reservation { + budget: Arc, + bytes: usize, +} + +impl Drop for Reservation { + fn drop(&mut self) { + self.budget.used.fetch_sub(self.bytes, Ordering::AcqRel); + } +} + +#[derive(Clone, Debug)] +pub(crate) struct BudgetPermit { + _reservation: Arc, +} + +impl Budget { + fn reserve(self: &Arc, bytes: usize) -> Result { + self.used + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |used| { + used.checked_add(bytes).filter(|total| *total <= MAX_PENDING_BYTES) + }) + .map_err(|_| "Screenshot memory budget is exhausted".to_string())?; + Ok(BudgetPermit { + _reservation: Arc::new(Reservation { + budget: self.clone(), + bytes, + }), + }) + } +} + // A request is answered or abandoned this long after it is admitted. Parts are // produced by the render path, so an output that is never repainted would // otherwise leave the request in Collecting forever, holding one of the few @@ -375,6 +424,7 @@ pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) struct Coordinator { requests: std::collections::HashMap, next_id: u64, + budget: Arc, deadline_changed: Option)>>, notified_deadline: Option, } @@ -432,6 +482,7 @@ impl Coordinator { return Err("Screenshot region is too large".into()); } + let permit = self.budget.reserve(pending)?; self.next_id = self.next_id.wrapping_add(1); let id = self.next_id; self.requests.insert( @@ -444,12 +495,17 @@ impl Coordinator { parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(), received: 0, deadline: now + REQUEST_TIMEOUT, + permit: Some(permit), }, ); self.notify_deadline_change(); Ok(id) } + pub(crate) fn permit(&self, request: u64) -> Option { + self.requests.get(&request).and_then(|entry| entry.permit.clone()) + } + // Used by the completion tests to assert that a request is still tracked. #[allow(dead_code)] pub(crate) fn is_live(&self, request: u64) -> bool { @@ -513,7 +569,11 @@ impl Coordinator { }) .collect(); entry.state = RequestState::Encoding; - Action::Encode { request, frames } + Action::Encode { + request, + frames, + permit: entry.permit.take().expect("collecting request owns its reservation"), + } } pub(crate) fn on_encoded(&mut self, request: u64, result: Result) -> Action { @@ -1045,6 +1105,43 @@ mod completion { )); } + #[test] + fn admission_enforces_one_budget_across_requests_and_late_readbacks() { + let (reply, _received) = sync_channel(8); + let mut coordinator = Coordinator::new(); + let first = coordinator.admit(1, 1, reply.clone(), vec![spec(4096, 8192)]).unwrap(); + let second = coordinator.admit(1, 2, reply.clone(), vec![spec(4096, 8192)]).unwrap(); + assert!(coordinator.admit(1, 3, reply.clone(), vec![spec(2, 2)]).is_err()); + + // A cancelled request can still have a readback retained by a sink or channel. + let late_readback = coordinator.permit(first).unwrap(); + coordinator.reject(first, "cancelled"); + assert!(coordinator.admit(1, 4, reply.clone(), vec![spec(2, 2)]).is_err()); + drop(late_readback); + assert!(coordinator.admit(1, 5, reply, vec![spec(2, 2)]).is_ok()); + coordinator.reject(second, "finished"); + } + + #[test] + fn aggregate_reservations_survive_request_cancellation_during_encoding() { + let mut harness = Harness::new(1); + let id = harness.coordinator.next_id; + let bytes = harness.coordinator.budget.used.load(Ordering::Acquire); + let Action::Encode { frames, permit, .. } = harness.coordinator.on_part(id, 0, Ok(buffer(2, 2))) else { + panic!("ready request should encode"); + }; + harness.coordinator.reject(id, "cancelled while encoder owns pixels"); + assert_eq!(harness.coordinator.budget.used.load(Ordering::Acquire), bytes); + assert!(harness.coordinator.budget.reserve(MAX_PENDING_BYTES).is_err()); + drop(frames); + drop(permit); + assert_eq!(harness.coordinator.budget.used.load(Ordering::Acquire), 0); + let first = harness.coordinator.budget.reserve(MAX_PENDING_BYTES / 2 + 1).unwrap(); + assert!(harness.coordinator.budget.reserve(MAX_PENDING_BYTES / 2).is_err()); + drop(first); + assert!(harness.coordinator.budget.reserve(MAX_PENDING_BYTES).is_ok()); + } + #[test] fn all_parts_ready_hands_frames_to_the_encoder() { let mut harness = Harness::new(2); @@ -1055,7 +1152,7 @@ mod completion { Action::None )); let action = harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))); - let Action::Encode { request, frames } = action else { + let Action::Encode { request, frames, .. } = action else { panic!("expected the request to move to encoding"); }; assert_eq!(request, id); @@ -1407,7 +1504,9 @@ mod completion { // buffer that was handed off is never replaced. assert!(!sink.publish(buffer(64, 64))); - let PartOutcome { request, part, result } = receiver.try_recv().expect("published once"); + let PartOutcome { + request, part, result, .. + } = receiver.try_recv().expect("published once"); assert_eq!((request, part), (7, 1)); assert_eq!(result.unwrap().width, 2); assert!(receiver.try_recv().is_err(), "no second buffer was sent"); @@ -1422,7 +1521,9 @@ mod completion { // failure has already been reported. assert!(!sink.publish(buffer(2, 2))); - let PartOutcome { request, part, result } = receiver.try_recv().expect("answered"); + let PartOutcome { + request, part, result, .. + } = receiver.try_recv().expect("answered"); assert_eq!((request, part), (3, 0)); assert!(result.is_err()); assert!(receiver.try_recv().is_err()); diff --git a/compositor/src/handlers/screenshot_worker.rs b/compositor/src/handlers/screenshot_worker.rs index 09cddba..b2ef510 100644 --- a/compositor/src/handlers/screenshot_worker.rs +++ b/compositor/src/handlers/screenshot_worker.rs @@ -20,6 +20,7 @@ const FILE_MODE: u32 = 0o600; pub(crate) struct Job { pub(crate) request: u64, pub(crate) frames: Vec, + pub(crate) _permit: Option, } pub(crate) struct Encoded { @@ -54,17 +55,13 @@ impl Worker { fn run(receiver: Receiver, results: channel::SyncSender) { while let Ok(job) = receiver.recv() { let result = encode(&job); + let request = job.request; + drop(job); // This send cannot block. Each request produces exactly one Encode, so at // most QUEUE_CAPACITY jobs can be queued while one more is being encoded, // and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here // would stall the worker with no way for the loop to recover it. - if results - .send(Encoded { - request: job.request, - result, - }) - .is_err() - { + if results.send(Encoded { request, result }).is_err() { return; } } @@ -131,25 +128,51 @@ fn encode_png(canvas: &Canvas) -> Result, String> { // The client is told to open this path and unlink it immediately, so the // directory is ours, private, and files are created with the final mode. +struct StagedFile { + path: PathBuf, + keep: bool, +} + +impl Drop for StagedFile { + fn drop(&mut self) { + if !self.keep { + let _ = fs::remove_file(&self.path); + } + } +} + fn write_private_file(png: &[u8]) -> Result { - let directory = private_directory()?; - for attempt in 0..64u32 { - let path = directory.join(format!("shot-{}-{attempt}.png", std::process::id())); - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true).mode(FILE_MODE); - match options.open(&path) { - Ok(mut file) => { - use io::Write; - file.write_all(png) - .and_then(|()| file.sync_all()) - .map_err(|error| format!("Could not write screenshot: {error}"))?; - return Ok(path); - } + write_private_file_in(&private_directory()?, png, |file, bytes| { + use io::Write; + file.write_all(bytes)?; + file.sync_all() + }) +} + +fn write_private_file_in( + directory: &std::path::Path, + png: &[u8], + write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>, +) -> Result { + static NEXT_FILE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let (mut file, mut staged) = loop { + let sequence = NEXT_FILE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = directory.join(format!("shot-{}-{sequence}.png", std::process::id())); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(FILE_MODE) + .open(&path) + { + Ok(file) => break (file, StagedFile { path, keep: false }), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(format!("Could not write screenshot: {error}")), } - } - Err("Could not find an unused screenshot filename".into()) + }; + + write(&mut file, png).map_err(|error| format!("Could not write screenshot: {error}"))?; + staged.keep = true; + Ok(std::mem::take(&mut staged.path)) } fn private_directory() -> Result { @@ -209,6 +232,7 @@ mod tests { let job = Job { request: 1, frames: vec![frame(4, 3)], + _permit: None, }; let mut canvas = compose(&job.frames).unwrap(); for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() { @@ -227,6 +251,26 @@ mod tests { assert_eq!(decoded.get_pixel(0, 0)[3], 255); } + #[test] + fn staging_has_no_fixed_filename_capacity_and_cleans_partial_writes() { + let directory = tempfile::tempdir().unwrap(); + for _ in 0..100 { + write_private_file_in(directory.path(), b"png", |file, bytes| { + use io::Write; + file.write_all(bytes) + }) + .unwrap(); + } + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100); + let result = write_private_file_in(directory.path(), b"partial", |file, bytes| { + use io::Write; + file.write_all(bytes)?; + Err(io::Error::other("injected write/sync failure")) + }); + assert!(result.is_err()); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100); + } + #[test] fn staged_files_are_owner_only() { let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec(); diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 6d5c08b..ef80f96 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -1,6 +1,6 @@ use smithay::desktop::{ - PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window, WindowSurfaceType, - find_popup_root_surface, get_popup_toplevel_coords, + PopupKeyboardGrab, PopupKind, PopupPointerGrab, Window, WindowSurfaceType, find_popup_root_surface, + get_popup_toplevel_coords, }; use smithay::input::Seat; use smithay::input::pointer::{Focus, GrabStartData}; @@ -17,7 +17,7 @@ use smithay::wayland::shell::xdg::{ }; use crate::Ferese; -use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit}; +use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab}; impl XdgShellHandler for Ferese { fn xdg_shell_state(&mut self) -> &mut XdgShellState { @@ -82,19 +82,7 @@ impl XdgShellHandler for Ferese { fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { // Hidden workspace windows are managed but unmapped from Space. - let Some(window) = self - .windows - .id_for_surface(surface.wl_surface()) - .and_then(|id| self.windows.window(id)) - .or_else(|| { - self.space.elements().find(|window| { - window - .toplevel() - .is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface()) - }) - }) - .cloned() - else { + let Some(window) = self.window_for_surface(surface.wl_surface()) else { return; }; @@ -330,17 +318,27 @@ pub(crate) fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool { }) } -pub fn handle_commit(popups: &mut PopupManager, space: &mut Space, surface: &WlSurface) { - handle_resize_commit(surface); - if let Some(window) = space - .elements() - .find(|window| { - window - .toplevel() - .is_some_and(|toplevel| toplevel.wl_surface() == surface) - }) - .cloned() - { +pub(crate) fn validate_size_constraints(toplevel: &ToplevelSurface) -> bool { + use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; + use smithay::wayland::shell::xdg::SurfaceCachedState; + + let valid = with_states(toplevel.wl_surface(), |states| { + let mut cached = states.cached_state.get::(); + let state = cached.current(); + let valid_axis = |min: i32, max: i32| min >= 0 && max >= 0 && (max == 0 || min <= max); + valid_axis(state.min_size.w, state.max_size.w) && valid_axis(state.min_size.h, state.max_size.h) + }); + if !valid { + toplevel + .xdg_toplevel() + .post_error(xdg_toplevel::Error::InvalidSize, "invalid minimum/maximum window size"); + } + + valid +} + +pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) { + if let Some(window) = state.window_for_surface(surface) { let configured = with_states(surface, |states| { states .data_map @@ -358,8 +356,8 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space, surfa } } - popups.commit(surface); - if let Some(PopupKind::Xdg(popup)) = popups.find_popup(surface) + state.popups.commit(surface); + if let Some(PopupKind::Xdg(popup)) = state.popups.find_popup(surface) && !popup.is_initial_configure_sent() { popup.send_configure().expect("initial popup configure is valid"); diff --git a/compositor/src/input.rs b/compositor/src/input.rs index ed1a93e..f3cc216 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -1,5 +1,3 @@ -use std::process::Command; - use ferese_layout::Direction; use smithay::backend::input::{ AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, GestureEndEvent, @@ -22,6 +20,7 @@ use crate::Ferese; use crate::config::BindingAction; use crate::gestures::SwipeDirection; use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab}; +use crate::process::spawn_client; const BTN_LEFT: u32 = 0x110; const BTN_RIGHT: u32 = 0x111; @@ -278,11 +277,16 @@ impl Ferese { keyboard.set_focus(self, None, SERIAL_COUNTER.next_serial()); } } - keyboard.input::<(), _>( + let serial = SERIAL_COUNTER.next_serial(); + let interaction_focus = keyboard.current_focus(); + let client_grab = keyboard + .with_grab(|_, grab| grab.is::>()) + .unwrap_or(true); + let intercepted = keyboard.input::<(), _>( self, keycode, state, - SERIAL_COUNTER.next_serial(), + serial, event.time_msec(), |data, modifiers, keysym| { if data.focus_cycle.is_some() { @@ -423,6 +427,10 @@ impl Ferese { FilterResult::Intercept(()) }, ); + if state == KeyState::Pressed && intercepted.is_none() && client_grab { + self.record_activation_input(serial, interaction_focus); + } + if was_captured && !self.input_capture.active() { self.restore_input_capture_focus(); } @@ -534,6 +542,10 @@ impl Ferese { if self.overview.is_active() { let position = pointer.current_location(); if self.layer_under(position).is_some() { + if event.state() == ButtonState::Pressed { + self.record_activation_input(serial, pointer.current_focus()); + } + pointer.button( self, &ButtonEvent { @@ -584,6 +596,16 @@ impl Ferese { self.focus_window_at(pointer.current_location(), serial, true); } + let client_grab = pointer + .with_grab(|_, grab| { + grab.is::>() + || grab.is::>() + }) + .unwrap_or(true); + if event.state() == ButtonState::Pressed && client_grab { + self.record_activation_input(serial, pointer.current_focus()); + } + pointer.button( self, &ButtonEvent { @@ -654,6 +676,10 @@ impl Ferese { let touch = self.seat.get_touch().expect("seat has touch capability"); let serial = SERIAL_COUNTER.next_serial(); + if !touch.is_grabbed() { + self.record_activation_input(serial, self.surface_under(location).map(|(surface, _)| surface)); + } + touch.down( self, self.surface_under(location), @@ -1042,20 +1068,10 @@ impl Ferese { match action { BindingAction::None => {} BindingAction::Spawn(argv) => { - let (program, args) = argv.split_first().expect("binding commands are validated"); - - match Command::new(program) - .args(args) - .env("WAYLAND_DISPLAY", &self.socket_name) - .env_remove("WAYLAND_SOCKET") - .spawn() + if let Some(child) = + spawn_client(self, argv.iter(), crate::private_client::ClientCapabilities::default()) { - Ok(child) => { - tracing::info!(%program, pid = child.id(), "spawned binding command") - } - Err(error) => { - tracing::warn!(%program, %error, "failed to spawn binding command") - } + crate::process::watch_client_exit(&self.loop_handle, child); } } BindingAction::Close => self.close_focused_window(), @@ -1166,6 +1182,43 @@ fn overview_escape(symbol: u32, overview_active: bool) -> bool { mod tests { use super::{emergency_shortcut_escape, overview_escape, virtual_terminal}; + #[test] + fn binding_commands_are_reaped_while_the_compositor_runs() { + if !crate::startup_tests::private_runtime("input::tests::binding_commands_are_reaped_while_the_compositor_runs") + { + return; + } + + let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + let children_path = format!("/proc/self/task/{}/children", unsafe { libc::gettid() }); + assert!(std::fs::read_to_string(&children_path).unwrap().trim().is_empty()); + for _ in 0..24 { + state.execute_binding(crate::config::BindingAction::Spawn(vec!["/bin/true".into()].into())); + } + + let children: Vec = std::fs::read_to_string(&children_path) + .unwrap() + .split_whitespace() + .map(|pid| pid.parse().unwrap()) + .collect(); + assert_eq!(children.len(), 24); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while !std::fs::read_to_string(&children_path).unwrap().trim().is_empty() { + assert!(std::time::Instant::now() < deadline, "binding children were not reaped"); + events + .dispatch(Some(std::time::Duration::from_millis(20)), &mut state) + .unwrap(); + crate::after_dispatch(&mut state); + } + + for pid in children { + let mut status = 0; + assert_eq!(unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }, -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ECHILD)); + } + } + #[test] fn escape_closes_only_an_active_overview() { assert!(overview_escape(keysyms::KEY_Escape, true)); diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 27ed1bd..b7aefa1 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -908,14 +908,16 @@ impl Ferese { for (position, part) in planned.iter().enumerate() { let (output, _) = &targets[part.index]; - self.pending_screencopies - .push(crate::handlers::screencopy::PendingScreencopy::owned( + self.pending_screencopies.push( + crate::handlers::screencopy::PendingScreencopy::owned( id, position, parts.clone(), output.clone(), part.buffer, - )); + ) + .with_permit(self.screenshot.permit(id)), + ); } // The nested backend redraws on its refresh timer; this drives the @@ -1006,11 +1008,20 @@ impl Ferese { request: capture, part: 0, result, + permit: self.screenshot.permit(capture), }); } pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) { - let action = self.screenshot.on_part(outcome.request, outcome.part, outcome.result); + let PartOutcome { + request, + part, + result, + permit, + } = outcome; + let action = self.screenshot.on_part(request, part, result); + // Pixels have moved into the coordinator/job before the channel lease is dropped. + drop(permit); self.apply_screenshot_action(action); } @@ -1022,11 +1033,20 @@ impl Ferese { fn apply_screenshot_action(&mut self, action: Action) { match action { Action::None => {} - Action::Encode { request, frames } => { - let submitted = self - .screenshot_worker - .as_ref() - .is_some_and(|worker| worker.submit(Job { request, frames }).is_ok()); + Action::Encode { + request, + frames, + permit, + } => { + let submitted = self.screenshot_worker.as_ref().is_some_and(|worker| { + worker + .submit(Job { + request, + frames, + _permit: Some(permit), + }) + .is_ok() + }); if !submitted { self.screenshot.reject(request, "Screenshot encoding queue is full"); } diff --git a/compositor/src/main.rs b/compositor/src/main.rs index d73ca09..3d717fd 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -1,5 +1,3 @@ -#![allow(irrefutable_let_patterns)] - mod backends; mod config; mod cursor; @@ -26,6 +24,7 @@ mod portal_shortcuts; mod presentation; mod presentation_policy; mod private_client; +mod process; mod reload; mod render; #[cfg(feature = "resize-metrics")] @@ -35,6 +34,8 @@ mod resume; mod session_lock; mod shell_control; mod stacking; +#[cfg(test)] +mod startup_tests; mod state; mod theme; mod wallpaper; @@ -42,11 +43,10 @@ mod window_rules; mod winit; use std::error::Error; -use std::process::{Child, Command}; -use std::time::Duration; -use std::{io, thread}; +use std::io; use calloop::signals::{Signal, Signals}; +use process::{spawn_client, terminate_child, watch_client_exit}; use smithay::reexports::calloop::EventLoop; use smithay::reexports::wayland_server::Display; pub use state::{Ferese, RuntimeConfig}; @@ -64,13 +64,8 @@ fn main() -> Result<(), Box> { if args.len() != 2 { return Err("usage: ferese --check-config PATH".into()); } - let source = std::fs::read_to_string(&args[1])?; - theme::prepare( - &source, - std::path::Path::new(&args[1]) - .parent() - .unwrap_or(std::path::Path::new(".")), - )?; + + check_config(std::path::Path::new(&args[1]))?; println!("Configuration is valid"); return Ok(()); } @@ -81,11 +76,10 @@ fn main() -> Result<(), Box> { .filter(|path| path.exists()) .map(|path| theme::read_source(&path)) .transpose()?; - let source = initial_source.as_deref().unwrap_or(""); let directory = config::config_path() .and_then(|path| path.parent().map(ToOwned::to_owned)) .unwrap_or_else(|| std::path::PathBuf::from(".")); - let (_, runtime, candidate) = theme::prepare(source, &directory)?; + let (_, runtime, candidate) = theme::prepare(initial_source.as_deref().unwrap_or(""), &directory)?; let mut event_loop = EventLoop::try_new()?; let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; event_loop @@ -109,56 +103,18 @@ fn main() -> Result<(), Box> { value }); theme::init(&mut event_loop, &mut state, candidate)?; - resume::init(&mut event_loop)?; + let _resume_monitor = resume::init(&mut event_loop)?; idle_inhibition::media::init(&mut event_loop)?; overview::init_font_loader(&mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?; + info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); - let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities); + + let child = spawn_client(&mut state, launch.client, launch.client_capabilities) + .map(|child| watch_client_exit(&state.loop_handle, child)); let runner = daemon::Runner::start(&mut state); state.daemons = Some(runner.clone()); - let reload_handle = event_loop.handle(); - let result = event_loop.run(None, &mut state, |state| { - state.poll_theme(); - // The decoder wakes the loop after publishing its result, including - // when no output has a pending frame. - let wallpaper_changed = state.wallpaper.poll(); - // All input/Wayland callbacks have returned, releasing seat locks. - if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) { - state.cancel_focus_cycle(); - } - // Coalesce cursor changes and redraw here, never inside cursor_image. - if state.input_capture.restore_focus { - state.restore_input_capture_focus(); - } - state.apply_unlocked_pointer_hint(); - let wallpaper_retry = state.wallpaper.take_retry_wakeup(); - let cursor_changed = std::mem::take(&mut state.cursor_redraw_pending); - let output_redraws = std::mem::take(&mut state.output_redraw_pending); - if wallpaper_changed || wallpaper_retry { - backends::direct::render_all(state); - } else { - if cursor_changed { - backends::direct::render_cursor(state); - } - - if !output_redraws.is_empty() { - backends::direct::render_on(state, &output_redraws); - } - } - if let Err(error) = state - .wallpaper - .arm_retry_timer(&reload_handle, std::time::Instant::now()) - { - warn!(%error, "could not schedule wallpaper upload retry"); - } - // Registry/sync/configure replies must not depend on a submitted - // frame: at startup clients need these before they can draw anything. - // This also keeps idle DRM sessions responsive when there is no damage. - if let Err(error) = state.display_handle.flush_clients() { - tracing::warn!(%error, "failed to flush Wayland clients"); - } - }); + let result = event_loop.run(None, &mut state, after_dispatch); if let Some(backend) = state.direct_backend.as_ref() { backend.dump_scheduling_metrics(); @@ -168,8 +124,10 @@ fn main() -> Result<(), Box> { state.resize_metrics.dump(); runner.borrow_mut().stop(); - if let Some(child) = &mut child { - terminate_child(child); + if let Some(child) = child + && let Some(mut child) = child.borrow_mut().take() + { + terminate_child(&mut child); } result.map_err(Into::into) @@ -181,130 +139,66 @@ fn init_logging() { tracing_subscriber::fmt().with_env_filter(filter).init(); } -fn spawn_client( - state: &mut Ferese, - mut args: Vec, - capabilities: private_client::ClientCapabilities, -) -> Option { - if args.is_empty() { - info!("no client requested; pass one after `--`, for example `-- foot`"); - return None; - } - let program = args.remove(0); +fn check_config(path: &std::path::Path) -> Result<(), String> { + let source = theme::read_source(path)?; + theme::prepare(&source, path.parent().unwrap_or(std::path::Path::new(".")))?; + Ok(()) +} - let mut command = Command::new(&program); - command.args(args); - // Wallpaper decoding and drivers may already have worker threads. Set - // the child's display explicitly instead of mutating the process env. - command.env("WAYLAND_DISPLAY", &state.socket_name); - command.env_remove("WAYLAND_SOCKET"); - command.env_remove("FERESE_SHELL_CONTROL_SOCKET"); - command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); - let private_connection = if capabilities.is_empty() { - None +#[derive(Debug)] +enum RedrawRequest<'a> { + Global, + Cursor, + Outputs(&'a [smithay::output::Output]), +} + +fn after_dispatch(state: &mut Ferese) { + after_dispatch_with_redraw(state, |state, request| match request { + RedrawRequest::Global => backends::direct::render_all(state), + RedrawRequest::Cursor => backends::direct::render_cursor(state), + RedrawRequest::Outputs(outputs) => backends::direct::render_on(state, outputs), + }); +} + +fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) { + state.poll_theme(); + // The decoder wakes the loop after publishing its result, including + // when no output has a pending frame. + let wallpaper_changed = state.wallpaper.poll(); + // All input/Wayland callbacks have returned, releasing seat locks. + if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) { + state.cancel_focus_cycle(); + } + + if state.input_capture.restore_focus { + state.restore_input_capture_focus(); + } + + state.apply_unlocked_pointer_hint(); + let wallpaper_retry = state.wallpaper.take_retry_wakeup(); + let cursor_changed = std::mem::take(&mut state.cursor_redraw_pending); + let output_redraws = std::mem::take(&mut state.output_redraw_pending); + if wallpaper_changed || wallpaper_retry { + redraw(state, RedrawRequest::Global); } else { - match private_client::prepare_command(state, &mut command, capabilities) { - Ok(connection) => Some(connection), - Err(error) => { - warn!(program = ?program, %error, "failed to create private Wayland connection"); - return None; - } + if cursor_changed { + redraw(state, RedrawRequest::Cursor); } - }; - let child = match command.spawn() { - Ok(child) => { - info!(program = ?program, pid = child.id(), "spawned Wayland client"); - Some(child) + if !output_redraws.is_empty() { + redraw(state, RedrawRequest::Outputs(&output_redraws)); } - Err(error) => { - warn!(program = ?program, %error, "failed to spawn Wayland client"); - None - } - }; - drop(private_connection); - child -} - -fn terminate_child(child: &mut Child) { - if child.try_wait().ok().flatten().is_some() { - return; } - // SAFETY: the process ID comes from the live Child owned by Ferese. - unsafe { - libc::kill(child.id() as i32, libc::SIGTERM); + if let Err(error) = state + .wallpaper + .arm_retry_timer(&state.loop_handle, std::time::Instant::now()) + { + warn!(%error, "could not schedule wallpaper upload retry"); } - for _ in 0..20 { - if child.try_wait().ok().flatten().is_some() { - return; - } - thread::sleep(Duration::from_millis(10)); - } - - let _ = child.kill(); - let _ = child.wait(); -} - -#[cfg(test)] -mod startup_tests { - use std::io::{Read, Write}; - use std::os::unix::net::UnixStream; - use std::sync::Arc; - - use super::*; - - #[test] - fn deferred_cursor_redraw_runs_after_input_lock_is_released() { - use std::sync::Mutex; - - let mut event_loop = EventLoop::::try_new().unwrap(); - let pointer_lock = Arc::new(Mutex::new(())); - let callback_lock = pointer_lock.clone(); - event_loop.handle().insert_idle(move |pending| { - // Mirrors Smithay's cursor_image callback during pointer motion. - let _guard = callback_lock.lock().unwrap(); - *pending = true; - *pending = true; // Multiple cursor changes must coalesce. - assert!(callback_lock.try_lock().is_err()); - }); - - let signal = event_loop.get_signal(); - let mut pending = false; - let mut redraws = 0; - event_loop - .run(Some(Duration::ZERO), &mut pending, |pending| { - if std::mem::take(pending) { - // Rendering reads the pointer location at this point. - let _guard = pointer_lock.try_lock().expect("input lock still held"); - redraws += 1; - } - assert!(!std::mem::take(pending)); - signal.stop(); - }) - .unwrap(); - assert_eq!(redraws, 1); - } - - #[test] - fn wayland_roundtrip_completes_without_a_rendered_frame() { - let mut display = Display::<()>::new().unwrap(); - let (server, mut client) = UnixStream::pair().unwrap(); - display - .handle() - .insert_client(server, Arc::new(state::ClientState::default())) - .unwrap(); - // wl_display.sync(new_id=2), using native-endian Wayland wire format. - for word in [1u32, 12u32 << 16, 2u32] { - client.write_all(&word.to_ne_bytes()).unwrap(); - } - display.dispatch_clients(&mut ()).unwrap(); - display.handle().flush_clients().unwrap(); - client.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); - let mut reply = [0u8; 12]; - client.read_exact(&mut reply).unwrap(); - assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2); - assert_eq!(u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff, 0); + // Clients need registry/sync/configure replies even when no frame is drawn. + if let Err(error) = state.display_handle.flush_clients() { + warn!(%error, "failed to flush Wayland clients"); } } diff --git a/compositor/src/process.rs b/compositor/src/process.rs new file mode 100644 index 0000000..837915e --- /dev/null +++ b/compositor/src/process.rs @@ -0,0 +1,172 @@ +use std::cell::RefCell; +use std::ffi::OsStr; +use std::io; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::process::{Child, Command}; +use std::rc::Rc; +use std::time::Duration; + +use calloop::generic::Generic; +use calloop::timer::{TimeoutAction, Timer}; +use calloop::{Interest, LoopHandle, Mode, PostAction}; +use tracing::{info, warn}; + +use crate::Ferese; +use crate::private_client::{self, ClientCapabilities}; + +pub(crate) fn command(program: impl AsRef) -> Command { + let mut command = Command::new(program); + // Calloop blocks these on the compositor thread; fork/exec inherits that + // mask. Leave unrelated signal masks alone, including those of the launcher. + // SAFETY: only async-signal-safe signal-set operations run between fork and exec. + unsafe { + command.pre_exec(|| { + let mut mask = std::mem::zeroed(); + libc::sigemptyset(&mut mask); + libc::sigaddset(&mut mask, libc::SIGINT); + libc::sigaddset(&mut mask, libc::SIGTERM); + if libc::sigprocmask(libc::SIG_UNBLOCK, &mask, std::ptr::null_mut()) == -1 { + return Err(io::Error::last_os_error()); + } + + Ok(()) + }); + } + + command +} + +pub(crate) fn spawn_client>( + state: &mut Ferese, + args: impl IntoIterator, + capabilities: ClientCapabilities, +) -> Option { + let mut args = args.into_iter(); + let Some(program) = args.next() else { + info!("no client requested; pass one after `--`, for example `-- foot`"); + return None; + }; + + let program = program.as_ref(); + let mut command = command(program); + command.args(args); + // Workers may already exist. Change only this child's environment. + command.env("WAYLAND_DISPLAY", &state.socket_name); + command.env_remove("WAYLAND_SOCKET"); + command.env_remove("FERESE_SHELL_CONTROL_SOCKET"); + command.env("FERESE_COMPOSITOR_WALLPAPER", "1"); + let private_connection = if capabilities.is_empty() { + None + } else { + match private_client::prepare_command(state, &mut command, capabilities) { + Ok(connection) => Some(connection), + Err(error) => { + warn!(program = ?program, %error, "failed to create private Wayland connection"); + return None; + } + } + }; + + let child = match command.spawn() { + Ok(child) => { + info!(program = ?program, pid = child.id(), "spawned Wayland client"); + Some(child) + } + Err(error) => { + warn!(program = ?program, %error, "failed to spawn Wayland client"); + None + } + }; + drop(private_connection); + child +} + +pub(crate) fn pidfd(pid: u32) -> io::Result { + // SAFETY: pidfd_open has no pointer arguments and returns a new owned fd. + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + + // SAFETY: a successful syscall returned a unique valid descriptor. + Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) }) +} + +fn reap(child: &RefCell>) -> bool { + let mut child = child.borrow_mut(); + let Some(process) = child.as_mut() else { + return true; + }; + + match process.try_wait() { + Ok(Some(status)) => { + info!(pid = process.id(), %status, "Wayland client exited"); + *child = None; + true + } + Ok(None) => false, + Err(error) => { + warn!(%error, "cannot reap Wayland client"); + false + } + } +} + +pub(crate) fn watch_client_exit(handle: &LoopHandle<'static, Ferese>, child: Child) -> Rc>> { + let pid = child.id(); + let owner = Rc::new(RefCell::new(Some(child))); + let watched = owner.clone(); + let result = pidfd(pid).and_then(|fd| { + handle + .insert_source(Generic::new(fd, Interest::READ, Mode::Level), move |_, _, _| { + Ok(if reap(&watched) { + PostAction::Remove + } else { + PostAction::Continue + }) + }) + .map_err(io::Error::other) + }); + + if let Err(error) = result { + warn!(%error, pid, "could not watch client exit with pidfd; polling instead"); + let watched = owner.clone(); + if let Err(error) = handle.insert_source(Timer::from_duration(Duration::from_millis(250)), move |_, _, _| { + if reap(&watched) { + TimeoutAction::Drop + } else { + TimeoutAction::ToDuration(Duration::from_millis(250)) + } + }) { + warn!(%error, pid, "could not schedule client exit polling; stopping child"); + if let Some(mut child) = owner.borrow_mut().take() { + terminate_child(&mut child); + } + } + } + + owner +} + +pub(crate) fn terminate_child(child: &mut Child) { + if child.try_wait().ok().flatten().is_some() { + return; + } + + // SAFETY: the process ID comes from the live Child owned by Ferese. + unsafe { + libc::kill(child.id() as i32, libc::SIGTERM); + } + + for _ in 0..20 { + if child.try_wait().ok().flatten().is_some() { + return; + } + + std::thread::sleep(Duration::from_millis(10)); + } + + let _ = child.kill(); + let _ = child.wait(); +} diff --git a/compositor/src/resume.rs b/compositor/src/resume.rs index bc8d2b3..5f6b0a9 100644 --- a/compositor/src/resume.rs +++ b/compositor/src/resume.rs @@ -1,57 +1,257 @@ -//! System wake is shared by hardware reconciliation and time-dependent UI state. +//! System wake and listener recovery share reconciliation, but not idle activity. use std::error::Error; +use std::sync::{Arc, Condvar, Mutex}; +use std::task::{Poll, Waker}; +use std::thread::JoinHandle; use std::time::Duration; -use calloop::EventLoop; use calloop::channel::{Event, channel}; +use calloop::{EventLoop, LoopHandle, RegistrationToken}; +use futures_lite::{StreamExt, future}; use crate::Ferese; -pub(crate) fn init(event_loop: &mut EventLoop) -> Result<(), Box> { +const RETRY_DELAY: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Refresh { + Resumed, + Reconcile, +} + +#[derive(Debug, PartialEq, Eq)] +enum MonitorExit { + Stop, + Reconnect, +} + +#[derive(Default)] +struct StopState { + requested: bool, + waker: Option, +} + +#[derive(Default)] +struct Cancellation { + state: Mutex, + changed: Condvar, +} + +impl Cancellation { + fn cancel(&self) { + let mut state = self.state.lock().unwrap(); + state.requested = true; + let waker = state.waker.take(); + self.changed.notify_all(); + drop(state); + + if let Some(waker) = waker { + waker.wake(); + } + } + + async fn cancelled(&self) { + future::poll_fn(|context| { + let mut state = self.state.lock().unwrap(); + if state.requested { + return Poll::Ready(()); + } + + state.waker = Some(context.waker().clone()); + Poll::Pending + }) + .await; + } + + fn retry(&self, delay: Duration) -> bool { + let (state, _) = self + .changed + .wait_timeout_while(self.state.lock().unwrap(), delay, |state| !state.requested) + .unwrap(); + !state.requested + } +} + +pub(crate) struct Monitor { + stop: Arc, + worker: Option>, + source: Option<(LoopHandle<'static, Ferese>, RegistrationToken)>, +} + +impl Drop for Monitor { + fn drop(&mut self) { + self.stop.cancel(); + if let Some((handle, token)) = self.source.take() { + handle.remove(token); + } + + // Cancellation wakes both the signal wait and retry delay. Do not join + // a DBus worker on the compositor thread during shutdown. + self.worker.take(); + } +} + +fn refresh(state: &mut Ferese, event: Refresh) { + match event { + Refresh::Resumed => crate::backends::direct::system_resumed(state), + Refresh::Reconcile => crate::backends::direct::reconcile_after_monitor_recovery(state), + } + + state.refresh_theme(); +} + +pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result> { let (sender, receiver) = channel(); - event_loop.handle().insert_source(receiver, |event, _, state| { - if let Event::Msg(()) = event { - crate::backends::direct::system_resumed(state); - state.refresh_theme(); + let handle = event_loop.handle(); + let token = handle.insert_source(receiver, |event, _, state| { + if let Event::Msg(event) = event { + refresh(state, event); } })?; - std::thread::Builder::new() + match start(zbus::connection::Builder::system, RETRY_DELAY, move |event| { + sender.send(event).is_ok() + }) { + Ok(mut monitor) => { + monitor.source = Some((handle, token)); + Ok(monitor) + } + Err(error) => { + handle.remove(token); + Err(error.into()) + } + } +} + +#[derive(Default)] +struct Recovery { + reconnecting: bool, + warned: bool, +} + +impl Recovery { + fn unavailable(&mut self) -> bool { + self.reconnecting = true; + let warn = !self.warned; + self.warned = true; + warn + } + + fn subscribed(&mut self) -> bool { + self.warned = false; + std::mem::take(&mut self.reconnecting) + } +} + +fn start( + connect: impl Fn() -> zbus::Result> + Send + 'static, + retry_delay: Duration, + mut send: impl FnMut(Refresh) -> bool + Send + 'static, +) -> std::io::Result { + let stop = Arc::new(Cancellation::default()); + let worker_stop = stop.clone(); + let worker = std::thread::Builder::new() .name("ferese-system-resume".into()) .spawn(move || { - let mut warned = false; + let mut recovery = Recovery::default(); loop { - let monitor = || -> zbus::Result { - let connection = zbus::blocking::connection::Builder::system()? - .method_timeout(Duration::from_secs(2)) - .build()?; - let proxy = zbus::blocking::Proxy::new( - &connection, - "org.freedesktop.login1", - "/org/freedesktop/login1", - "org.freedesktop.login1.Manager", - )?; - for message in proxy.receive_signal("PrepareForSleep")? { - let (sleeping,): (bool,) = message.body().deserialize()?; - if !sleeping && sender.send(()).is_err() { - return Ok(false); + let result = future::block_on(future::race( + async { + worker_stop.cancelled().await; + Ok(MonitorExit::Stop) + }, + async { + let connection = connect()?.method_timeout(Duration::from_secs(2)).build().await?; + future::race( + async { + connection.closed().await; + Ok(MonitorExit::Reconnect) + }, + listen(&connection, &mut recovery, &mut send), + ) + .await + }, + )); + + match result { + Ok(MonitorExit::Stop) => break, + Ok(MonitorExit::Reconnect) => { + if recovery.unavailable() { + tracing::warn!("system resume monitoring interrupted; retrying connection"); } } - Ok(true) - }; - - match monitor() { - Ok(false) => break, - Ok(true) => warned = false, - Err(error) if !warned => { - tracing::warn!(%error, "system resume monitor unavailable; retrying connection"); - warned = true; + Err(error) => { + if recovery.unavailable() { + tracing::warn!(%error, "system resume monitor unavailable; retrying connection"); + } } - Err(_) => {} } - // Recovery only: a healthy listener blocks on DBus signals. - std::thread::sleep(Duration::from_secs(5)); + + if !worker_stop.retry(retry_delay) { + break; + } } })?; - Ok(()) + + Ok(Monitor { + stop, + worker: Some(worker), + source: None, + }) } + +async fn listen( + connection: &zbus::Connection, + recovery: &mut Recovery, + send: &mut impl FnMut(Refresh) -> bool, +) -> zbus::Result { + let proxy = zbus::Proxy::new( + connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await?; + let mut owners = proxy.receive_owner_changed().await?; + let mut signals = proxy.receive_signal("PrepareForSleep").await?; + // AddMatch succeeds even when logind is absent. Only call the listener + // recovered once the service is present, and retry if its owner changes. + connection + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "GetNameOwner", + &("org.freedesktop.login1",), + ) + .await?; + + // Subscribe first: a resume during reconciliation must remain queued. + if recovery.subscribed() { + tracing::debug!("system resume monitoring recovered; reconciling current state"); + if !send(Refresh::Reconcile) { + return Ok(MonitorExit::Stop); + } + } + + future::race( + async { + owners.next().await; + Ok(MonitorExit::Reconnect) + }, + async { + while let Some(message) = signals.next().await { + let (sleeping,): (bool,) = message.body().deserialize()?; + if !sleeping && !send(Refresh::Resumed) { + return Ok(MonitorExit::Stop); + } + } + + Ok(MonitorExit::Reconnect) + }, + ) + .await +} + +#[cfg(test)] +mod tests; diff --git a/compositor/src/resume/tests.rs b/compositor/src/resume/tests.rs new file mode 100644 index 0000000..503a819 --- /dev/null +++ b/compositor/src/resume/tests.rs @@ -0,0 +1,249 @@ +use std::io::{BufRead, BufReader}; +use std::process::{Child, Stdio}; +use std::sync::mpsc::{self, Receiver}; +use std::time::Instant; + +use super::*; + +#[test] +fn warning_suppression_resets_at_subscription_not_at_next_resume() { + let mut recovery = Recovery::default(); + assert!(!recovery.subscribed(), "startup is not a recovery or a resume"); + assert!(recovery.unavailable()); + assert!(!recovery.unavailable(), "one warning per outage"); + assert!(recovery.subscribed()); + assert!(!recovery.subscribed(), "one reconciliation per recovery"); + assert!(recovery.unavailable(), "another outage must warn even without a resume"); + assert!(!recovery.unavailable()); +} + +fn stop_and_join(mut monitor: Monitor) { + let worker = monitor.worker.take().unwrap(); + drop(monitor); + let deadline = Instant::now() + Duration::from_secs(1); + while !worker.is_finished() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(2)); + } + + assert!(worker.is_finished(), "cancellation did not stop the worker promptly"); + worker.join().unwrap(); +} + +#[test] +fn shutdown_interrupts_retry_delay() { + let (attempt, attempted) = mpsc::channel(); + let monitor = start( + move || { + attempt.send(()).unwrap(); + Err(zbus::Error::Failure("test connection failure".into())) + }, + RETRY_DELAY, + |_| panic!("no subscription succeeded"), + ) + .unwrap(); + attempted.recv_timeout(Duration::from_secs(1)).unwrap(); + stop_and_join(monitor); + assert!( + attempted.try_recv().is_err(), + "shutdown must not start another connection" + ); +} + +struct Bus { + process: Child, + address: String, +} + +impl Bus { + fn new() -> Self { + let mut process = crate::process::command("dbus-daemon") + .args(["--session", "--nofork", "--print-address=1"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut address = String::new(); + BufReader::new(process.stdout.take().unwrap()) + .read_line(&mut address) + .unwrap(); + Self { + process, + address: address.trim().to_owned(), + } + } + + fn stop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } + + fn manager(&self) -> zbus::blocking::Connection { + struct Manager; + #[zbus::interface(name = "org.freedesktop.login1.Manager")] + impl Manager { + #[zbus(property)] + fn lid_closed(&self) -> bool { + false + } + } + + zbus::blocking::connection::Builder::address(self.address.as_str()) + .unwrap() + .name("org.freedesktop.login1") + .unwrap() + .serve_at("/org/freedesktop/login1", Manager) + .unwrap() + .build() + .unwrap() + } +} + +impl Drop for Bus { + fn drop(&mut self) { + self.stop(); + } +} + +fn signal(manager: &zbus::blocking::Connection, sleeping: bool) { + manager + .emit_signal( + None::<&str>, + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + "PrepareForSleep", + &(sleeping,), + ) + .unwrap(); +} + +fn confirm_subscription(manager: &zbus::blocking::Connection, events: &Receiver) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + signal(manager, false); + if let Ok(event) = events.recv_timeout(Duration::from_millis(10)) { + assert_eq!(event, Refresh::Resumed); + break; + } + + assert!(Instant::now() < deadline, "listener did not subscribe"); + } +} + +#[test] +fn reconnect_reconciles_a_resume_missed_during_the_outage() { + if !crate::startup_tests::private_runtime("resume::tests::reconnect_reconciles_a_resume_missed_during_the_outage") { + return; + } + + let mut bus = Bus::new(); + let manager = bus.manager(); + let address = Arc::new(Mutex::new(bus.address.clone())); + let worker_address = address.clone(); + let (sender, events) = mpsc::channel(); + let (attempt, attempts) = mpsc::channel(); + let monitor = start( + move || { + attempt.send(()).unwrap(); + zbus::connection::Builder::address(worker_address.lock().unwrap().as_str()) + }, + Duration::from_millis(40), + move |event| sender.send(event).is_ok(), + ) + .unwrap(); + confirm_subscription(&manager, &events); + attempts.recv_timeout(Duration::from_secs(1)).unwrap(); + *address.lock().unwrap() = "unix:path=/nonexistent/ferese-test-bus".into(); + bus.stop(); + attempts.recv_timeout(Duration::from_secs(1)).unwrap(); + + let replacement = Bus::new(); + let manager = replacement.manager(); + signal(&manager, false); // No listener can receive this resume. + *address.lock().unwrap() = replacement.address.clone(); + assert_eq!(events.recv_timeout(Duration::from_secs(2)).unwrap(), Refresh::Reconcile); + assert!(events.recv_timeout(Duration::from_millis(80)).is_err()); + signal(&manager, true); + assert!(events.recv_timeout(Duration::from_millis(40)).is_err()); + signal(&manager, false); + assert_eq!(events.recv_timeout(Duration::from_secs(1)).unwrap(), Refresh::Resumed); + stop_and_join(monitor); +} + +#[test] +fn shutdown_interrupts_a_healthy_signal_wait() { + if !crate::startup_tests::private_runtime("resume::tests::shutdown_interrupts_a_healthy_signal_wait") { + return; + } + + let bus = Bus::new(); + let manager = bus.manager(); + let address = bus.address.clone(); + let (sender, events) = mpsc::channel(); + let monitor = start( + move || zbus::connection::Builder::address(address.as_str()), + RETRY_DELAY, + move |event| sender.send(event).is_ok(), + ) + .unwrap(); + confirm_subscription(&manager, &events); + stop_and_join(monitor); +} + +#[test] +fn logind_replacement_reconciles_without_a_bus_disconnect() { + if !crate::startup_tests::private_runtime("resume::tests::logind_replacement_reconciles_without_a_bus_disconnect") { + return; + } + + let bus = Bus::new(); + let manager = bus.manager(); + let address = bus.address.clone(); + let (sender, events) = mpsc::channel(); + let monitor = start( + move || zbus::connection::Builder::address(address.as_str()), + Duration::from_millis(40), + move |event| sender.send(event).is_ok(), + ) + .unwrap(); + confirm_subscription(&manager, &events); + manager.release_name("org.freedesktop.login1").unwrap(); + assert!( + events.recv_timeout(Duration::from_millis(100)).is_err(), + "absent logind is not recovery" + ); + let replacement = bus.manager(); + assert_eq!(events.recv_timeout(Duration::from_secs(2)).unwrap(), Refresh::Reconcile); + signal(&replacement, false); + assert_eq!(events.recv_timeout(Duration::from_secs(1)).unwrap(), Refresh::Resumed); + stop_and_join(monitor); +} + +#[test] +fn shutdown_interrupts_connection_authentication() { + if !crate::startup_tests::private_runtime("resume::tests::shutdown_interrupts_connection_authentication") { + return; + } + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("silent-bus"); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = format!("unix:path={}", path.display()); + let monitor = start( + move || zbus::connection::Builder::address(address.as_str()), + RETRY_DELAY, + |_| panic!("authentication cannot complete"), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + let _connection = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(2)); + } + Err(error) => panic!("{error}"), + } + }; + stop_and_join(monitor); +} diff --git a/compositor/src/session_lock.rs b/compositor/src/session_lock.rs index 37445d0..eae50a8 100644 --- a/compositor/src/session_lock.rs +++ b/compositor/src/session_lock.rs @@ -111,7 +111,7 @@ pub(crate) struct Lock { pub(crate) idle_opacity: f32, pub(crate) sleeping: bool, pub(crate) idle_overlays: HashMap, - idle_timer: Option, + idle_timer: Option<(RegistrationToken, Instant)>, } impl Lock { @@ -130,6 +130,8 @@ impl Lock { pub(crate) fn output_removed(&mut self, output: &Output) { self.presented.remove(output); self.idle_overlays.remove(output); + self.surfaces.remove(output); + self.backgrounds.remove(output); } fn activity(&mut self, now: Instant) -> bool { @@ -167,7 +169,11 @@ impl Ferese { crate::backends::direct::wake_locked_outputs(self); self.cursor_redraw_pending = true; } - self.rearm_lock_idle(); + // Activity moves the deadline later. Keep the existing source and let + // it recheck idle_since when it fires. Sleeping outputs have no timer. + if self.session_lock.idle_timer.is_none() { + self.rearm_lock_idle(); + } } pub(crate) fn refresh_lock_idle_policy(&mut self) { @@ -175,32 +181,52 @@ impl Ferese { self.rearm_lock_idle(); } - fn rearm_lock_idle(&mut self) { - if let Some(token) = self.session_lock.idle_timer.take() { - self.loop_handle.remove(token); - } + fn lock_idle_deadline(&self, now: Instant) -> Option { if !self.lock_outputs_protected() { - return; + return None; } let elapsed = self .session_lock .idle_since - .map_or(Duration::ZERO, |since| since.elapsed()); - let Some(delay) = self - .lock_idle + .map_or(Duration::ZERO, |since| now.saturating_duration_since(since)); + self.lock_idle .next_deadline(elapsed, self.animation_duration(Duration::from_millis(500))) - else { + .map(|delay| now + delay) + } + + fn rearm_lock_idle(&mut self) { + let deadline = self.lock_idle_deadline(Instant::now()); + if let Some((_, armed)) = self.session_lock.idle_timer + && deadline.is_some_and(|deadline| armed <= deadline) + { + return; + } + + if let Some((token, _)) = self.session_lock.idle_timer.take() { + self.loop_handle.remove(token); + } + + let Some(deadline) = deadline else { return; }; match self .loop_handle - .insert_source(Timer::from_duration(delay), |_, _, state| { - state.session_lock.idle_timer = None; - state.refresh_lock_idle_policy(); - TimeoutAction::Drop + .insert_source(Timer::from_deadline(deadline), |_, _, state| { + let now = Instant::now(); + state.update_lock_idle(now); + if let Some(deadline) = state.lock_idle_deadline(now) { + if let Some((_, armed)) = state.session_lock.idle_timer.as_mut() { + *armed = deadline; + } + + TimeoutAction::ToInstant(deadline) + } else { + state.session_lock.idle_timer = None; + TimeoutAction::Drop + } }) { - Ok(token) => self.session_lock.idle_timer = Some(token), + Ok(token) => self.session_lock.idle_timer = Some((token, deadline)), Err(error) => tracing::warn!(%error, "could not arm lock idle deadline"), } } @@ -251,8 +277,17 @@ impl Ferese { let surface = self .focused_output() .and_then(|output| self.session_lock.surfaces.get(output)) - .or_else(|| self.session_lock.surfaces.values().find(|surface| surface.alive())) .filter(|surface| surface.alive()) + .or_else(|| { + // Stable output IDs keep fallback focus independent of HashMap order. + self.session_lock + .surfaces + .iter() + .filter(|(_, surface)| surface.alive()) + .filter_map(|(output, surface)| self.output_id(output).map(|id| (id, surface))) + .min_by_key(|(id, _)| *id) + .map(|(_, surface)| surface) + }) .map(|surface| surface.wl_surface().clone()); self.seat .get_keyboard() @@ -378,7 +413,7 @@ impl SessionLockHandler for Ferese { } fn unlock(&mut self) { - if let Some(timer) = self.session_lock.idle_timer.take() { + if let Some((timer, _)) = self.session_lock.idle_timer.take() { self.loop_handle.remove(timer); } let sleeping = self.session_lock.sleeping; @@ -456,346 +491,4 @@ smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockMa smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSurfaceV1: smithay::wayland::session_lock::ExtLockSurfaceUserData] => SessionLockManagerState); #[cfg(test)] -mod tests { - use super::*; - - #[test] - #[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"] - fn zero_outputs_confirm_and_dead_owner_can_be_replaced_without_unlocking() { - use std::io::{Read, Write}; - use std::os::unix::net::UnixStream; - use std::sync::Arc; - - use smithay::reexports::calloop::EventLoop; - use smithay::reexports::wayland_server::Display; - - let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap()); - assert!(runtime.starts_with(std::env::temp_dir())); - let mut event_loop = EventLoop::try_new().unwrap(); - let config = crate::config::Config::default().runtime_config().unwrap(); - let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap(); - let capture_epoch = state.capture_epoch; - - let acquire = |state: &mut Ferese| { - let (server, mut wire) = UnixStream::pair().unwrap(); - let client = state - .display_handle - .insert_client(server, Arc::new(crate::state::ClientState::default())) - .unwrap(); - let manager = client - .create_resource::(&state.display_handle, 1, ()) - .unwrap(); - // ext_session_lock_manager_v1.lock(new_id=2). - for word in [manager.id().protocol_id(), (12u32 << 16) | 1, 2] { - wire.write_all(&word.to_ne_bytes()).unwrap(); - } - wire - }; - let dispatch = |event_loop: &mut EventLoop<'static, Ferese>, state: &mut Ferese| { - event_loop.dispatch(Duration::from_millis(20), state).unwrap(); - state.display_handle.flush_clients().unwrap(); - }; - - let mut first = acquire(&mut state); - dispatch(&mut event_loop, &mut state); - assert_ne!( - state.capture_epoch, capture_epoch, - "lock revokes deferred window snapshots" - ); - assert!( - matches!(state.session_lock.lifecycle, Lifecycle::Locked(_)), - "no outputs must confirm immediately" - ); - first.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); - let mut event = [0u8; 8]; - first.read_exact(&mut event).unwrap(); - assert_eq!(u32::from_ne_bytes(event[..4].try_into().unwrap()), 2); - assert_eq!( - u32::from_ne_bytes(event[4..].try_into().unwrap()), - 8u32 << 16, - "protocol locked event" - ); - - let owner = state.session_lock.lifecycle.owner().cloned(); - - let mut competitor = acquire(&mut state); - dispatch(&mut event_loop, &mut state); - assert_eq!( - state.session_lock.lifecycle.owner(), - owner.as_ref(), - "a live owner cannot be replaced" - ); - // A rejected client must not unlock the confirmed owner. - for word in [2u32, (8u32 << 16) | 2] { - competitor.write_all(&word.to_ne_bytes()).unwrap(); - } - dispatch(&mut event_loop, &mut state); - assert_eq!(state.session_lock.lifecycle.owner(), owner.as_ref()); - - drop(competitor); - drop(first); - dispatch(&mut event_loop, &mut state); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Orphaned)); - assert!(state.session_lock.active()); - - let replacement = acquire(&mut state); - dispatch(&mut event_loop, &mut state); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Locked(_))); - assert_ne!(state.session_lock.lifecycle.owner(), owner.as_ref()); - drop(replacement); - dispatch(&mut event_loop, &mut state); - assert!(state.session_lock.active()); - - let output = Output::new( - "last-output".into(), - smithay::output::PhysicalProperties { - size: (0, 0).into(), - subpixel: smithay::output::Subpixel::Unknown, - make: "test".into(), - model: "test".into(), - }, - ); - output.change_current_state( - Some(smithay::output::Mode { - size: (800, 600).into(), - refresh: 60_000, - }), - None, - None, - None, - ); - state.space.map_output(&output, (0, 0)); - state.register_output(&output, "last-output".into()); - - let pending = acquire(&mut state); - dispatch(&mut event_loop, &mut state); - assert!( - matches!(state.session_lock.lifecycle, Lifecycle::Acquiring(_)), - "a newly added output requires a protected frame" - ); - drop(pending); - dispatch(&mut event_loop, &mut state); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Orphaned)); - - let replacement = acquire(&mut state); - dispatch(&mut event_loop, &mut state); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Acquiring(_))); - state.unregister_output(&output); - assert!( - matches!(state.session_lock.lifecycle, Lifecycle::Locked(_)), - "removing the last pending output must confirm" - ); - - drop(replacement); - dispatch(&mut event_loop, &mut state); - state.space.map_output(&output, (0, 0)); - state.session_lock.output_added(&output); - - let mut pending = acquire(&mut state); - dispatch(&mut event_loop, &mut state); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Acquiring(_))); - assert!(state.session_lock.idle_timer.is_none()); - - // Remove the final display without delivering a frame or confirming during removal. - state.space.unmap_output(&output); - state.session_lock.output_removed(&output); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Acquiring(_))); - - state.refresh_lock_outputs(); - assert!(matches!(state.session_lock.lifecycle, Lifecycle::Locked(_))); - assert!(state.session_lock.idle_timer.is_some()); - state.display_handle.flush_clients().unwrap(); - pending.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); - pending.read_exact(&mut event).unwrap(); - assert_eq!(u32::from_ne_bytes(event[..4].try_into().unwrap()), 2); - assert_eq!(u32::from_ne_bytes(event[4..].try_into().unwrap()), 8u32 << 16); - } - - #[test] - fn dim_fade_respects_speed_without_changing_idle_deadlines() { - let policy = IdleSettings::default(); - for (duration, half_at) in [(250, 30_125), (1000, 30_500)] { - let fade = Duration::from_millis(duration); - assert_eq!(policy.appearance(Duration::from_secs(29), fade), (0.0, false)); - let halfway = policy.appearance(Duration::from_millis(half_at), fade); - assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1); - assert_eq!( - policy.next_deadline(Duration::from_millis(half_at), fade), - Some(Duration::from_millis(16)) - ); - assert_eq!(policy.appearance(Duration::from_secs(120), fade), (1.0, true)); - } - assert_eq!( - policy.appearance(Duration::from_secs(30), Duration::ZERO), - (0.65, false) - ); - assert_eq!(policy.appearance(Duration::from_secs(120), Duration::ZERO), (1.0, true)); - } - - #[test] - fn idle_policy_fades_then_sleeps_and_activity_resets_it() { - let policy = IdleSettings::default(); - assert_eq!( - policy.appearance(Duration::from_secs(29), Duration::from_millis(500)), - (0.0, false) - ); - let halfway = policy.appearance(Duration::from_millis(30_250), Duration::from_millis(500)); - assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1); - assert_eq!( - policy.appearance(Duration::from_secs(31), Duration::from_millis(500)), - (0.65, false) - ); - assert_eq!( - policy.appearance(Duration::from_secs(120), Duration::from_millis(500)), - (1.0, true) - ); - assert_eq!( - policy.appearance(Duration::ZERO, Duration::from_millis(500)), - (0.0, false) - ); - assert_eq!( - policy.next_deadline(Duration::from_millis(29_998), Duration::from_millis(500)), - Some(Duration::from_millis(2)) - ); - assert_eq!( - policy.next_deadline(Duration::from_secs(30), Duration::from_millis(500)), - Some(Duration::from_millis(16)) - ); - assert_eq!( - policy.next_deadline(Duration::from_secs(30), Duration::ZERO), - Some(Duration::from_secs(90)) - ); - assert_eq!(policy.next_deadline(Duration::from_secs(120), Duration::ZERO), None); - assert_eq!( - IdleSettings { - dim_after_seconds: 0, - sleep_after_seconds: 0 - } - .next_deadline(Duration::ZERO, Duration::ZERO), - None - ); - } - - #[test] - fn mirror_must_present_protected_frame_before_lock_is_ready() { - let make_output = |name: &str| { - Output::new( - name.into(), - smithay::output::PhysicalProperties { - size: (0, 0).into(), - subpixel: smithay::output::Subpixel::Unknown, - make: "test".into(), - model: "test".into(), - }, - ) - }; - let source = make_output("source"); - let mirror = make_output("mirror"); - let mut lock = Lock { - lifecycle: Lifecycle::Orphaned, - ..Lock::default() - }; - lock.presented.insert(source.clone()); - let displays = [&source, &mirror]; - assert!( - !lock.ready_for_idle(displays.into_iter()), - "failed mirror rendering must block confirmation" - ); - lock.presented.insert(mirror.clone()); - assert!(lock.ready_for_idle(displays.into_iter())); - lock.output_added(&mirror); - assert!( - !lock.ready_for_idle(displays.into_iter()), - "reconfiguration invalidates physical protection" - ); - lock.output_removed(&mirror); - assert!(lock.ready_for_idle(std::iter::once(&source))); - } - - #[test] - fn protected_fallback_frames_allow_idle_after_early_locker_crash() { - let output = Output::new( - "test".into(), - smithay::output::PhysicalProperties { - size: (0, 0).into(), - subpixel: smithay::output::Subpixel::Unknown, - make: "test".into(), - model: "test".into(), - }, - ); - let mut lock = Lock { - lifecycle: Lifecycle::Orphaned, - ..Lock::default() - }; - assert!(!lock.ready_for_idle(std::iter::once(&output))); - lock.presented.insert(output.clone()); - assert!(lock.ready_for_idle(std::iter::once(&output))); - assert!(matches!(lock.lifecycle, Lifecycle::Orphaned)); - lock.output_added(&output); - assert!(!lock.ready_for_idle(std::iter::once(&output))); - assert!(matches!(lock.lifecycle, Lifecycle::Orphaned)); - } - - #[test] - fn input_wakes_a_sleeping_orphan_without_unlocking() { - let mut lock = Lock { - lifecycle: Lifecycle::Orphaned, - sleeping: true, - idle_opacity: 1.0, - ..Lock::default() - }; - let now = Instant::now(); - assert!(lock.activity(now)); - assert!(lock.active() && matches!(lock.lifecycle, Lifecycle::Orphaned)); - assert!(!lock.sleeping); - assert_eq!(lock.idle_opacity, 0.0); - assert_eq!(lock.idle_since, Some(now)); - assert!(!lock.activity(now)); - let mut unlocked = Lock::default(); - assert!(!unlocked.activity(now)); - assert!(unlocked.idle_since.is_none()); - } - - #[test] - fn idle_disabled_options_are_independent() { - assert_eq!( - IdleSettings { - dim_after_seconds: 0, - sleep_after_seconds: 0 - } - .appearance(Duration::from_secs(9999), Duration::from_millis(500)), - (0.0, false) - ); - assert_eq!( - IdleSettings { - dim_after_seconds: 0, - sleep_after_seconds: 10 - } - .appearance(Duration::from_secs(10), Duration::from_millis(500)), - (1.0, true) - ); - assert_eq!( - IdleSettings { - dim_after_seconds: 1, - sleep_after_seconds: 0 - } - .appearance(Duration::from_secs(9999), Duration::from_millis(500)), - (0.65, false) - ); - assert!( - IdleSettings { - dim_after_seconds: 86401, - sleep_after_seconds: 0 - } - .validate() - .is_err() - ); - assert!(crate::config::Config::parse_source("lock-screen { dim-after-seconds -1; }").is_err()); - assert!( - crate::config::Config::parse_source("lock-screen { sleep-after-seconds 90000; }") - .unwrap() - .runtime_config() - .is_err() - ); - } -} +mod tests; diff --git a/compositor/src/session_lock/tests.rs b/compositor/src/session_lock/tests.rs new file mode 100644 index 0000000..c6f658e --- /dev/null +++ b/compositor/src/session_lock/tests.rs @@ -0,0 +1,628 @@ +use super::*; + +#[test] +fn dim_fade_respects_speed_without_changing_idle_deadlines() { + let policy = IdleSettings::default(); + for (duration, half_at) in [(250, 30_125), (1000, 30_500)] { + let fade = Duration::from_millis(duration); + assert_eq!(policy.appearance(Duration::from_secs(29), fade), (0.0, false)); + let halfway = policy.appearance(Duration::from_millis(half_at), fade); + assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1); + assert_eq!( + policy.next_deadline(Duration::from_millis(half_at), fade), + Some(Duration::from_millis(16)) + ); + assert_eq!(policy.appearance(Duration::from_secs(120), fade), (1.0, true)); + } + assert_eq!( + policy.appearance(Duration::from_secs(30), Duration::ZERO), + (0.65, false) + ); + assert_eq!(policy.appearance(Duration::from_secs(120), Duration::ZERO), (1.0, true)); +} + +#[test] +fn idle_policy_fades_then_sleeps_and_activity_resets_it() { + let policy = IdleSettings::default(); + assert_eq!( + policy.appearance(Duration::from_secs(29), Duration::from_millis(500)), + (0.0, false) + ); + let halfway = policy.appearance(Duration::from_millis(30_250), Duration::from_millis(500)); + assert!((halfway.0 - 0.325).abs() < 0.001 && !halfway.1); + assert_eq!( + policy.appearance(Duration::from_secs(31), Duration::from_millis(500)), + (0.65, false) + ); + assert_eq!( + policy.appearance(Duration::from_secs(120), Duration::from_millis(500)), + (1.0, true) + ); + assert_eq!( + policy.appearance(Duration::ZERO, Duration::from_millis(500)), + (0.0, false) + ); + assert_eq!( + policy.next_deadline(Duration::from_millis(29_998), Duration::from_millis(500)), + Some(Duration::from_millis(2)) + ); + assert_eq!( + policy.next_deadline(Duration::from_secs(30), Duration::from_millis(500)), + Some(Duration::from_millis(16)) + ); + assert_eq!( + policy.next_deadline(Duration::from_secs(30), Duration::ZERO), + Some(Duration::from_secs(90)) + ); + assert_eq!(policy.next_deadline(Duration::from_secs(120), Duration::ZERO), None); + assert_eq!( + IdleSettings { + dim_after_seconds: 0, + sleep_after_seconds: 0 + } + .next_deadline(Duration::ZERO, Duration::ZERO), + None + ); +} + +#[test] +fn mirror_must_present_protected_frame_before_lock_is_ready() { + let make_output = |name: &str| { + Output::new( + name.into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ) + }; + let source = make_output("source"); + let mirror = make_output("mirror"); + let mut lock = Lock { + lifecycle: Lifecycle::Orphaned, + ..Lock::default() + }; + lock.presented.insert(source.clone()); + let displays = [&source, &mirror]; + assert!( + !lock.ready_for_idle(displays.into_iter()), + "failed mirror rendering must block confirmation" + ); + lock.presented.insert(mirror.clone()); + assert!(lock.ready_for_idle(displays.into_iter())); + lock.output_added(&mirror); + assert!( + !lock.ready_for_idle(displays.into_iter()), + "reconfiguration invalidates physical protection" + ); + lock.output_removed(&mirror); + assert!(lock.ready_for_idle(std::iter::once(&source))); +} + +#[test] +fn protected_fallback_frames_allow_idle_after_early_locker_crash() { + let output = Output::new( + "test".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + let mut lock = Lock { + lifecycle: Lifecycle::Orphaned, + ..Lock::default() + }; + assert!(!lock.ready_for_idle(std::iter::once(&output))); + lock.presented.insert(output.clone()); + assert!(lock.ready_for_idle(std::iter::once(&output))); + assert!(matches!(lock.lifecycle, Lifecycle::Orphaned)); + lock.output_added(&output); + assert!(!lock.ready_for_idle(std::iter::once(&output))); + assert!(matches!(lock.lifecycle, Lifecycle::Orphaned)); +} + +#[test] +fn input_wakes_a_sleeping_orphan_without_unlocking() { + let mut lock = Lock { + lifecycle: Lifecycle::Orphaned, + sleeping: true, + idle_opacity: 1.0, + ..Lock::default() + }; + let now = Instant::now(); + assert!(lock.activity(now)); + assert!(lock.active() && matches!(lock.lifecycle, Lifecycle::Orphaned)); + assert!(!lock.sleeping); + assert_eq!(lock.idle_opacity, 0.0); + assert_eq!(lock.idle_since, Some(now)); + assert!(!lock.activity(now)); + let mut unlocked = Lock::default(); + assert!(!unlocked.activity(now)); + assert!(unlocked.idle_since.is_none()); +} + +#[test] +fn idle_disabled_options_are_independent() { + assert_eq!( + IdleSettings { + dim_after_seconds: 0, + sleep_after_seconds: 0 + } + .appearance(Duration::from_secs(9999), Duration::from_millis(500)), + (0.0, false) + ); + assert_eq!( + IdleSettings { + dim_after_seconds: 0, + sleep_after_seconds: 10 + } + .appearance(Duration::from_secs(10), Duration::from_millis(500)), + (1.0, true) + ); + assert_eq!( + IdleSettings { + dim_after_seconds: 1, + sleep_after_seconds: 0 + } + .appearance(Duration::from_secs(9999), Duration::from_millis(500)), + (0.65, false) + ); + assert!( + IdleSettings { + dim_after_seconds: 86401, + sleep_after_seconds: 0 + } + .validate() + .is_err() + ); + assert!(crate::config::Config::parse_source("lock-screen { dim-after-seconds -1; }").is_err()); + assert!( + crate::config::Config::parse_source("lock-screen { sleep-after-seconds 90000; }") + .unwrap() + .runtime_config() + .is_err() + ); +} + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::sync::Arc; + +use calloop::EventLoop; +use smithay::reexports::wayland_server::Client; +use smithay::reexports::wayland_server::protocol::wl_compositor::WlCompositor; + +struct Fixture { + events: EventLoop<'static, Ferese>, + state: Ferese, +} + +impl Fixture { + fn new(test: &str) -> Option { + if !crate::startup_tests::private_runtime(test) { + return None; + } + + let mut events = EventLoop::try_new().unwrap(); + let state = crate::startup_tests::state(&mut events); + Some(Self { events, state }) + } + + fn dispatch(&mut self) { + self.events.dispatch(Duration::from_millis(5), &mut self.state).unwrap(); + crate::after_dispatch(&mut self.state); + } + + fn output(&mut self, name: &str) -> Output { + let output = Output::new( + name.into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + self.state.space.map_output(&output, (0, 0)); + self.state.register_output(&output, name.into()); + output + } + + fn acquire(&mut self) -> Locker { + let (server, mut wire) = UnixStream::pair().unwrap(); + let client = self + .state + .display_handle + .insert_client(server, Arc::new(crate::state::ClientState::default())) + .unwrap(); + let manager = client + .create_resource::(&self.state.display_handle, 1, ()) + .unwrap(); + request(&mut wire, manager.id().protocol_id(), 1, &[2]); + self.dispatch(); + Locker { + wire, + client, + next_id: 3, + } + } +} + +struct Locker { + wire: UnixStream, + client: Client, + next_id: u32, +} + +impl Locker { + fn id(&mut self) -> u32 { + let id = self.next_id; + self.next_id += 1; + id + } + + fn expect_locked(&mut self) { + self.wire.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + let mut event = [0u8; 8]; + self.wire.read_exact(&mut event).unwrap(); + assert_eq!(u32::from_ne_bytes(event[..4].try_into().unwrap()), 2); + assert_eq!(u32::from_ne_bytes(event[4..].try_into().unwrap()), 8u32 << 16); + } + + fn surface(&mut self, fixture: &mut Fixture, output: &Output) -> LockSurface { + output.create_global::(&fixture.state.display_handle); + let registry = self.id(); + request(&mut self.wire, 1, 1, &[registry]); + fixture.dispatch(); + self.wire.set_nonblocking(true).unwrap(); + let mut events = Vec::new(); + let _ = self.wire.read_to_end(&mut events); + self.wire.set_nonblocking(false).unwrap(); + let mut offset = 0; + let mut output_name = None; + while offset < events.len() { + let object = u32::from_ne_bytes(events[offset..offset + 4].try_into().unwrap()); + let header = u32::from_ne_bytes(events[offset + 4..offset + 8].try_into().unwrap()); + if object == registry && header & 0xffff == 0 { + let len = u32::from_ne_bytes(events[offset + 12..offset + 16].try_into().unwrap()) as usize; + if &events[offset + 16..offset + 16 + len] == b"wl_output\0" { + let name = u32::from_ne_bytes(events[offset + 8..offset + 12].try_into().unwrap()); + output_name = Some(output_name.map_or(name, |previous: u32| previous.max(name))); + } + } + + offset += (header >> 16) as usize; + } + + let output_id = self.id(); + let mut args = vec![output_name.expect("output global"), 10]; + args.extend( + b"wl_output\0\0\0" + .chunks_exact(4) + .map(|bytes| u32::from_ne_bytes(bytes.try_into().unwrap())), + ); + args.extend([4, output_id]); + request(&mut self.wire, registry, 0, &args); + let compositor = self + .client + .create_resource::(&fixture.state.display_handle, 6, ()) + .unwrap(); + let surface_id = self.id(); + request(&mut self.wire, compositor.id().protocol_id(), 0, &[surface_id]); + let lock_surface = self.id(); + request(&mut self.wire, 2, 1, &[lock_surface, surface_id, output_id]); + fixture.dispatch(); + fixture + .state + .session_lock + .surfaces + .get(output) + .expect("lock surface on requested output") + .clone() + } +} + +fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]) { + for word in [object, (((args.len() + 2) * 4) as u32) << 16 | opcode] + .into_iter() + .chain(args.iter().copied()) + { + wire.write_all(&word.to_ne_bytes()).unwrap(); + } +} + +#[test] +fn lock_confirms_without_outputs() { + let Some(mut fixture) = Fixture::new("session_lock::tests::lock_confirms_without_outputs") else { + return; + }; + let epoch = fixture.state.capture_epoch; + let mut locker = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); + assert_ne!(fixture.state.capture_epoch, epoch, "lock revokes deferred snapshots"); + locker.expect_locked(); +} + +#[test] +fn lock_waits_for_each_output_before_confirmation() { + let Some(mut fixture) = Fixture::new("session_lock::tests::lock_waits_for_each_output_before_confirmation") else { + return; + }; + let first = fixture.output("first"); + let second = fixture.output("second"); + let mut locker = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + fixture.state.lock_frame_presented(&first); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + assert!(fixture.state.session_lock.idle_timer.is_none()); + fixture.state.lock_frame_presented(&second); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); + fixture.dispatch(); + locker.expect_locked(); +} + +#[test] +fn live_owner_rejects_competitor_unlock() { + let Some(mut fixture) = Fixture::new("session_lock::tests::live_owner_rejects_competitor_unlock") else { + return; + }; + let _owner_wire = fixture.acquire(); + let owner = fixture.state.session_lock.lifecycle.owner().cloned(); + let mut competitor = fixture.acquire(); + assert_eq!(fixture.state.session_lock.lifecycle.owner(), owner.as_ref()); + request(&mut competitor.wire, 2, 2, &[]); + fixture.dispatch(); + assert_eq!(fixture.state.session_lock.lifecycle.owner(), owner.as_ref()); + assert!(fixture.state.session_lock.active()); +} + +#[test] +fn pending_owner_cannot_unlock_before_confirmation() { + let Some(mut fixture) = Fixture::new("session_lock::tests::pending_owner_cannot_unlock_before_confirmation") else { + return; + }; + fixture.output("pending"); + let mut pending = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + request(&mut pending.wire, 2, 2, &[]); + fixture.dispatch(); + assert!(fixture.state.session_lock.active()); + fixture.dispatch(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Orphaned)); +} + +#[test] +fn owner_disconnect_stays_locked_and_allows_replacement() { + let Some(mut fixture) = Fixture::new("session_lock::tests::owner_disconnect_stays_locked_and_allows_replacement") + else { + return; + }; + let owner_wire = fixture.acquire(); + let owner = fixture.state.session_lock.lifecycle.owner().cloned(); + drop(owner_wire); + fixture.dispatch(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Orphaned)); + assert!(fixture.state.session_lock.active()); + let mut replacement = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); + assert_ne!(fixture.state.session_lock.lifecycle.owner(), owner.as_ref()); + replacement.expect_locked(); +} + +#[test] +fn removing_last_output_confirms_pending_lock() { + let Some(mut fixture) = Fixture::new("session_lock::tests::removing_last_output_confirms_pending_lock") else { + return; + }; + let output = fixture.output("last"); + let mut pending = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + fixture.state.unregister_output(&output); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); + fixture.dispatch(); + pending.expect_locked(); +} + +#[test] +fn refresh_after_removal_confirms_and_starts_idle_timer() { + let Some(mut fixture) = Fixture::new("session_lock::tests::refresh_after_removal_confirms_and_starts_idle_timer") + else { + return; + }; + let output = fixture.output("last"); + let mut pending = fixture.acquire(); + fixture.state.space.unmap_output(&output); + fixture.state.session_lock.output_removed(&output); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + fixture.state.refresh_lock_outputs(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); + assert!(fixture.state.session_lock.idle_timer.is_some()); + fixture.dispatch(); + pending.expect_locked(); +} + +#[test] +fn activity_reuses_timer_and_expired_timer_rechecks_latest_activity() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::activity_reuses_timer_and_expired_timer_rechecks_latest_activity") + else { + return; + }; + fixture.state.session_lock.lifecycle = Lifecycle::Orphaned; + fixture.state.lock_idle = IdleSettings { + dim_after_seconds: 1, + sleep_after_seconds: 2, + }; + fixture.state.session_lock.idle_since = Some(Instant::now() - Duration::from_millis(999)); + fixture.state.refresh_lock_idle_policy(); + let timer = fixture.state.session_lock.idle_timer.unwrap(); + for _ in 0..1000 { + fixture.state.lock_input_activity(); + assert_eq!( + fixture.state.session_lock.idle_timer, + Some(timer), + "input replaced the source" + ); + } + + let limit = Instant::now() + Duration::from_secs(1); + while fixture.state.session_lock.idle_timer.unwrap().1 == timer.1 { + assert!(Instant::now() < limit, "idle timer did not recheck"); + fixture + .events + .dispatch(Duration::from_millis(10), &mut fixture.state) + .unwrap(); + } + + let rechecked = fixture.state.session_lock.idle_timer.unwrap(); + assert_eq!(timer.0, rechecked.0, "timer callback replaced the source"); + assert!(rechecked.1 > timer.1); + assert_eq!(fixture.state.session_lock.idle_opacity, 0.0); + assert!(!fixture.state.session_lock.sleeping); +} + +#[test] +fn earlier_policy_replaces_timer_and_sleeping_activity_arms_one() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::earlier_policy_replaces_timer_and_sleeping_activity_arms_one") + else { + return; + }; + fixture.state.session_lock.lifecycle = Lifecycle::Orphaned; + fixture.state.lock_input_activity(); + let first = fixture.state.session_lock.idle_timer.unwrap(); + fixture.state.lock_idle.dim_after_seconds = 60; + fixture.state.refresh_lock_idle_policy(); + assert_eq!(fixture.state.session_lock.idle_timer, Some(first)); + fixture.state.lock_idle.dim_after_seconds = 1; + fixture.state.refresh_lock_idle_policy(); + let earlier = fixture.state.session_lock.idle_timer.unwrap(); + assert_ne!(first.0, earlier.0); + assert!(earlier.1 < first.1); + fixture.state.session_lock.idle_since = Some(Instant::now() - Duration::from_secs(121)); + fixture.state.refresh_lock_idle_policy(); + assert!(fixture.state.session_lock.sleeping); + assert!(fixture.state.session_lock.idle_timer.is_none()); + fixture.state.lock_input_activity(); + assert!(fixture.state.session_lock.idle_timer.is_some()); + assert!(!fixture.state.session_lock.sleeping); + assert_eq!(fixture.state.session_lock.idle_opacity, 0.0); + assert!(fixture.state.session_lock.active()); +} + +#[test] +fn dead_preferred_surface_uses_live_fallback_in_stable_output_order() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::dead_preferred_surface_uses_live_fallback_in_stable_output_order") + else { + return; + }; + let preferred = fixture.output("preferred"); + let second = fixture.output("second"); + let third = fixture.output("third"); + let mut locker = fixture.acquire(); + let dead = locker.surface(&mut fixture, &preferred); + let fallback = locker.surface(&mut fixture, &second); + let later = locker.surface(&mut fixture, &third); + let preferred_id = fixture.state.output_id(&preferred).unwrap(); + fixture.state.output_workspaces.focus_output(preferred_id).unwrap(); + request(&mut locker.wire, dead.wl_surface().id().protocol_id(), 0, &[]); + fixture.dispatch(); + assert!(!dead.alive()); + // Exercise the defensive path even though destruction normally removes it. + fixture.state.session_lock.surfaces.insert(preferred.clone(), dead); + for _ in 0..5 { + fixture.state.session_lock.surfaces.remove(&second); + fixture + .state + .session_lock + .surfaces + .insert(second.clone(), fallback.clone()); + fixture.state.session_lock.surfaces.insert(third.clone(), later.clone()); + fixture.state.focus_lock_surface(); + assert_eq!( + fixture.state.seat.get_keyboard().unwrap().current_focus(), + Some(fallback.wl_surface().clone()) + ); + } +} + +#[test] +fn output_removed_clears_all_lock_resources() { + let Some(mut fixture) = Fixture::new("session_lock::tests::output_removed_clears_all_lock_resources") else { + return; + }; + let output = fixture.output("removed"); + let mut locker = fixture.acquire(); + locker.surface(&mut fixture, &output); + let background = + smithay::backend::renderer::element::solid::SolidColorBuffer::new((800, 600), [0.0, 0.0, 0.0, 1.0]); + fixture + .state + .session_lock + .backgrounds + .insert(output.clone(), background.clone()); + fixture + .state + .session_lock + .idle_overlays + .insert(output.clone(), background); + fixture.state.session_lock.presented.insert(output.clone()); + fixture.state.session_lock.output_removed(&output); + let lock = &fixture.state.session_lock; + assert!(!lock.surfaces.contains_key(&output)); + assert!(!lock.backgrounds.contains_key(&output)); + assert!(!lock.idle_overlays.contains_key(&output)); + assert!(!lock.presented.contains(&output)); + assert!(lock.active(), "output cleanup must not unlock"); +} + +#[test] +fn confirmed_owner_can_unlock_and_cancel_idle_timer() { + let Some(mut fixture) = Fixture::new("session_lock::tests::confirmed_owner_can_unlock_and_cancel_idle_timer") + else { + return; + }; + let mut locker = fixture.acquire(); + locker.expect_locked(); + assert!(fixture.state.session_lock.idle_timer.is_some()); + request(&mut locker.wire, 2, 2, &[]); + fixture.dispatch(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Unlocked)); + assert!(fixture.state.session_lock.idle_timer.is_none()); +} + +#[test] +fn acquiring_owner_disconnect_keeps_lock_and_replacement_waits_for_frame() { + let Some(mut fixture) = + Fixture::new("session_lock::tests::acquiring_owner_disconnect_keeps_lock_and_replacement_waits_for_frame") + else { + return; + }; + let output = fixture.output("pending"); + let pending = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + drop(pending); + fixture.dispatch(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Orphaned)); + assert!(fixture.state.session_lock.active()); + let mut replacement = fixture.acquire(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Acquiring(_))); + fixture.state.lock_frame_presented(&output); + fixture.dispatch(); + assert!(matches!(fixture.state.session_lock.lifecycle, Lifecycle::Locked(_))); + replacement.expect_locked(); +} diff --git a/compositor/src/startup_tests.rs b/compositor/src/startup_tests.rs new file mode 100644 index 0000000..5abb8cf --- /dev/null +++ b/compositor/src/startup_tests.rs @@ -0,0 +1,253 @@ +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::os::unix::process::ExitStatusExt; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use calloop::EventLoop; +use calloop::signals::{Signal, Signals}; +use smithay::input::SeatHandler; +use smithay::input::pointer::{CursorImageStatus, MotionEvent}; +use smithay::reexports::wayland_server::protocol::{wl_compositor::WlCompositor, wl_surface::WlSurface}; +use smithay::reexports::wayland_server::{Display, Resource}; +use smithay::utils::SERIAL_COUNTER; + +use crate::{Ferese, RedrawRequest, after_dispatch, after_dispatch_with_redraw}; + +// Each state needs its own sockets and environment. A subprocess also bounds +// the cursor deadlock test without changing environment variables across tests. +pub(crate) fn private_runtime(test: &str) -> bool { + const CHILD: &str = "FERESE_STARTUP_TEST_CHILD"; + if std::env::var_os(CHILD).is_some_and(|value| value == test) { + return true; + } + + let directory = tempfile::tempdir().unwrap(); + let log_path = directory.path().join("test.log"); + let log = std::fs::File::create(&log_path).unwrap(); + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", test, "--nocapture"]) + .env(CHILD, test) + .env("XDG_RUNTIME_DIR", directory.path()) + .env("XDG_CONFIG_HOME", directory.path()) + .env("XDG_STATE_HOME", directory.path()) + .env("XDG_CACHE_HOME", directory.path()) + .env_remove("FERESE_SOCKET") + .stdout(Stdio::from(log.try_clone().unwrap())) + .stderr(Stdio::from(log)) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("{test} did not complete; possible startup deadlock"); + } + + std::thread::sleep(Duration::from_millis(10)); + }; + + assert!(status.success(), "{}", std::fs::read_to_string(log_path).unwrap()); + false +} + +pub(crate) fn state(events: &mut EventLoop<'static, Ferese>) -> Ferese { + let mut config = crate::config::Config::default().runtime_config().unwrap(); + config.wallpaper.path = None; + + Ferese::new(events, Display::new().unwrap(), config).unwrap() +} + +#[test] +fn launched_clients_unblock_termination_signals_and_keep_private_sockets() { + if !private_runtime("startup_tests::launched_clients_unblock_termination_signals_and_keep_private_sockets") { + return; + } + + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM, Signal::SIGUSR1]).unwrap(); + let mut private = crate::private_client::ClientCapabilities::EFFECTS; + private.insert(crate::private_client::ClientCapabilities::SHELL_CONTROL); + for capabilities in [crate::private_client::ClientCapabilities::default(), private] { + let mut child = crate::process::spawn_client(&mut state, ["/bin/sleep", "30"], capabilities).unwrap(); + let status = std::fs::read_to_string(format!("/proc/{}/status", child.id())).unwrap(); + let blocked = status.lines().find(|line| line.starts_with("SigBlk:")).unwrap(); + let blocked = u64::from_str_radix(blocked.split_whitespace().nth(1).unwrap(), 16).unwrap(); + assert_eq!(blocked & ((1 << (libc::SIGINT - 1)) | (1 << (libc::SIGTERM - 1))), 0); + assert_ne!(blocked & (1 << (libc::SIGUSR1 - 1)), 0, "unrelated mask was lost"); + if !capabilities.is_empty() { + let environment = std::fs::read(format!("/proc/{}/environ", child.id())).unwrap(); + for name in ["WAYLAND_SOCKET=", "FERESE_SHELL_CONTROL_SOCKET="] { + let variable = environment + .split(|byte| *byte == 0) + .filter_map(|value| std::str::from_utf8(value).ok()) + .find_map(|value| value.strip_prefix(name)) + .unwrap(); + let fd = std::fs::read_link(format!("/proc/{}/fd/{variable}", child.id())).unwrap(); + assert!(fd.to_string_lossy().starts_with("socket:[")); + } + } + + crate::process::terminate_child(&mut child); + assert_eq!( + child.wait().unwrap().signal(), + Some(libc::SIGTERM), + "child needed SIGKILL" + ); + } + + // Child setup must not unblock the compositor's own signalfd mask. + let mut mask = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut mask) }, + 0 + ); + assert_eq!(unsafe { libc::sigismember(&mask, libc::SIGINT) }, 1); + assert_eq!(unsafe { libc::sigismember(&mask, libc::SIGTERM) }, 1); + drop(signals); +} + +#[test] +fn explicit_client_exit_is_reaped_without_stopping_the_compositor() { + if !private_runtime("startup_tests::explicit_client_exit_is_reaped_without_stopping_the_compositor") { + return; + } + + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + let child = crate::process::spawn_client( + &mut state, + ["/bin/sh", "-c", "exit 7"], + crate::private_client::ClientCapabilities::default(), + ) + .unwrap(); + let pid = child.id(); + let watched = crate::process::watch_client_exit(&events.handle(), child); + let deadline = Instant::now() + Duration::from_secs(2); + while watched.borrow().is_some() { + assert!(Instant::now() < deadline, "client was not reaped"); + events.dispatch(Duration::from_millis(10), &mut state).unwrap(); + after_dispatch(&mut state); + } + + let mut status = 0; + assert_eq!(unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) }, -1); + assert_eq!(std::io::Error::last_os_error().raw_os_error(), Some(libc::ECHILD)); + // Exit watching removes itself; subsequent event-loop dispatch remains usable. + events.dispatch(Duration::ZERO, &mut state).unwrap(); +} + +#[test] +fn check_config_rejects_an_otherwise_valid_oversized_file() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.kdl"); + let valid = format!("//{}\n", "x".repeat(60 * 1024 - 3)); + std::fs::write(&path, &valid).unwrap(); + crate::check_config(&path).unwrap(); + assert!(crate::theme::prepare(&(valid.clone() + "\n"), directory.path()).is_ok()); + std::fs::write(&path, valid + "\n").unwrap(); + let error = crate::check_config(&path).unwrap_err(); + assert!(error.contains("exceeds 60 KiB"), "{error}"); + assert_eq!(crate::theme::read_source(&path).unwrap_err(), error); +} + +#[test] +fn deferred_cursor_redraw_runs_after_input_lock_is_released() { + if !private_runtime("startup_tests::deferred_cursor_redraw_runs_after_input_lock_is_released") { + return; + } + + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + let (server, mut client_wire) = UnixStream::pair().unwrap(); + let client = state + .display_handle + .insert_client(server, Arc::new(crate::state::ClientState::default())) + .unwrap(); + let compositor = client + .create_resource::(&state.display_handle, 6, ()) + .unwrap(); + // wl_compositor.create_surface(new_id=2). + for word in [compositor.id().protocol_id(), 12u32 << 16, 2] { + client_wire.write_all(&word.to_ne_bytes()).unwrap(); + } + + events.dispatch(Duration::from_millis(10), &mut state).unwrap(); + let surface = client + .object_from_protocol_id::(&state.display_handle, 2) + .unwrap(); + let pointer = state.seat.get_pointer().unwrap(); + let seat = state.seat.clone(); + state.cursor_image(&seat, CursorImageStatus::Hidden); + after_dispatch_with_redraw(&mut state, |_, _| {}); + events.handle().insert_idle(move |state| { + for _ in 0..2 { + let event = MotionEvent { + location: (10.0, 20.0).into(), + serial: SERIAL_COUNTER.next_serial(), + time: 0, + }; + pointer.motion(state, Some((surface.clone(), (0.0, 0.0).into())), &event); + // Smithay invokes Ferese::cursor_image while holding its real pointer lock. + pointer.motion(state, None, &event); + assert!(state.cursor_redraw_pending); + } + }); + let signal = events.get_signal(); + let mut redraws = 0; + events + .run(Some(Duration::ZERO), &mut state, |state| { + after_dispatch_with_redraw(state, |state, request| { + assert!(matches!(request, RedrawRequest::Cursor)); + assert_eq!( + state.seat.get_pointer().unwrap().current_location(), + (10.0, 20.0).into() + ); + redraws += 1; + }); + assert!(!state.cursor_redraw_pending); + signal.stop(); + }) + .unwrap(); + assert_eq!(redraws, 1); + after_dispatch_with_redraw(&mut state, |_, _| panic!("redraw was not consumed")); +} + +#[test] +fn wayland_roundtrip_completes_without_a_rendered_frame() { + if !private_runtime("startup_tests::wayland_roundtrip_completes_without_a_rendered_frame") { + return; + } + + let mut events = EventLoop::try_new().unwrap(); + let mut state = state(&mut events); + let (server, mut client) = UnixStream::pair().unwrap(); + state + .display_handle + .insert_client(server, Arc::new(crate::state::ClientState::default())) + .unwrap(); + // wl_display.sync(new_id=2), using native-endian Wayland wire format. + for word in [1u32, 12u32 << 16, 2u32] { + client.write_all(&word.to_ne_bytes()).unwrap(); + } + + events.dispatch(Duration::from_millis(10), &mut state).unwrap(); + client.set_read_timeout(Some(Duration::from_millis(20))).unwrap(); + let mut reply = [0u8; 12]; + assert!( + client.read_exact(&mut reply).is_err(), + "dispatch flushed replies before after_dispatch" + ); + after_dispatch(&mut state); + client.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); + client.read_exact(&mut reply).unwrap(); + assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2); + assert_eq!(u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff, 0); +} diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 931a34a..b7e6ca9 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -7,6 +7,8 @@ mod layout; mod navigation; mod outputs; mod reconciliation; +#[cfg(test)] +mod window_lifecycle_tests; pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; mod prediction; mod presentation_dependencies; @@ -478,6 +480,7 @@ pub struct Ferese { pub direct_backend: Option, _ipc_socket: Option, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, + pub(crate) activation_inputs: crate::handlers::activation::InputHistory, pub(crate) pending_screencopies: Vec, // Screenshot requests outlive the readback that filled them: the // coordinator owns them until encoding finishes and the caller is @@ -721,6 +724,7 @@ impl Ferese { direct_backend: None, _ipc_socket: None, pending_dmabuf_imports: Vec::new(), + activation_inputs: Default::default(), pending_screencopies: Vec::new(), screenshot: Coordinator::new(), screenshot_parts: None, diff --git a/compositor/src/state/capture_privacy.rs b/compositor/src/state/capture_privacy.rs index 33d93a6..a3d3fc5 100644 --- a/compositor/src/state/capture_privacy.rs +++ b/compositor/src/state/capture_privacy.rs @@ -138,4 +138,4 @@ impl Ferese { #[cfg(test)] #[path = "capture_privacy_tests.rs"] -mod tests; +pub(super) mod tests; diff --git a/compositor/src/state/capture_privacy_tests.rs b/compositor/src/state/capture_privacy_tests.rs index 829ceb7..2da773e 100644 --- a/compositor/src/state/capture_privacy_tests.rs +++ b/compositor/src/state/capture_privacy_tests.rs @@ -22,7 +22,7 @@ use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData, XdgW use crate::Ferese; use crate::state::{ClientState, DesktopOutput}; -fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Option) { +pub(crate) fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Option) { let bytes = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode] .into_iter() .chain(args.iter().copied()) @@ -54,12 +54,12 @@ fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Op } } -fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) { +pub(crate) fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) { events.dispatch(Duration::from_millis(1), state).unwrap(); state.display_handle.flush_clients().unwrap(); } -fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 { +pub(crate) fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 { wire.set_nonblocking(true).unwrap(); let mut bytes = Vec::new(); let _ = wire.read_to_end(&mut bytes); @@ -79,7 +79,7 @@ fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 { serial.unwrap() } -fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) { +pub(crate) fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) { let (server, mut wire) = UnixStream::pair().unwrap(); let client = state .display_handle @@ -208,7 +208,7 @@ fn pixels( Rectangle::from_size((320, 240).into()), )); let mut target = Some(texture.clone()); - crate::backends::direct::capture::capture_output(state, renderer, &mut target, output, &scene).unwrap(); + crate::backends::direct::capture::capture_output(state, renderer, &mut target, output, &scene); let mut result = receiver.try_recv().unwrap().result.unwrap().pixels; for pixel in result.chunks_exact_mut(4) { pixel.swap(0, 2); @@ -424,8 +424,7 @@ fn capture_privacy_pixels_and_policy_transitions() { &mut Some(capture_texture.clone()), &output, &scene, - ) - .unwrap(); + ); let region = receiver.try_recv().unwrap().result.unwrap(); assert_eq!((region.width, region.height), (160, 120)); assert!( diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index 75122d7..42bef2a 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -246,12 +246,14 @@ impl Ferese { let is_maximized = self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id); + let placement = self.workspaces.placement(*id); + let is_floating = matches!(placement, Some(WindowPlacement::Floating { .. })); let rect = if workspace_fullscreen == Some(*id) { fullscreen_bounds - } else if is_maximized && !is_scrolling_layout { + } else if is_maximized && (!is_scrolling_layout || is_floating) { maximized_rect(bounds, self.gap_config.outer) } else { - match self.workspaces.placement(*id) { + match placement { Some(WindowPlacement::Tiled) => { let Some(rect) = layout.geometry.get(id).copied() else { continue; @@ -271,7 +273,6 @@ impl Ferese { }; let is_fullscreen = workspace_fullscreen == Some(*id); - let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. })); visible.insert(*id); let scrolling = if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) { diff --git a/compositor/src/state/outputs.rs b/compositor/src/state/outputs.rs index 3edead8..bb85e6c 100644 --- a/compositor/src/state/outputs.rs +++ b/compositor/src/state/outputs.rs @@ -111,8 +111,6 @@ impl Ferese { true } }); - self.session_lock.surfaces.remove(output); - self.session_lock.backgrounds.remove(output); self.session_lock.output_removed(output); } diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs new file mode 100644 index 0000000..b53e0dc --- /dev/null +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -0,0 +1,281 @@ +use super::capture_privacy::tests::{ack_configure, dispatch, request, window}; +use super::*; +use smithay::input::pointer::{Focus, GrabStartData, MotionEvent}; +use smithay::reexports::wayland_server::Resource; +use smithay::utils::SERIAL_COUNTER; + +fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese, Output) { + let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + state.animations_enabled = false; + let output = Output::new( + "test".into(), + smithay::output::PhysicalProperties { + size: (0, 0).into(), + subpixel: smithay::output::Subpixel::Unknown, + make: "test".into(), + model: "test".into(), + }, + ); + output.change_current_state( + Some(smithay::output::Mode { + size: (800, 600).into(), + refresh: 60_000, + }), + None, + None, + None, + ); + state.space.map_output(&output, (0, 0)); + state.register_output(&output, "test".into()); + (events, state, output) +} + +#[test] +fn hidden_null_commit_removes_membership_and_remaps_without_workspace_switch() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::hidden_null_commit_removes_membership_and_remaps_without_workspace_switch", + ) { + return; + } + + let (mut events, mut state, _) = fixture(); + let (window, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.switch_workspace(2); + let active = state.workspaces.active_id(); + assert!(state.space.element_location(&window).is_none()); + assert_eq!( + state.window_for_surface(window.toplevel().unwrap().wl_surface()), + Some(window.clone()) + ); + + request(&mut wire, 2, 1, &[0, 0, 0], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert!(!state.windows.ids().contains_key(&window)); + assert!(state.workspaces.workspace_for_window(id).is_none()); + + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + ack_configure(&mut wire, 3); + request(&mut wire, 2, 1, &[6, 0, 0], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + let remapped = state.windows.ids()[&window]; + assert_ne!(id, remapped); + assert_eq!(state.workspaces.workspace_for_window(remapped), Some(active)); + assert_eq!(state.workspaces.active_id(), active); +} + +#[test] +fn hidden_commits_update_client_geometry_and_release_resize_transactions() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::hidden_commits_update_client_geometry_and_release_resize_transactions", + ) { + return; + } + + let (mut events, mut state, _) = fixture(); + let (window, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.switch_workspace(2); + assert!(state.space.element_location(&window).is_none()); + let toplevel = window.toplevel().unwrap(); + toplevel.with_pending_state(|pending| pending.size = Some((80, 48).into())); + let serial = toplevel.send_configure(); + state.windows.set_transaction( + id, + crate::resize_transaction::ResizeTransaction::new(serial, state.presentation_now()), + ); + state.display_handle.flush_clients().unwrap(); + assert_eq!(ack_configure(&mut wire, 3), u32::from(serial)); + request(&mut wire, 3, 3, &[0, 0, 32, 48], None); + request(&mut wire, 2, 1, &[6, 0, 0], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert_eq!(window.geometry().size, (32, 48).into()); + assert!(state.windows.transaction(&id).is_none()); + assert_eq!( + state.windows.geometry(&id).unwrap().client.committed_size, + Some(ClientSize { width: 32, height: 48 }) + ); + assert!(state.space.element_location(&window).is_none()); +} + +#[test] +fn floating_maximize_and_restore_work_in_both_layouts() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::floating_maximize_and_restore_work_in_both_layouts", + ) { + return; + } + + let (mut events, mut state, _) = fixture(); + let (window, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.focused_window = Some(id); + state.toggle_focused_floating(); + state.set_floating_window_geometry(&window, (120, 100).into(), (64, 48).into()); + state.display_handle.flush_clients().unwrap(); + ack_configure(&mut wire, 3); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + let normal = match state.workspaces.placement(id).unwrap() { + WindowPlacement::Floating { rect } => rect, + _ => panic!("expected floating window"), + }; + + let mut next_object = 7; + for mode in [ferese_core::LayoutMode::Scrolling, ferese_core::LayoutMode::Tree] { + let bounds = state.output_bounds().unwrap(); + state.workspaces.set_active_layout_mode(mode, bounds).unwrap(); + state.relayout(); + state.set_window_maximized(id, true); + let maximized = maximized_rect(bounds, state.gap_config.outer); + assert_eq!(state.windows.geometry(&id).unwrap().logical, maximized, "{mode:?}"); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { rect: normal }) + ); + state.display_handle.flush_clients().unwrap(); + ack_configure(&mut wire, 3); + // Commit the configured raster instead of keeping the original tiny buffer. + use smithay::reexports::wayland_server::protocol::wl_shm::WlShm; + use std::io::Write; + use std::os::fd::AsRawFd; + let client = window.toplevel().unwrap().wl_surface().client().unwrap(); + let shm = client + .create_resource::(&state.display_handle, 1, ()) + .unwrap(); + let (width, height) = (maximized.width.round() as u32, maximized.height.round() as u32); + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&0xff112233u32.to_ne_bytes().repeat((width * height) as usize)) + .unwrap(); + request( + &mut wire, + shm.id().protocol_id(), + 0, + &[next_object, width * height * 4], + Some(file.as_raw_fd()), + ); + request( + &mut wire, + next_object, + 0, + &[next_object + 1, 0, width, height, width * 4, 0], + None, + ); + request(&mut wire, 3, 3, &[0, 0, width, height], None); + request(&mut wire, 2, 1, &[next_object + 1, 0, 0], None); + next_object += 2; + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert!( + window + .toplevel() + .unwrap() + .current_state() + .states + .contains(xdg_toplevel::State::Maximized) + ); + + state.set_window_maximized(id, false); + assert_eq!(state.windows.geometry(&id).unwrap().logical, normal, "{mode:?}"); + state.display_handle.flush_clients().unwrap(); + ack_configure(&mut wire, 3); + request(&mut wire, 3, 3, &[0, 0, 64, 48], None); + request(&mut wire, 2, 1, &[6, 0, 0], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + assert!( + !window + .toplevel() + .unwrap() + .current_state() + .states + .contains(xdg_toplevel::State::Maximized) + ); + assert_eq!( + state.workspaces.placement(id), + Some(WindowPlacement::Floating { rect: normal }) + ); + } +} + +#[test] +fn malformed_resize_limits_disconnect_only_the_client_and_motion_survives() { + use std::io::Read; + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::malformed_resize_limits_disconnect_only_the_client_and_motion_survives", + ) { + return; + } + + let (mut events, mut state, _) = fixture(); + for (minimum, maximum) in [ + ((800, 0), (400, 0)), + ((0, 800), (0, 400)), + ((-1, 0), (0, 0)), + ((0, 0), (-1, 0)), + ((0, -1), (0, 0)), + ((0, 0), (0, -1)), + ] { + let (window, mut wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.focused_window = Some(id); + state.toggle_focused_floating(); + let pointer = state.seat.get_pointer().unwrap(); + pointer.set_grab( + &mut state, + crate::grabs::ResizeSurfaceGrab::new( + GrabStartData { + focus: None, + button: 0x110, + location: (100.0, 100.0).into(), + }, + window, + xdg_toplevel::ResizeEdge::BottomRight.into(), + Rectangle::new((100, 100).into(), (64, 48).into()), + ), + SERIAL_COUNTER.next_serial(), + Focus::Clear, + ); + request(&mut wire, 4, 8, &[minimum.0 as u32, minimum.1 as u32], None); + request(&mut wire, 4, 7, &[maximum.0 as u32, maximum.1 as u32], None); + request(&mut wire, 2, 6, &[], None); + dispatch(&mut events, &mut state); + pointer.motion( + &mut state, + None, + &MotionEvent { + location: (120.0, 130.0).into(), + serial: SERIAL_COUNTER.next_serial(), + time: 1, + }, + ); + pointer.unset_grab(&mut state, SERIAL_COUNTER.next_serial(), 2); + wire.set_nonblocking(true).unwrap(); + let mut messages = Vec::new(); + let _ = wire.read_to_end(&mut messages); + let mut offset = 0; + let mut rejected = false; + while offset + 8 <= messages.len() { + let object = u32::from_ne_bytes(messages[offset..offset + 4].try_into().unwrap()); + let header = u32::from_ne_bytes(messages[offset + 4..offset + 8].try_into().unwrap()); + if object == 1 && header & 0xffff == 0 { + assert_eq!( + u32::from_ne_bytes(messages[offset + 12..offset + 16].try_into().unwrap()), + xdg_toplevel::Error::InvalidSize as u32 + ); + rejected = true; + } + + offset += (header >> 16) as usize; + } + assert!(rejected, "invalid limits {minimum:?}/{maximum:?} were not rejected"); + } + + let (healthy, _wire) = window(&mut state, &mut events, 0xff112233); + assert!(state.windows.ids().contains_key(&healthy)); +} diff --git a/compositor/src/state/windows.rs b/compositor/src/state/windows.rs index e201acc..68fc3f7 100644 --- a/compositor/src/state/windows.rs +++ b/compositor/src/state/windows.rs @@ -1,6 +1,20 @@ use super::*; impl Ferese { + pub(crate) fn window_for_surface(&self, surface: &WlSurface) -> Option { + self.windows + .id_for_surface(surface) + .and_then(|id| self.windows.window(id)) + .or_else(|| { + self.space.elements().find(|window| { + window + .toplevel() + .is_some_and(|toplevel| toplevel.wl_surface() == surface) + }) + }) + .cloned() + } + pub(crate) fn restore_initial_floating_size(&mut self, window: &Window) { let Some(toplevel) = window.toplevel() else { return }; let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { @@ -449,17 +463,10 @@ impl Ferese { } pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) { - let Some((window, id)) = self - .windows - .ids() - .iter() - .find(|(window, _)| { - window - .toplevel() - .is_some_and(|toplevel| toplevel.wl_surface() == surface) - }) - .map(|(window, id)| (window.clone(), *id)) - else { + let Some(id) = self.windows.id_for_surface(surface) else { + return; + }; + let Some(window) = self.windows.window(id).cloned() else { return; }; diff --git a/compositor/src/theme.rs b/compositor/src/theme.rs index a4eefd3..6e5dc93 100644 --- a/compositor/src/theme.rs +++ b/compositor/src/theme.rs @@ -97,7 +97,7 @@ static SYSTEM_APPEARANCE: std::sync::OnceLock ferese_config::theme::AutoContext { let appearance = SYSTEM_APPEARANCE.get_or_init(|| { - let value = std::process::Command::new("gsettings") + let value = crate::process::command("gsettings") .args(["get", "org.gnome.desktop.interface", "color-scheme"]) .output() .ok() @@ -323,7 +323,7 @@ pub(crate) fn init( .spawn(move || { use std::io::BufRead; use std::os::unix::process::CommandExt; - let mut command = std::process::Command::new("gsettings"); + let mut command = crate::process::command("gsettings"); unsafe { command.pre_exec(|| { if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { @@ -338,7 +338,7 @@ pub(crate) fn init( .stderr(std::process::Stdio::null()) .spawn(); if let Ok(mut monitor) = monitor { - if let Ok(output) = std::process::Command::new("gsettings") + if let Ok(output) = crate::process::command("gsettings") .args(["get", "org.gnome.desktop.interface", "color-scheme"]) .output() { @@ -430,8 +430,7 @@ impl Ferese { self.poll_theme(); return; } - let wallpaper = (self.wallpaper.configuration() != &wallpaper) - .then(|| WallpaperState::with_wakeup(wallpaper, Some(self.loop_signal.clone()))); + let wallpaper = (self.wallpaper.configuration() != &wallpaper).then(|| self.wallpaper.replacement(wallpaper)); self.theme_engine.pending = Some(Pending { candidate, wallpaper, diff --git a/compositor/src/wallpaper.rs b/compositor/src/wallpaper.rs index f21dcda..e219f0c 100644 --- a/compositor/src/wallpaper.rs +++ b/compositor/src/wallpaper.rs @@ -18,8 +18,11 @@ use smithay::utils::{Buffer, Physical, Rectangle, Size}; use crate::presentation::NativeTextureElement; +mod decoder; mod pixels; +use decoder::Decoder; use pixels::Pixels; +use std::sync::Arc; const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5); @@ -59,6 +62,8 @@ struct WallpaperTexture { pub(crate) struct WallpaperState { config: WallpaperConfig, pending: Option, + loading: Option, + decoder: Arc, commit: CommitCounter, owned: bool, mode: WallpaperMode, @@ -78,26 +83,35 @@ impl WallpaperState { } pub fn with_wakeup(config: WallpaperConfig, wakeup: Option) -> Self { + Self::with_decoder(config, wakeup, Decoder::new(pixels::load)) + } + + pub(crate) fn replacement(&self, config: WallpaperConfig) -> Self { + Self::with_decoder(config, self.wakeup.clone(), self.decoder.clone()) + } + + fn with_decoder( + config: WallpaperConfig, + wakeup: Option, + decoder: Arc, + ) -> Self { let retained = config.clone(); - let (sender, receiver) = mpsc::channel(); let owned = config.path.as_ref().is_some_and(|path| path.is_file()); - if owned { - let path = config.path.unwrap(); - let wakeup = wakeup.clone(); - std::thread::spawn(move || { - let _ = sender.send(pixels::load(&path)); - if let Some(wakeup) = wakeup { - wakeup.wakeup(); - } - }); - } + let receiver = if owned { + Some(decoder.submit(config.path.clone().unwrap(), wakeup.clone())) + } else { + decoder.cancel_pending(); + None + }; Self { config: retained, pending: None, + loading: None, + decoder, commit: CommitCounter::default(), owned, mode: config.mode, - receiver: owned.then_some(receiver), + receiver, pixels: None, textures: HashMap::new(), upload_retries: HashMap::new(), @@ -121,8 +135,22 @@ impl WallpaperState { } pub fn reload(&mut self, config: WallpaperConfig) { + if self.config.path != config.path && config.path.as_ref().is_some_and(|path| !path.is_file()) { + tracing::warn!("new wallpaper is unavailable; retaining previous image"); + return; + } + if self.receiver.is_some() { - self.pending = (self.config != config).then_some(config); + if self.loading.is_some() && self.config == config { + self.receiver = None; + self.loading = None; + self.pending = None; + self.decoder.cancel_pending(); + return; + } + + let requested = self.loading.as_ref().unwrap_or(&self.config); + self.pending = (requested != &config).then_some(config); return; } if self.config == config { @@ -134,19 +162,18 @@ impl WallpaperState { self.commit.increment(); return; } - if config.path.as_ref().is_some_and(|path| !path.is_file()) { - tracing::warn!("new wallpaper is unavailable; retaining previous image"); - return; - } - let mut replacement = Self::with_wakeup(config, self.wakeup.clone()); - self.config = replacement.config; - self.mode = replacement.mode; - self.owned = replacement.owned; - self.receiver = replacement.receiver.take(); - if self.config.path.is_none() { + if let Some(path) = config.path.clone() { + self.receiver = Some(self.decoder.submit(path, self.wakeup.clone())); + self.loading = Some(config); + } else { + self.decoder.cancel_pending(); + self.config = config; + self.mode = self.config.mode; + self.owned = false; self.pixels = None; self.textures.clear(); self.upload_retries.clear(); + self.commit.increment(); } } @@ -205,16 +232,15 @@ impl WallpaperState { match receiver.try_recv() { Ok(result) => { self.receiver = None; - if let Some(pending) = self.pending.take() { - if pending.path != self.config.path { - self.reload(pending); - return true; - } - self.config = pending; - self.mode = self.config.mode; - } + let loading = self.loading.take(); match result { Ok(pixels) => { + if let Some(config) = loading { + self.config = config; + self.mode = self.config.mode; + self.owned = true; + } + tracing::info!( width = pixels.width(), height = pixels.height(), @@ -227,13 +253,17 @@ impl WallpaperState { self.commit.increment(); } Err(error) => { - tracing::warn!(%error, "wallpaper unavailable; using output clear color") + tracing::warn!(%error, "wallpaper decode failed; retaining previous image") } } + if let Some(pending) = self.pending.take() { + self.reload(pending); + } true } Err(mpsc::TryRecvError::Disconnected) => { self.receiver = None; + self.loading = None; false } Err(mpsc::TryRecvError::Empty) => false, @@ -323,6 +353,46 @@ fn image_geometry( mod tests { use super::*; + #[test] + fn failed_reload_preserves_accepted_config_and_unavailable_pending_keeps_success() { + let directory = tempfile::tempdir().unwrap(); + let mut state = WallpaperState::new(WallpaperConfig { + path: None, + mode: WallpaperMode::Fill, + }); + let accepted = state.config.clone(); + state.pixels = Some(pixels::from_rgba(image::RgbaImage::new(2, 2)).unwrap()); + let bad = directory.path().join("bad-image"); + std::fs::write(&bad, b"not an image").unwrap(); + state.reload(WallpaperConfig { + path: Some(bad), + mode: WallpaperMode::Fit, + }); + let deadline = Instant::now() + Duration::from_secs(2); + while !state.poll() { + assert!(Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(1)); + } + + assert_eq!(state.config, accepted); + assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (2, 2)); + let (sender, receiver) = mpsc::channel(); + state.receiver = Some(receiver); + state.reload(WallpaperConfig { + path: Some(directory.path().join("missing")), + mode: WallpaperMode::Fit, + }); + sender + .send(Ok(pixels::from_rgba(image::RgbaImage::new(3, 4)).unwrap())) + .unwrap(); + assert!(state.poll()); + assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (3, 4)); + assert_eq!(state.config, accepted); + + let replacement = state.replacement(accepted); + assert!(Arc::ptr_eq(&state.decoder, &replacement.decoder)); + } + #[test] fn portal_wallpaper_decodes_without_a_filename_extension() { let directory = tempfile::tempdir().unwrap(); diff --git a/compositor/src/wallpaper/decoder.rs b/compositor/src/wallpaper/decoder.rs new file mode 100644 index 0000000..2c14201 --- /dev/null +++ b/compositor/src/wallpaper/decoder.rs @@ -0,0 +1,114 @@ +//! One in-flight decode and one replaceable request across wallpaper states. +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Condvar, Mutex, mpsc}; + +use super::pixels::Pixels; + +struct Request { + path: PathBuf, + result: mpsc::Sender>, + wakeup: Option, +} + +#[derive(Default)] +struct Queue { + pending: Option, + closed: bool, +} + +pub(super) struct Decoder { + shared: Arc<(Mutex, Condvar)>, +} + +impl Decoder { + pub fn new(load: impl Fn(&Path) -> Result + Send + 'static) -> Arc { + let shared = Arc::new((Mutex::new(Queue::default()), Condvar::new())); + let worker = shared.clone(); + std::thread::Builder::new() + .name("ferese-wallpaper".into()) + .spawn(move || { + loop { + let (queue, ready) = &*worker; + let mut queue = ready + .wait_while(queue.lock().unwrap(), |queue| !queue.closed && queue.pending.is_none()) + .unwrap(); + if queue.closed { + break; + } + + let request = queue.pending.take().unwrap(); + drop(queue); + let _ = request.result.send(load(&request.path)); + if let Some(wakeup) = request.wakeup { + wakeup.wakeup(); + } + } + }) + .expect("start wallpaper decoder"); + Arc::new(Self { shared }) + } + + pub fn submit( + &self, + path: PathBuf, + wakeup: Option, + ) -> mpsc::Receiver> { + let (result, receiver) = mpsc::channel(); + let (queue, ready) = &*self.shared; + queue.lock().unwrap().pending = Some(Request { path, result, wakeup }); + ready.notify_one(); + receiver + } + + pub fn cancel_pending(&self) { + self.shared.0.lock().unwrap().pending = None; + } +} + +impl Drop for Decoder { + fn drop(&mut self) { + let (queue, ready) = &*self.shared; + let mut queue = queue.lock().unwrap(); + queue.closed = true; + queue.pending = None; + ready.notify_one(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn replacements_share_one_decode_and_only_the_latest_pending_starts() { + let (started, events) = mpsc::channel(); + let (release, held) = mpsc::channel(); + let decoder = Decoder::new(move |path| { + started.send(path.to_owned()).unwrap(); + if path == Path::new("first") { + held.recv_timeout(Duration::from_secs(2)).unwrap(); + } + Err("test decode".into()) + }); + let first = decoder.submit("first".into(), None); + assert_eq!( + events.recv_timeout(Duration::from_secs(2)).unwrap(), + PathBuf::from("first") + ); + let dropped = decoder.submit("second".into(), None); + let dropped_too = decoder.submit("third".into(), None); + let latest = decoder.submit("latest".into(), None); + assert!(events.try_recv().is_err()); + assert!(matches!(dropped.try_recv(), Err(mpsc::TryRecvError::Disconnected))); + assert!(matches!(dropped_too.try_recv(), Err(mpsc::TryRecvError::Disconnected))); + release.send(()).unwrap(); + assert!(first.recv_timeout(Duration::from_secs(2)).unwrap().is_err()); + assert_eq!( + events.recv_timeout(Duration::from_secs(2)).unwrap(), + PathBuf::from("latest") + ); + assert!(latest.recv_timeout(Duration::from_secs(2)).unwrap().is_err()); + assert!(events.try_recv().is_err()); + } +} diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 5134629..cf70869 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -155,7 +155,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() &mut capture_texture, &output, &scene, - )?; + ); capture_changed_binding = true; } if capture_changed_binding { diff --git a/docs/development.md b/docs/development.md index 729f26f..8c7f4be 100644 --- a/docs/development.md +++ b/docs/development.md @@ -83,6 +83,30 @@ cargo test --locked -p ferese backends::direct::capture::tests -- --ignored cargo test --locked -p ferese mirrored_pixels_fit -- --ignored ``` +## Session lock checks + +```sh +cargo test --release --locked -p ferese session_lock::tests -- --nocapture +``` + +These tests create private runtime directories and Wayland clients. They run in +normal test suites and in the compositor lifecycle CI job, without a host desktop or DRM. +They cover confirmation, rejected unlocks, owner death and replacement, output +removal, keyboard focus and idle timers. Physical display protection still needs +hardware testing. + +## Resume listener checks + +```sh +cargo test --release --locked -p ferese resume::tests -- --nocapture +``` + +These tests need `dbus-daemon`. They use a private bus to test missed resumes, +logind replacement and listener shutdown during connection setup, signal waits +and retries. They run in the compositor lifecycle CI job and never contact system logind. +Recovery requests fresh hardware and theme state without resetting idle activity. +Hardware suspend/resume still needs testing on a direct session. + ## Idle inhibition checks The playback test runs a fake MPRIS player on a private bus and opens a client in @@ -131,3 +155,49 @@ another VT, suspend/resume with a dock disconnected, change the lid while suspended, and unplug/reconnect the managed GPU where supported. Verify current output geometry, idle notifications, recovery after failures and continued lock protection. Nested sessions do not validate DRM reacquisition. + +## Capture and DRM failure checks + +The compositor tests cover screenshot admission across requests, cancellation +while buffers remain owned, staging-file cleanup, activation serials, child +reaping and wallpaper decode coalescing: + +```sh +cargo test --release --locked -p ferese +``` + +Screenshot requests share a 256 MiB readback reservation. The reservation follows +pending readbacks, queued results and encoder jobs, including after cancellation. +It does not include GPU targets, the encoder's composition canvas or encoded PNG +bytes; it is not a cap on total process memory. + +The offscreen pixel tests need EGL. CI runs them with software Mesa and private +Wayland sockets: + +```sh +LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless cargo test --release --locked -p ferese state::capture_privacy::tests -- --ignored +LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless cargo test --release --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored +``` + +A retirement error or a frame pending for two seconds triggers DRM device +reconciliation. Ferese keeps the frame marked pending until device teardown; +clearing that flag alone would leave Smithay's buffer ownership uncertain. +The watchdog keeps one timer per output while frames are flowing. Session pause +and output removal cancel it. + +To exercise both failures on hardware, build with the test feature and start +Ferese from an unused VT. These commands each inject one failure. Device +recreation may briefly blank the displays; do not run them inside your active +desktop. + +```sh +cargo build --release --locked -p ferese --features drm-fault-injection +FERESE_TEST_DRM_FAILURE=retirement target/release/ferese --backend drm +FERESE_TEST_DRM_FAILURE=missing-completion target/release/ferese --backend drm +``` + +Run them separately. Check that redraws resume, connected outputs retain their +identities, windows remain reachable, and lock protection survives the recovery. +The unit tests check retirement-result handling and pending-frame ownership; +they cannot prove hardware recovery succeeds. The installer leaves fault +injection disabled.