fix compositor edge cases, process handing and other cool improvements

This commit is contained in:
Samuel Onoja committed 2026-10-04 17:11:40 +01:00
1 parent 5706a71941
commit 405c74af9f
38 files changed
+3364 -848

No files matched your search

@@ -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
+2
View File
@@ -36,3 +36,5 @@ tempfile.workspace = true
[features] [features]
# Opt-in counters/histograms with a per-client exit summary. # Opt-in counters/histograms with a per-client exit summary.
resize-metrics = [] resize-metrics = []
# One-shot KMS recovery checks; never enabled by the installer.
drm-fault-injection = []
+64 -10
View File
@@ -10,6 +10,7 @@ mod lid;
mod mirror; mod mirror;
mod output_power; mod output_power;
mod planes; mod planes;
mod recovery;
mod redraw; mod redraw;
mod scheduling; mod scheduling;
mod topology; mod topology;
@@ -260,6 +261,8 @@ struct DirectOutput {
capture_texture: Option<GlesTexture>, capture_texture: Option<GlesTexture>,
render_metrics: RenderMetrics, render_metrics: RenderMetrics,
frame_pending: bool, frame_pending: bool,
completion: recovery::Completion,
completion_timer: Option<RegistrationToken>,
animation_fallback: AnimationFallback, animation_fallback: AnimationFallback,
cursor_visible: bool, cursor_visible: bool,
scheduler: crate::frame_scheduler::FrameScheduler, scheduler: crate::frame_scheduler::FrameScheduler,
@@ -800,23 +803,43 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
.loop_handle .loop_handle
.insert_source(notifier, move |event, metadata, state| match event { .insert_source(notifier, move |event, metadata, state| match event {
DrmEvent::VBlank(crtc) => { DrmEvent::VBlank(crtc) => {
#[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 { if let Some(metadata) = metadata {
let mut retired = false; let mut retired = false;
let mut retirement_failed = false;
let feedback = state let feedback = state
.direct_backend .direct_backend
.as_mut() .as_mut()
.and_then(|backend| backend.devices.get_mut(&node)) .and_then(|backend| backend.devices.get_mut(&node))
.and_then(|device| device.outputs.get_mut(&crtc)) .and_then(|device| device.outputs.get_mut(&crtc))
.and_then(|output| match output.surface.frame_submitted() { .and_then(|output| {
Ok(feedback) => { #[cfg(feature = "drm-fault-injection")]
retired = feedback.is_some(); if output.frame_pending && recovery::inject("retirement") {
feedback 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"); 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) { if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) {
@@ -852,6 +875,7 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
.and_then(|device| device.outputs.get_mut(&crtc)) .and_then(|device| device.outputs.get_mut(&crtc))
&& retired && retired
{ {
output.completion.retired();
output.frame_pending = false; output.frame_pending = false;
output.power.presented(); output.power.presented();
if let DrmEventTime::Monotonic(timestamp) = metadata.time { if let DrmEventTime::Monotonic(timestamp) = metadata.time {
@@ -886,6 +910,11 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
state.refresh_idle_inhibition(); state.refresh_idle_inhibition();
} }
if retirement_failed {
recovery::reconcile_device(state, node);
return;
}
apply_output_power(state); apply_output_power(state);
state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence); state.record_drm_presentation(node, crtc, metadata.time, metadata.sequence);
@@ -1168,6 +1197,10 @@ pub(crate) fn system_resumed(state: &mut Ferese) {
lid::request_refresh(state, true); lid::request_refresh(state, true);
} }
pub(crate) fn reconcile_after_monitor_recovery(state: &mut Ferese) {
lid::request_reconcile(state);
}
fn internal_connector(interface: connector::Interface) -> bool { fn internal_connector(interface: connector::Interface) -> bool {
matches!( matches!(
interface, interface,
@@ -1268,8 +1301,9 @@ fn render_output(
primary.sync.wait()?; primary.sync.wait()?;
} }
if result.is_empty {
if output.mirror_source.is_some() { if output.mirror_source.is_some() {
capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output)?; capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output);
} else { } else {
capture::capture_output( capture::capture_output(
state, state,
@@ -1277,10 +1311,9 @@ fn render_output(
&mut output.capture_texture, &mut output.capture_texture,
&output.output, &output.output,
&frame, &frame,
)?; );
} }
if result.is_empty {
output output
.render_metrics .render_metrics
.record_no_damage(render_started.elapsed(), missed_deadlines); .record_no_damage(render_started.elapsed(), missed_deadlines);
@@ -1327,7 +1360,8 @@ fn render_output(
}; };
let visibility = result.states.clone(); let visibility = result.states.clone();
drop(result); drop(result);
output.surface.queue_frame(presentation)?;
capture::after_queue(output.surface.queue_frame(presentation), || {
if output.mirror_source.is_none() { if output.mirror_source.is_none() {
state.display_presentation.queued(&output.output, &visibility); state.display_presentation.queued(&output.output, &visibility);
} }
@@ -1336,7 +1370,25 @@ fn render_output(
.render_metrics .render_metrics
.record_frame(render_started.elapsed(), &damage, missed_deadlines, effects); .record_frame(render_started.elapsed(), &damage, missed_deadlines, effects);
output.frame_pending = true; 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(); 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) Ok(true)
})() })()
}; };
@@ -2042,6 +2094,8 @@ fn create_direct_output(
capture_texture: None, capture_texture: None,
render_metrics, render_metrics,
frame_pending: false, frame_pending: false,
completion: Default::default(),
completion_timer: None,
animation_fallback: AnimationFallback::default(), animation_fallback: AnimationFallback::default(),
cursor_visible: false, cursor_visible: false,
power: OutputPower::default(), power: OutputPower::default(),
+130 -8
View File
@@ -3,6 +3,7 @@ use smithay::backend::renderer::element::RenderElement;
use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture}; use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture};
use smithay::backend::renderer::{Bind, Offscreen, Texture}; use smithay::backend::renderer::{Bind, Offscreen, Texture};
use smithay::output::Output; use smithay::output::Output;
use std::fmt::Display;
use crate::Ferese; use crate::Ferese;
use crate::render::{capture_output_elements, redraw_output}; use crate::render::{capture_output_elements, redraw_output};
@@ -14,7 +15,7 @@ pub(crate) fn capture_output(
texture: &mut Option<GlesTexture>, texture: &mut Option<GlesTexture>,
output: &Output, output: &Output,
scene: &FrameScene, scene: &FrameScene,
) -> Result<(), GlesError> { ) {
for include_cursor in [true, false] { for include_cursor in [true, false] {
if !state.has_pending_screencopy(output, include_cursor) { if !state.has_pending_screencopy(output, include_cursor) {
continue; continue;
@@ -23,11 +24,13 @@ pub(crate) fn capture_output(
// Sample the same scene into an independent target: the displayed // Sample the same scene into an independent target: the displayed
// primary buffer may omit both a scanned-out client and hardware cursor. // primary buffer may omit both a scanned-out client and hardware cursor.
let elements = capture_output_elements(state, renderer, output, include_cursor, scene); 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); state.process_screencopies(renderer, &target, output, include_cursor);
} }
Ok(())
} }
pub(super) fn capture_mirror( pub(super) fn capture_mirror(
@@ -36,7 +39,7 @@ pub(super) fn capture_mirror(
source: &Output, source: &Output,
scene: &FrameScene, scene: &FrameScene,
output: &mut super::DirectOutput, output: &mut super::DirectOutput,
) -> Result<(), GlesError> { ) {
for include_cursor in [true, false] { for include_cursor in [true, false] {
if !state.has_pending_screencopy(&output.output, include_cursor) { if !state.has_pending_screencopy(&output.output, include_cursor) {
continue; continue;
@@ -44,17 +47,46 @@ pub(super) fn capture_mirror(
let elements = capture_output_elements(state, renderer, source, include_cursor, scene); let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
// Never sample mirror_texture: it contains the unfiltered display. // Never sample mirror_texture: it contains the unfiltered display.
let canvas = output.mirror_canvas.as_ref().expect("mirror display composed"); 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( let element = super::mirror::fitted_texture(
output.mirror_capture_texture.as_ref().unwrap().clone(), output.mirror_capture_texture.as_ref().unwrap().clone(),
&output.output, &output.output,
smithay::backend::renderer::element::Id::new(), smithay::backend::renderer::element::Id::new(),
Default::default(), 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); state.process_screencopies(renderer, &target, &output.output, include_cursor);
} }
Ok(()) }
fn capture_request<T, E: Display>(
state: &mut Ferese,
output: &Output,
include_cursor: bool,
render: impl FnOnce() -> Result<T, E>,
) -> Option<T> {
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<T, E>(queued: Result<T, E>, capture: impl FnOnce()) -> Result<T, E> {
let queued = queued?;
capture();
Ok(queued)
} }
pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>( pub(super) fn render_capture<'a, E: RenderElement<GlesRenderer>>(
@@ -178,3 +210,93 @@ mod tests {
assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into()); 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::<i32, Buffer>::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::<i32, Buffer>::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"
);
}
}
+41 -9
View File
@@ -8,6 +8,7 @@ pub(super) struct Probe {
serial: u64, serial: u64,
revision: u64, revision: u64,
reactivate: bool, reactivate: bool,
wake: bool,
} }
#[derive(Default)] #[derive(Default)]
@@ -33,12 +34,13 @@ impl LidState {
changed changed
} }
fn begin(&mut self, reactivate: bool) -> Probe { fn begin(&mut self, reactivate: bool, wake: bool) -> Probe {
self.serial = self.serial.wrapping_add(1); self.serial = self.serial.wrapping_add(1);
let probe = Probe { let probe = Probe {
serial: self.serial, serial: self.serial,
revision: self.revision, revision: self.revision,
reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate), reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate),
wake: wake || self.pending.is_some_and(|pending| pending.wake),
}; };
self.pending = Some(probe); self.pending = Some(probe);
probe probe
@@ -269,6 +271,15 @@ impl Reader {
} }
pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) { 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 { let Some(backend) = state.direct_backend.as_mut() else {
return; return;
}; };
@@ -289,7 +300,7 @@ pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
} }
backend.lid_reader.suspend_requested = false; backend.lid_reader.suspend_requested = false;
backend.lid_reader.lease.lock().unwrap().suspend = 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 sent = backend.lid_reader.requests.send(probe).is_ok();
let timer = state let timer = state
.loop_handle .loop_handle
@@ -329,10 +340,13 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
// Waking a locked session may request redraws. Hold them until the fresh // Waking a locked session may request redraws. Hold them until the fresh
// device/connector snapshot has been reconciled. // device/connector snapshot has been reconciled.
backend.lid_reader.suspend_requested = false; backend.lid_reader.suspend_requested = false;
if probe.wake {
backend.topology.reconciling = true; backend.topology.reconciling = true;
state.reset_animation_clock(); state.reset_animation_clock();
state.lock_input_activity(); state.lock_input_activity();
state.direct_backend.as_mut().unwrap().topology.reconciling = false; state.direct_backend.as_mut().unwrap().topology.reconciling = false;
}
reconcile_outputs(state, reactivate); reconcile_outputs(state, reactivate);
} }
@@ -431,7 +445,7 @@ mod tests {
fn newer_input_wins_over_a_delayed_resume_read() { fn newer_input_wins_over_a_delayed_resume_read() {
let mut lid = LidState::default(); let mut lid = LidState::default();
lid.observe(true); lid.observe(true);
let probe = lid.begin(true); let probe = lid.begin(true, true);
lid.observe(false); lid.observe(false);
assert_eq!(lid.complete(probe, Some(true)), Some(true)); assert_eq!(lid.complete(probe, Some(true)), Some(true));
assert!(!lid.closed()); assert!(!lid.closed());
@@ -440,11 +454,11 @@ mod tests {
#[test] #[test]
fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() { fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() {
let mut lid = LidState::default(); let mut lid = LidState::default();
let probe = lid.begin(false); let probe = lid.begin(false, true);
lid.complete(probe, None); lid.complete(probe, None);
assert_eq!(lid.value, None); assert_eq!(lid.value, None);
lid.observe(true); lid.observe(true);
let probe = lid.begin(true); let probe = lid.begin(true, true);
lid.complete(probe, None); lid.complete(probe, None);
assert_eq!(lid.value, Some(true)); assert_eq!(lid.value, Some(true));
} }
@@ -452,21 +466,39 @@ mod tests {
#[test] #[test]
fn pause_and_newer_resume_invalidate_old_replies() { fn pause_and_newer_resume_invalidate_old_replies() {
let mut lid = LidState::default(); let mut lid = LidState::default();
let old = lid.begin(true); let old = lid.begin(true, true);
lid.pause(); lid.pause();
assert_eq!(lid.complete(old, Some(true)), None); 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_eq!(lid.complete(old, Some(true)), None);
assert!(lid.pending()); assert!(lid.pending());
assert_eq!(lid.complete(current, Some(false)), Some(true)); assert_eq!(lid.complete(current, Some(false)), Some(true));
assert!(!lid.pending()); 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] #[test]
fn system_wake_cannot_cancel_pending_session_reactivation() { fn system_wake_cannot_cancel_pending_session_reactivation() {
let mut lid = LidState::default(); let mut lid = LidState::default();
lid.begin(true); lid.begin(true, true);
let probe = lid.begin(false); let probe = lid.begin(false, true);
assert_eq!(lid.complete(probe, Some(false)), Some(true)); assert_eq!(lid.complete(probe, Some(false)), Some(true));
} }
} }
+165
View File
@@ -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<Option<String>> = 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<Instant>,
}
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<Duration> {
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<T, E>(result: Result<Option<T>, E>) -> (Option<T>, bool, Option<E>) {
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"
);
}
}
+6 -1
View File
@@ -9,7 +9,12 @@ pub(super) fn cancel_output_timers(
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>, handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
output: &mut DirectOutput, output: &mut DirectOutput,
) { ) {
for token in [output.render_timer.take(), output.callback_timer.take()] output.completion.retired();
for token in [
output.render_timer.take(),
output.callback_timer.take(),
output.completion_timer.take(),
]
.into_iter() .into_iter()
.flatten() .flatten()
{ {
+4 -18
View File
@@ -1,7 +1,6 @@
//! Session-owned services. Never grant shell/effects privileges to daemons. //! Session-owned services. Never grant shell/effects privileges to daemons.
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::HashMap; use std::collections::HashMap;
use std::os::fd::{FromRawFd, OwnedFd};
use std::process::Child; use std::process::Child;
use std::rc::Rc; use std::rc::Rc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -13,6 +12,7 @@ use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken}
use crate::Ferese; use crate::Ferese;
use crate::config::DaemonConfig; use crate::config::DaemonConfig;
use crate::private_client::ClientCapabilities; use crate::private_client::ClientCapabilities;
use crate::process::{pidfd, spawn_client, terminate_child};
struct Service { struct Service {
config: DaemonConfig, config: DaemonConfig,
@@ -27,16 +27,6 @@ pub(crate) struct Runner(
Option<(RegistrationToken, Instant)>, Option<(RegistrationToken, Instant)>,
); );
fn pidfd(pid: u32) -> std::io::Result<OwnedFd> {
// 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 { impl Runner {
pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> { pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> {
let runner = Rc::new(RefCell::new(Self::new( 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. // TERM/grace/reaping must not pause the compositor's event loop.
for mut removed in previous { for mut removed in previous {
if let Some(mut child) = removed.child.take() { 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 { if !service.finished && now >= service.next_start {
service.child = crate::spawn_client( service.child = spawn_client(state, service.config.command.iter(), ClientCapabilities::default());
state,
service.config.command.iter().map(Into::into).collect(),
ClientCapabilities::default(),
);
service.next_start = now + Duration::from_secs(5); service.next_start = now + Duration::from_secs(5);
if service.child.is_none() && !service.config.restart { if service.child.is_none() && !service.config.restart {
service.finished = true; service.finished = true;
@@ -211,7 +197,7 @@ impl Runner {
pub fn stop(&mut self) { pub fn stop(&mut self) {
for service in &mut self.0 { for service in &mut self.0 {
if let Some(mut child) = service.child.take() { if let Some(mut child) = service.child.take() {
crate::terminate_child(&mut child); terminate_child(&mut child);
} }
service.finished = true; service.finished = true;
} }
+1 -1
View File
@@ -2,4 +2,4 @@ mod move_grab;
mod resize_grab; mod resize_grab;
pub use move_grab::MoveSurfaceGrab; pub use move_grab::MoveSurfaceGrab;
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab, handle_resize_commit}; pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab};
+55 -74
View File
@@ -1,5 +1,3 @@
use std::cell::RefCell;
use smithay::desktop::Window; use smithay::desktop::Window;
use smithay::input::pointer::{ use smithay::input::pointer::{
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent, AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
@@ -98,10 +96,6 @@ impl ResizeSurfaceGrab {
edges: ResizeEdge, edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>, initial_rect: Rectangle<i32, Logical>,
) -> Self { ) -> Self {
ResizeState::with(
window.toplevel().expect("managed window has a toplevel").wl_surface(),
|state| *state = ResizeState::Resizing { edges, initial_rect },
);
Self { Self {
start_data, start_data,
window, window,
@@ -128,8 +122,8 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
let initial = self.initial_rect; let initial = self.initial_rect;
let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. }; 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_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_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 + initial.size.h) as f64 + if self.edges.bottom() { delta.y } 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( let raw = ferese_layout::Rect::new(
raw_left, raw_left,
raw_top, raw_top,
@@ -203,12 +197,6 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
state.size = Some(self.last_size); state.size = Some(self.last_size);
}); });
surface.send_pending_configure(); 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<Ferese> for ResizeSurfaceGrab {
state.size = Some(self.initial_rect.size); state.size = Some(self.initial_rect.size);
}); });
surface.send_pending_configure(); 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); 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<i32, Logical>,
},
WaitingForFinalCommit {
edges: ResizeEdge,
initial_rect: Rectangle<i32, Logical>,
},
}
impl ResizeState {
fn with<T>(surface: &WlSurface, callback: impl FnOnce(&mut Self) -> T) -> T {
with_states(surface, |states| {
states.data_map.insert_if_missing(RefCell::<Self>::default);
callback(
&mut states
.data_map
.get::<RefCell<Self>>()
.expect("resize state was inserted")
.borrow_mut(),
)
})
}
fn commit(&mut self) -> Option<(ResizeEdge, Rectangle<i32, Logical>)> {
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( fn constrained_size(
initial: Size<i32, Logical>, initial: Size<i32, Logical>,
delta: Point<f64, Logical>, delta: Point<f64, Logical>,
@@ -368,21 +309,34 @@ fn constrained_size(
let mut width = initial.w; let mut width = initial.w;
let mut height = initial.h; let mut height = initial.h;
if edges.left() { if edges.left() {
width -= delta.x as i32; width = width.saturating_sub(delta.x as i32);
} else if edges.right() { } else if edges.right() {
width += delta.x as i32; width = width.saturating_add(delta.x as i32);
}
if edges.top() {
height -= delta.y as i32;
} else if edges.bottom() {
height += delta.y as i32;
} }
let maximum_width = if maximum.w == 0 { i32::MAX } else { maximum.w }; if edges.top() {
let maximum_height = if maximum.h == 0 { i32::MAX } else { maximum.h }; 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), width.clamp(minimum_width, maximum_width),
height.clamp(minimum.h.max(1), maximum_height), height.clamp(minimum_height, maximum_height),
) )
.into() .into()
} }
@@ -395,10 +349,11 @@ fn resized_rect(
let mut location = initial.loc; let mut location = initial.loc;
if edges.left() { 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() { 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) Rectangle::new(location, size)
@@ -469,6 +424,32 @@ mod tests {
assert_eq!(size, (1_024, 768).into()); 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] #[test]
fn resized_rect_keeps_the_opposite_edge_fixed() { fn resized_rect_keeps_the_opposite_edge_fixed() {
let initial = Rectangle::new((100, 80).into(), (800, 600).into()); let initial = Rectangle::new((100, 80).into(), (800, 600).into());
+244 -4
View File
@@ -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::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::utils::SERIAL_COUNTER; use smithay::utils::SERIAL_COUNTER;
@@ -9,6 +14,50 @@ use smithay::wayland::xdg_activation::{
use crate::Ferese; use crate::Ferese;
const TOKEN_MAX_AGE: Duration = Duration::from_secs(10); const TOKEN_MAX_AGE: Duration = Duration::from_secs(10);
const MAX_INTERACTIONS: usize = 64;
pub(crate) struct InputHistory<C = ClientId> {
events: VecDeque<(Serial, C, Instant)>,
}
impl<C> Default for InputHistory<C> {
fn default() -> Self {
Self {
events: VecDeque::new(),
}
}
}
impl<C: PartialEq> InputHistory<C> {
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<Instant> {
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<WlSurface>) {
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 { impl XdgActivationHandler for Ferese {
fn activation_state(&mut self) -> &mut XdgActivationState { 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 { 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::<Self>::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) { 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) { if self.session_lock.active() || self.input_capture.captures(1) {
return; 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::<ApprovedInteraction>()
.is_some_and(|proof| proof.0.elapsed() <= TOKEN_MAX_AGE);
if !valid { if !valid {
tracing::debug!("ignored stale or non-interactive activation request"); tracing::debug!("ignored stale or non-interactive activation request");
return; 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::<ApprovedInteraction>().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::<ApprovedInteraction>().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::<WlCompositor, (), Ferese>(&state.display_handle, 6, ())
.unwrap();
let shell = recipient
.create_resource::<XdgWmBase, XdgWmBaseUserData, Ferese>(&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);
}
}
+11 -8
View File
@@ -5,7 +5,7 @@ use smithay::reexports::wayland_server::protocol::wl_buffer;
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface; use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
use smithay::wayland::buffer::BufferHandler; use smithay::wayland::buffer::BufferHandler;
use smithay::wayland::compositor::{ 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::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
use smithay::wayland::shm::{ShmHandler, ShmState}; use smithay::wayland::shm::{ShmHandler, ShmState};
@@ -35,6 +35,14 @@ impl CompositorHandler for Ferese {
} }
fn commit(&mut self, surface: &WlSurface) { 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.update_surface_preferences(surface);
self.capture_resize_before_commit(surface); self.capture_resize_before_commit(surface);
self.capture_close_before_commit(surface); self.capture_close_before_commit(surface);
@@ -45,12 +53,7 @@ impl CompositorHandler for Ferese {
while let Some(parent) = get_parent(&root) { while let Some(parent) = get_parent(&root) {
root = parent; root = parent;
} }
let window = { let window = self.window_for_surface(&root);
self.space
.elements()
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root))
.cloned()
};
if let Some(window) = window { if let Some(window) = window {
window.on_commit(); window.on_commit();
@@ -76,7 +79,7 @@ impl CompositorHandler for Ferese {
self.refresh_idle_inhibition(); self.refresh_idle_inhibition();
layer_shell::handle_commit(self, surface); 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); crate::backends::direct::render_surface(self, surface);
} }
+7 -1
View File
@@ -1,4 +1,4 @@
mod activation; pub(crate) mod activation;
mod compositor; mod compositor;
mod input_method; mod input_method;
mod layer_shell; mod layer_shell;
@@ -60,6 +60,12 @@ impl SeatHandler for Ferese {
} }
fn focus_changed(&mut self, seat: &Seat<Self>, focused: Option<&WlSurface>) { fn focus_changed(&mut self, seat: &Seat<Self>, 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)) { if let Some(id) = focused.and_then(|surface| self.windows.id_for_surface(surface)) {
self.focus_history.record(id); self.focus_history.record(id);
} }
+26
View File
@@ -49,6 +49,7 @@ pub(crate) enum CaptureSink {
part: usize, part: usize,
complete: PartSender, complete: PartSender,
published: AtomicBool, published: AtomicBool,
permit: Option<super::screenshot::BudgetPermit>,
}, },
} }
@@ -59,6 +60,7 @@ impl CaptureSink {
part, part,
complete, complete,
published: AtomicBool::new(false), published: AtomicBool::new(false),
permit: None,
} }
} }
@@ -73,12 +75,14 @@ impl CaptureSink {
part, part,
complete, complete,
published, published,
permit,
} => { } => {
if !published.swap(true, Ordering::AcqRel) { if !published.swap(true, Ordering::AcqRel) {
let _ = complete.send(PartOutcome { let _ = complete.send(PartOutcome {
request: *request, request: *request,
part: *part, part: *part,
result: Err("Screenshot part failed".to_string()), result: Err("Screenshot part failed".to_string()),
permit: permit.clone(),
}); });
} }
} }
@@ -93,6 +97,7 @@ impl CaptureSink {
part, part,
complete, complete,
published, published,
permit,
} = self } = self
else { else {
return false; return false;
@@ -105,12 +110,20 @@ impl CaptureSink {
request: *request, request: *request,
part: *part, part: *part,
result: Ok(buffer), result: Ok(buffer),
permit: permit.clone(),
}) })
.is_ok() .is_ok()
} }
} }
impl PendingScreencopy { impl PendingScreencopy {
pub(crate) fn with_permit(mut self, permit: Option<super::screenshot::BudgetPermit>) -> Self {
if let CaptureSink::Owned { permit: stored, .. } = &mut self.sink {
*stored = permit;
}
self
}
pub(crate) fn owned( pub(crate) fn owned(
request: u64, request: u64,
part: usize, part: usize,
@@ -333,6 +346,19 @@ impl Ferese {
.iter() .iter()
.any(|capture| capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor)) .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 { fn cursor_overlay_matches(requested: bool, rendered: bool) -> bool {
+106 -5
View File
@@ -2,6 +2,10 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::mpsc::SyncSender as ReplySender; use std::sync::mpsc::SyncSender as ReplySender;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use ferese_ipc::Response; use ferese_ipc::Response;
@@ -315,6 +319,7 @@ pub(crate) struct PartOutcome {
pub(crate) request: u64, pub(crate) request: u64,
pub(crate) part: usize, pub(crate) part: usize,
pub(crate) result: Result<CaptureBuffer, String>, pub(crate) result: Result<CaptureBuffer, String>,
pub(crate) permit: Option<BudgetPermit>,
} }
// A calloop channel so publishing a part also wakes the event loop, whichever // A calloop channel so publishing a part also wakes the event loop, whichever
@@ -352,11 +357,16 @@ struct Request {
parts: Vec<(PartSpec, PartState)>, parts: Vec<(PartSpec, PartState)>,
received: usize, received: usize,
deadline: Instant, deadline: Instant,
permit: Option<BudgetPermit>,
} }
pub(crate) enum Action { pub(crate) enum Action {
None, None,
Encode { request: u64, frames: Vec<OutputFrame> }, Encode {
request: u64,
frames: Vec<OutputFrame>,
permit: BudgetPermit,
},
// The path was already sent to the caller; it must not be cleaned up. // The path was already sent to the caller; it must not be cleaned up.
Delivered(PathBuf), Delivered(PathBuf),
// The request is gone, so this file is ours to remove. // 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_OUTSTANDING: usize = 8;
const MAX_PENDING_BYTES: usize = 256 * 1024 * 1024; const MAX_PENDING_BYTES: usize = 256 * 1024 * 1024;
#[derive(Default, Debug)]
struct Budget {
used: AtomicUsize,
}
#[derive(Debug)]
struct Reservation {
budget: Arc<Budget>,
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<Reservation>,
}
impl Budget {
fn reserve(self: &Arc<Self>, bytes: usize) -> Result<BudgetPermit, String> {
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 // 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 // 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 // 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 { pub(crate) struct Coordinator {
requests: std::collections::HashMap<u64, Request>, requests: std::collections::HashMap<u64, Request>,
next_id: u64, next_id: u64,
budget: Arc<Budget>,
deadline_changed: Option<Box<dyn Fn(Option<Instant>)>>, deadline_changed: Option<Box<dyn Fn(Option<Instant>)>>,
notified_deadline: Option<Instant>, notified_deadline: Option<Instant>,
} }
@@ -432,6 +482,7 @@ impl Coordinator {
return Err("Screenshot region is too large".into()); return Err("Screenshot region is too large".into());
} }
let permit = self.budget.reserve(pending)?;
self.next_id = self.next_id.wrapping_add(1); self.next_id = self.next_id.wrapping_add(1);
let id = self.next_id; let id = self.next_id;
self.requests.insert( self.requests.insert(
@@ -444,12 +495,17 @@ impl Coordinator {
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(), parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
received: 0, received: 0,
deadline: now + REQUEST_TIMEOUT, deadline: now + REQUEST_TIMEOUT,
permit: Some(permit),
}, },
); );
self.notify_deadline_change(); self.notify_deadline_change();
Ok(id) Ok(id)
} }
pub(crate) fn permit(&self, request: u64) -> Option<BudgetPermit> {
self.requests.get(&request).and_then(|entry| entry.permit.clone())
}
// Used by the completion tests to assert that a request is still tracked. // Used by the completion tests to assert that a request is still tracked.
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn is_live(&self, request: u64) -> bool { pub(crate) fn is_live(&self, request: u64) -> bool {
@@ -513,7 +569,11 @@ impl Coordinator {
}) })
.collect(); .collect();
entry.state = RequestState::Encoding; 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<PathBuf, String>) -> Action { pub(crate) fn on_encoded(&mut self, request: u64, result: Result<PathBuf, String>) -> 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] #[test]
fn all_parts_ready_hands_frames_to_the_encoder() { fn all_parts_ready_hands_frames_to_the_encoder() {
let mut harness = Harness::new(2); let mut harness = Harness::new(2);
@@ -1055,7 +1152,7 @@ mod completion {
Action::None Action::None
)); ));
let action = harness.coordinator.on_part(id, 1, Ok(buffer(2, 2))); 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"); panic!("expected the request to move to encoding");
}; };
assert_eq!(request, id); assert_eq!(request, id);
@@ -1407,7 +1504,9 @@ mod completion {
// buffer that was handed off is never replaced. // buffer that was handed off is never replaced.
assert!(!sink.publish(buffer(64, 64))); 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!((request, part), (7, 1));
assert_eq!(result.unwrap().width, 2); assert_eq!(result.unwrap().width, 2);
assert!(receiver.try_recv().is_err(), "no second buffer was sent"); assert!(receiver.try_recv().is_err(), "no second buffer was sent");
@@ -1422,7 +1521,9 @@ mod completion {
// failure has already been reported. // failure has already been reported.
assert!(!sink.publish(buffer(2, 2))); 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_eq!((request, part), (3, 0));
assert!(result.is_err()); assert!(result.is_err());
assert!(receiver.try_recv().is_err()); assert!(receiver.try_recv().is_err());
+66 -22
View File
@@ -20,6 +20,7 @@ const FILE_MODE: u32 = 0o600;
pub(crate) struct Job { pub(crate) struct Job {
pub(crate) request: u64, pub(crate) request: u64,
pub(crate) frames: Vec<OutputFrame>, pub(crate) frames: Vec<OutputFrame>,
pub(crate) _permit: Option<super::screenshot::BudgetPermit>,
} }
pub(crate) struct Encoded { pub(crate) struct Encoded {
@@ -54,17 +55,13 @@ impl Worker {
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) { fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
while let Ok(job) = receiver.recv() { while let Ok(job) = receiver.recv() {
let result = encode(&job); let result = encode(&job);
let request = job.request;
drop(job);
// This send cannot block. Each request produces exactly one Encode, so at // 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, // most QUEUE_CAPACITY jobs can be queued while one more is being encoded,
// and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here // and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here
// would stall the worker with no way for the loop to recover it. // would stall the worker with no way for the loop to recover it.
if results if results.send(Encoded { request, result }).is_err() {
.send(Encoded {
request: job.request,
result,
})
.is_err()
{
return; return;
} }
} }
@@ -131,25 +128,51 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
// The client is told to open this path and unlink it immediately, so the // 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. // directory is ours, private, and files are created with the final mode.
fn write_private_file(png: &[u8]) -> Result<PathBuf, String> { struct StagedFile {
let directory = private_directory()?; path: PathBuf,
for attempt in 0..64u32 { keep: bool,
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);
} }
impl Drop for StagedFile {
fn drop(&mut self) {
if !self.keep {
let _ = fs::remove_file(&self.path);
}
}
}
fn write_private_file(png: &[u8]) -> Result<PathBuf, String> {
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<PathBuf, String> {
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) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(format!("Could not write screenshot: {error}")), 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<PathBuf, String> { fn private_directory() -> Result<PathBuf, String> {
@@ -209,6 +232,7 @@ mod tests {
let job = Job { let job = Job {
request: 1, request: 1,
frames: vec![frame(4, 3)], frames: vec![frame(4, 3)],
_permit: None,
}; };
let mut canvas = compose(&job.frames).unwrap(); let mut canvas = compose(&job.frames).unwrap();
for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() { 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); 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] #[test]
fn staged_files_are_owner_only() { fn staged_files_are_owner_only() {
let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec(); let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec();
+27 -29
View File
@@ -1,6 +1,6 @@
use smithay::desktop::{ use smithay::desktop::{
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window, WindowSurfaceType, PopupKeyboardGrab, PopupKind, PopupPointerGrab, Window, WindowSurfaceType, find_popup_root_surface,
find_popup_root_surface, get_popup_toplevel_coords, get_popup_toplevel_coords,
}; };
use smithay::input::Seat; use smithay::input::Seat;
use smithay::input::pointer::{Focus, GrabStartData}; use smithay::input::pointer::{Focus, GrabStartData};
@@ -17,7 +17,7 @@ use smithay::wayland::shell::xdg::{
}; };
use crate::Ferese; use crate::Ferese;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit}; use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab};
impl XdgShellHandler for Ferese { impl XdgShellHandler for Ferese {
fn xdg_shell_state(&mut self) -> &mut XdgShellState { fn xdg_shell_state(&mut self) -> &mut XdgShellState {
@@ -82,19 +82,7 @@ impl XdgShellHandler for Ferese {
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) { fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
// Hidden workspace windows are managed but unmapped from Space. // Hidden workspace windows are managed but unmapped from Space.
let Some(window) = self let Some(window) = self.window_for_surface(surface.wl_surface()) else {
.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 {
return; return;
}; };
@@ -330,17 +318,27 @@ pub(crate) fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool {
}) })
} }
pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surface: &WlSurface) { pub(crate) fn validate_size_constraints(toplevel: &ToplevelSurface) -> bool {
handle_resize_commit(surface); use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
if let Some(window) = space use smithay::wayland::shell::xdg::SurfaceCachedState;
.elements()
.find(|window| { let valid = with_states(toplevel.wl_surface(), |states| {
window let mut cached = states.cached_state.get::<SurfaceCachedState>();
.toplevel() let state = cached.current();
.is_some_and(|toplevel| toplevel.wl_surface() == surface) 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)
.cloned() });
{ 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| { let configured = with_states(surface, |states| {
states states
.data_map .data_map
@@ -358,8 +356,8 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surfa
} }
} }
popups.commit(surface); state.popups.commit(surface);
if let Some(PopupKind::Xdg(popup)) = popups.find_popup(surface) if let Some(PopupKind::Xdg(popup)) = state.popups.find_popup(surface)
&& !popup.is_initial_configure_sent() && !popup.is_initial_configure_sent()
{ {
popup.send_configure().expect("initial popup configure is valid"); popup.send_configure().expect("initial popup configure is valid");
+70 -17
View File
@@ -1,5 +1,3 @@
use std::process::Command;
use ferese_layout::Direction; use ferese_layout::Direction;
use smithay::backend::input::{ use smithay::backend::input::{
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, GestureEndEvent, AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, GestureEndEvent,
@@ -22,6 +20,7 @@ use crate::Ferese;
use crate::config::BindingAction; use crate::config::BindingAction;
use crate::gestures::SwipeDirection; use crate::gestures::SwipeDirection;
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab}; use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab};
use crate::process::spawn_client;
const BTN_LEFT: u32 = 0x110; const BTN_LEFT: u32 = 0x110;
const BTN_RIGHT: u32 = 0x111; const BTN_RIGHT: u32 = 0x111;
@@ -278,11 +277,16 @@ impl Ferese {
keyboard.set_focus(self, None, SERIAL_COUNTER.next_serial()); 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::<smithay::desktop::PopupKeyboardGrab<Self>>())
.unwrap_or(true);
let intercepted = keyboard.input::<(), _>(
self, self,
keycode, keycode,
state, state,
SERIAL_COUNTER.next_serial(), serial,
event.time_msec(), event.time_msec(),
|data, modifiers, keysym| { |data, modifiers, keysym| {
if data.focus_cycle.is_some() { if data.focus_cycle.is_some() {
@@ -423,6 +427,10 @@ impl Ferese {
FilterResult::Intercept(()) 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() { if was_captured && !self.input_capture.active() {
self.restore_input_capture_focus(); self.restore_input_capture_focus();
} }
@@ -534,6 +542,10 @@ impl Ferese {
if self.overview.is_active() { if self.overview.is_active() {
let position = pointer.current_location(); let position = pointer.current_location();
if self.layer_under(position).is_some() { if self.layer_under(position).is_some() {
if event.state() == ButtonState::Pressed {
self.record_activation_input(serial, pointer.current_focus());
}
pointer.button( pointer.button(
self, self,
&ButtonEvent { &ButtonEvent {
@@ -584,6 +596,16 @@ impl Ferese {
self.focus_window_at(pointer.current_location(), serial, true); self.focus_window_at(pointer.current_location(), serial, true);
} }
let client_grab = pointer
.with_grab(|_, grab| {
grab.is::<smithay::desktop::PopupPointerGrab<Self>>()
|| grab.is::<smithay::input::pointer::ClickGrab<Self>>()
})
.unwrap_or(true);
if event.state() == ButtonState::Pressed && client_grab {
self.record_activation_input(serial, pointer.current_focus());
}
pointer.button( pointer.button(
self, self,
&ButtonEvent { &ButtonEvent {
@@ -654,6 +676,10 @@ impl Ferese {
let touch = self.seat.get_touch().expect("seat has touch capability"); let touch = self.seat.get_touch().expect("seat has touch capability");
let serial = SERIAL_COUNTER.next_serial(); let serial = SERIAL_COUNTER.next_serial();
if !touch.is_grabbed() {
self.record_activation_input(serial, self.surface_under(location).map(|(surface, _)| surface));
}
touch.down( touch.down(
self, self,
self.surface_under(location), self.surface_under(location),
@@ -1042,20 +1068,10 @@ impl Ferese {
match action { match action {
BindingAction::None => {} BindingAction::None => {}
BindingAction::Spawn(argv) => { BindingAction::Spawn(argv) => {
let (program, args) = argv.split_first().expect("binding commands are validated"); if let Some(child) =
spawn_client(self, argv.iter(), crate::private_client::ClientCapabilities::default())
match Command::new(program)
.args(args)
.env("WAYLAND_DISPLAY", &self.socket_name)
.env_remove("WAYLAND_SOCKET")
.spawn()
{ {
Ok(child) => { crate::process::watch_client_exit(&self.loop_handle, child);
tracing::info!(%program, pid = child.id(), "spawned binding command")
}
Err(error) => {
tracing::warn!(%program, %error, "failed to spawn binding command")
}
} }
} }
BindingAction::Close => self.close_focused_window(), BindingAction::Close => self.close_focused_window(),
@@ -1166,6 +1182,43 @@ fn overview_escape(symbol: u32, overview_active: bool) -> bool {
mod tests { mod tests {
use super::{emergency_shortcut_escape, overview_escape, virtual_terminal}; 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<i32> = 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] #[test]
fn escape_closes_only_an_active_overview() { fn escape_closes_only_an_active_overview() {
assert!(overview_escape(keysyms::KEY_Escape, true)); assert!(overview_escape(keysyms::KEY_Escape, true));
+29 -9
View File
@@ -908,14 +908,16 @@ impl Ferese {
for (position, part) in planned.iter().enumerate() { for (position, part) in planned.iter().enumerate() {
let (output, _) = &targets[part.index]; let (output, _) = &targets[part.index];
self.pending_screencopies self.pending_screencopies.push(
.push(crate::handlers::screencopy::PendingScreencopy::owned( crate::handlers::screencopy::PendingScreencopy::owned(
id, id,
position, position,
parts.clone(), parts.clone(),
output.clone(), output.clone(),
part.buffer, part.buffer,
)); )
.with_permit(self.screenshot.permit(id)),
);
} }
// The nested backend redraws on its refresh timer; this drives the // The nested backend redraws on its refresh timer; this drives the
@@ -1006,11 +1008,20 @@ impl Ferese {
request: capture, request: capture,
part: 0, part: 0,
result, result,
permit: self.screenshot.permit(capture),
}); });
} }
pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) { 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); self.apply_screenshot_action(action);
} }
@@ -1022,11 +1033,20 @@ impl Ferese {
fn apply_screenshot_action(&mut self, action: Action) { fn apply_screenshot_action(&mut self, action: Action) {
match action { match action {
Action::None => {} Action::None => {}
Action::Encode { request, frames } => { Action::Encode {
let submitted = self request,
.screenshot_worker frames,
.as_ref() permit,
.is_some_and(|worker| worker.submit(Job { request, frames }).is_ok()); } => {
let submitted = self.screenshot_worker.as_ref().is_some_and(|worker| {
worker
.submit(Job {
request,
frames,
_permit: Some(permit),
})
.is_ok()
});
if !submitted { if !submitted {
self.screenshot.reject(request, "Screenshot encoding queue is full"); self.screenshot.reject(request, "Screenshot encoding queue is full");
} }
+71 -177
View File
@@ -1,5 +1,3 @@
#![allow(irrefutable_let_patterns)]
mod backends; mod backends;
mod config; mod config;
mod cursor; mod cursor;
@@ -26,6 +24,7 @@ mod portal_shortcuts;
mod presentation; mod presentation;
mod presentation_policy; mod presentation_policy;
mod private_client; mod private_client;
mod process;
mod reload; mod reload;
mod render; mod render;
#[cfg(feature = "resize-metrics")] #[cfg(feature = "resize-metrics")]
@@ -35,6 +34,8 @@ mod resume;
mod session_lock; mod session_lock;
mod shell_control; mod shell_control;
mod stacking; mod stacking;
#[cfg(test)]
mod startup_tests;
mod state; mod state;
mod theme; mod theme;
mod wallpaper; mod wallpaper;
@@ -42,11 +43,10 @@ mod window_rules;
mod winit; mod winit;
use std::error::Error; use std::error::Error;
use std::process::{Child, Command}; use std::io;
use std::time::Duration;
use std::{io, thread};
use calloop::signals::{Signal, Signals}; use calloop::signals::{Signal, Signals};
use process::{spawn_client, terminate_child, watch_client_exit};
use smithay::reexports::calloop::EventLoop; use smithay::reexports::calloop::EventLoop;
use smithay::reexports::wayland_server::Display; use smithay::reexports::wayland_server::Display;
pub use state::{Ferese, RuntimeConfig}; pub use state::{Ferese, RuntimeConfig};
@@ -64,13 +64,8 @@ fn main() -> Result<(), Box<dyn Error>> {
if args.len() != 2 { if args.len() != 2 {
return Err("usage: ferese --check-config PATH".into()); return Err("usage: ferese --check-config PATH".into());
} }
let source = std::fs::read_to_string(&args[1])?;
theme::prepare( check_config(std::path::Path::new(&args[1]))?;
&source,
std::path::Path::new(&args[1])
.parent()
.unwrap_or(std::path::Path::new(".")),
)?;
println!("Configuration is valid"); println!("Configuration is valid");
return Ok(()); return Ok(());
} }
@@ -81,11 +76,10 @@ fn main() -> Result<(), Box<dyn Error>> {
.filter(|path| path.exists()) .filter(|path| path.exists())
.map(|path| theme::read_source(&path)) .map(|path| theme::read_source(&path))
.transpose()?; .transpose()?;
let source = initial_source.as_deref().unwrap_or("");
let directory = config::config_path() let directory = config::config_path()
.and_then(|path| path.parent().map(ToOwned::to_owned)) .and_then(|path| path.parent().map(ToOwned::to_owned))
.unwrap_or_else(|| std::path::PathBuf::from(".")); .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 mut event_loop = EventLoop::try_new()?;
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
event_loop event_loop
@@ -109,56 +103,18 @@ fn main() -> Result<(), Box<dyn Error>> {
value value
}); });
theme::init(&mut event_loop, &mut state, candidate)?; 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)?; idle_inhibition::media::init(&mut event_loop)?;
overview::init_font_loader(&mut event_loop, &mut state)?; overview::init_font_loader(&mut event_loop, &mut state)?;
backends::init(launch.backend, &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"); 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); let runner = daemon::Runner::start(&mut state);
state.daemons = Some(runner.clone()); state.daemons = Some(runner.clone());
let reload_handle = event_loop.handle(); let result = event_loop.run(None, &mut state, after_dispatch);
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");
}
});
if let Some(backend) = state.direct_backend.as_ref() { if let Some(backend) = state.direct_backend.as_ref() {
backend.dump_scheduling_metrics(); backend.dump_scheduling_metrics();
@@ -168,8 +124,10 @@ fn main() -> Result<(), Box<dyn Error>> {
state.resize_metrics.dump(); state.resize_metrics.dump();
runner.borrow_mut().stop(); runner.borrow_mut().stop();
if let Some(child) = &mut child { if let Some(child) = child
terminate_child(child); && let Some(mut child) = child.borrow_mut().take()
{
terminate_child(&mut child);
} }
result.map_err(Into::into) result.map_err(Into::into)
@@ -181,130 +139,66 @@ fn init_logging() {
tracing_subscriber::fmt().with_env_filter(filter).init(); tracing_subscriber::fmt().with_env_filter(filter).init();
} }
fn spawn_client( fn check_config(path: &std::path::Path) -> Result<(), String> {
state: &mut Ferese, let source = theme::read_source(path)?;
mut args: Vec<std::ffi::OsString>, theme::prepare(&source, path.parent().unwrap_or(std::path::Path::new(".")))?;
capabilities: private_client::ClientCapabilities, Ok(())
) -> Option<Child> {
if args.is_empty() {
info!("no client requested; pass one after `--`, for example `-- foot`");
return None;
}
let program = args.remove(0);
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
} 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
} }
fn terminate_child(child: &mut Child) { #[derive(Debug)]
if child.try_wait().ok().flatten().is_some() { enum RedrawRequest<'a> {
return; Global,
Cursor,
Outputs(&'a [smithay::output::Output]),
} }
// SAFETY: the process ID comes from the live Child owned by Ferese. fn after_dispatch(state: &mut Ferese) {
unsafe { after_dispatch_with_redraw(state, |state, request| match request {
libc::kill(child.id() as i32, libc::SIGTERM); RedrawRequest::Global => backends::direct::render_all(state),
} RedrawRequest::Cursor => backends::direct::render_cursor(state),
RedrawRequest::Outputs(outputs) => backends::direct::render_on(state, outputs),
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::<bool>::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 after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) {
fn wayland_roundtrip_completes_without_a_rendered_frame() { state.poll_theme();
let mut display = Display::<()>::new().unwrap(); // The decoder wakes the loop after publishing its result, including
let (server, mut client) = UnixStream::pair().unwrap(); // when no output has a pending frame.
display let wallpaper_changed = state.wallpaper.poll();
.handle() // All input/Wayland callbacks have returned, releasing seat locks.
.insert_client(server, Arc::new(state::ClientState::default())) if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) {
.unwrap(); state.cancel_focus_cycle();
// 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(); if state.input_capture.restore_focus {
client.set_read_timeout(Some(Duration::from_secs(1))).unwrap(); state.restore_input_capture_focus();
let mut reply = [0u8; 12]; }
client.read_exact(&mut reply).unwrap();
assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2); state.apply_unlocked_pointer_hint();
assert_eq!(u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff, 0); 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 {
if cursor_changed {
redraw(state, RedrawRequest::Cursor);
}
if !output_redraws.is_empty() {
redraw(state, RedrawRequest::Outputs(&output_redraws));
}
}
if let Err(error) = state
.wallpaper
.arm_retry_timer(&state.loop_handle, std::time::Instant::now())
{
warn!(%error, "could not schedule wallpaper upload retry");
}
// 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");
} }
} }
+172
View File
@@ -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<OsStr>) -> 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<S: AsRef<OsStr>>(
state: &mut Ferese,
args: impl IntoIterator<Item = S>,
capabilities: ClientCapabilities,
) -> Option<Child> {
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<OwnedFd> {
// 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<Option<Child>>) -> 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<RefCell<Option<Child>>> {
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();
}
+235 -35
View File
@@ -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::error::Error;
use std::sync::{Arc, Condvar, Mutex};
use std::task::{Poll, Waker};
use std::thread::JoinHandle;
use std::time::Duration; use std::time::Duration;
use calloop::EventLoop;
use calloop::channel::{Event, channel}; use calloop::channel::{Event, channel};
use calloop::{EventLoop, LoopHandle, RegistrationToken};
use futures_lite::{StreamExt, future};
use crate::Ferese; use crate::Ferese;
pub(crate) fn init(event_loop: &mut EventLoop<Ferese>) -> Result<(), Box<dyn Error>> { const RETRY_DELAY: Duration = Duration::from_secs(5);
let (sender, receiver) = channel();
event_loop.handle().insert_source(receiver, |event, _, state| { #[derive(Clone, Copy, Debug, PartialEq, Eq)]
if let Event::Msg(()) = event { enum Refresh {
crate::backends::direct::system_resumed(state); Resumed,
Reconcile,
}
#[derive(Debug, PartialEq, Eq)]
enum MonitorExit {
Stop,
Reconnect,
}
#[derive(Default)]
struct StopState {
requested: bool,
waker: Option<Waker>,
}
#[derive(Default)]
struct Cancellation {
state: Mutex<StopState>,
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<Cancellation>,
worker: Option<JoinHandle<()>>,
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(); state.refresh_theme();
} }
pub(crate) fn init(event_loop: &mut EventLoop<'static, Ferese>) -> Result<Monitor, Box<dyn Error>> {
let (sender, receiver) = channel();
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<zbus::connection::Builder<'static>> + Send + 'static,
retry_delay: Duration,
mut send: impl FnMut(Refresh) -> bool + Send + 'static,
) -> std::io::Result<Monitor> {
let stop = Arc::new(Cancellation::default());
let worker_stop = stop.clone();
let worker = std::thread::Builder::new()
.name("ferese-system-resume".into()) .name("ferese-system-resume".into())
.spawn(move || { .spawn(move || {
let mut warned = false; let mut recovery = Recovery::default();
loop { loop {
let monitor = || -> zbus::Result<bool> { let result = future::block_on(future::race(
let connection = zbus::blocking::connection::Builder::system()? async {
.method_timeout(Duration::from_secs(2)) worker_stop.cancelled().await;
.build()?; Ok(MonitorExit::Stop)
let proxy = zbus::blocking::Proxy::new( },
&connection, 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");
}
}
Err(error) => {
if recovery.unavailable() {
tracing::warn!(%error, "system resume monitor unavailable; retrying connection");
}
}
}
if !worker_stop.retry(retry_delay) {
break;
}
}
})?;
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<MonitorExit> {
let proxy = zbus::Proxy::new(
connection,
"org.freedesktop.login1", "org.freedesktop.login1",
"/org/freedesktop/login1", "/org/freedesktop/login1",
"org.freedesktop.login1.Manager", "org.freedesktop.login1.Manager",
)?; )
for message in proxy.receive_signal("PrepareForSleep")? { .await?;
let (sleeping,): (bool,) = message.body().deserialize()?; let mut owners = proxy.receive_owner_changed().await?;
if !sleeping && sender.send(()).is_err() { let mut signals = proxy.receive_signal("PrepareForSleep").await?;
return Ok(false); // AddMatch succeeds even when logind is absent. Only call the listener
} // recovered once the service is present, and retry if its owner changes.
} connection
Ok(true) .call_method(
}; Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
Some("org.freedesktop.DBus"),
"GetNameOwner",
&("org.freedesktop.login1",),
)
.await?;
match monitor() { // Subscribe first: a resume during reconciliation must remain queued.
Ok(false) => break, if recovery.subscribed() {
Ok(true) => warned = false, tracing::debug!("system resume monitoring recovered; reconciling current state");
Err(error) if !warned => { if !send(Refresh::Reconcile) {
tracing::warn!(%error, "system resume monitor unavailable; retrying connection"); return Ok(MonitorExit::Stop);
warned = true;
} }
Err(_) => {}
} }
// Recovery only: a healthy listener blocks on DBus signals.
std::thread::sleep(Duration::from_secs(5)); 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(())
} }
Ok(MonitorExit::Reconnect)
},
)
.await
}
#[cfg(test)]
mod tests;
+249
View File
@@ -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<Refresh>) {
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);
}
+51 -358
View File
@@ -111,7 +111,7 @@ pub(crate) struct Lock {
pub(crate) idle_opacity: f32, pub(crate) idle_opacity: f32,
pub(crate) sleeping: bool, pub(crate) sleeping: bool,
pub(crate) idle_overlays: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>, pub(crate) idle_overlays: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
idle_timer: Option<RegistrationToken>, idle_timer: Option<(RegistrationToken, Instant)>,
} }
impl Lock { impl Lock {
@@ -130,6 +130,8 @@ impl Lock {
pub(crate) fn output_removed(&mut self, output: &Output) { pub(crate) fn output_removed(&mut self, output: &Output) {
self.presented.remove(output); self.presented.remove(output);
self.idle_overlays.remove(output); self.idle_overlays.remove(output);
self.surfaces.remove(output);
self.backgrounds.remove(output);
} }
fn activity(&mut self, now: Instant) -> bool { fn activity(&mut self, now: Instant) -> bool {
@@ -167,40 +169,64 @@ impl Ferese {
crate::backends::direct::wake_locked_outputs(self); crate::backends::direct::wake_locked_outputs(self);
self.cursor_redraw_pending = true; self.cursor_redraw_pending = true;
} }
// 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(); self.rearm_lock_idle();
} }
}
pub(crate) fn refresh_lock_idle_policy(&mut self) { pub(crate) fn refresh_lock_idle_policy(&mut self) {
self.update_lock_idle(Instant::now()); self.update_lock_idle(Instant::now());
self.rearm_lock_idle(); self.rearm_lock_idle();
} }
fn rearm_lock_idle(&mut self) { fn lock_idle_deadline(&self, now: Instant) -> Option<Instant> {
if let Some(token) = self.session_lock.idle_timer.take() {
self.loop_handle.remove(token);
}
if !self.lock_outputs_protected() { if !self.lock_outputs_protected() {
return; return None;
} }
let elapsed = self let elapsed = self
.session_lock .session_lock
.idle_since .idle_since
.map_or(Duration::ZERO, |since| since.elapsed()); .map_or(Duration::ZERO, |since| now.saturating_duration_since(since));
let Some(delay) = self self.lock_idle
.lock_idle
.next_deadline(elapsed, self.animation_duration(Duration::from_millis(500))) .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; return;
}; };
match self match self
.loop_handle .loop_handle
.insert_source(Timer::from_duration(delay), |_, _, state| { .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; state.session_lock.idle_timer = None;
state.refresh_lock_idle_policy();
TimeoutAction::Drop 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"), Err(error) => tracing::warn!(%error, "could not arm lock idle deadline"),
} }
} }
@@ -251,8 +277,17 @@ impl Ferese {
let surface = self let surface = self
.focused_output() .focused_output()
.and_then(|output| self.session_lock.surfaces.get(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()) .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()); .map(|surface| surface.wl_surface().clone());
self.seat self.seat
.get_keyboard() .get_keyboard()
@@ -378,7 +413,7 @@ impl SessionLockHandler for Ferese {
} }
fn unlock(&mut self) { 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); self.loop_handle.remove(timer);
} }
let sleeping = self.session_lock.sleeping; 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); smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSurfaceV1: smithay::wayland::session_lock::ExtLockSurfaceUserData] => SessionLockManagerState);
#[cfg(test)] #[cfg(test)]
mod tests { 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::<ExtSessionLockManagerV1, (), Ferese>(&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()
);
}
}
+628
View File
@@ -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<Self> {
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::<ExtSessionLockManagerV1, (), Ferese>(&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::<Ferese>(&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::<WlCompositor, (), Ferese>(&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();
}
+253
View File
@@ -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::<WlCompositor, (), Ferese>(&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::<WlSurface>(&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);
}
+4
View File
@@ -7,6 +7,8 @@ mod layout;
mod navigation; mod navigation;
mod outputs; mod outputs;
mod reconciliation; mod reconciliation;
#[cfg(test)]
mod window_lifecycle_tests;
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition}; pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
mod prediction; mod prediction;
mod presentation_dependencies; mod presentation_dependencies;
@@ -478,6 +480,7 @@ pub struct Ferese {
pub direct_backend: Option<DirectBackendState>, pub direct_backend: Option<DirectBackendState>,
_ipc_socket: Option<IpcSocketGuard>, _ipc_socket: Option<IpcSocketGuard>,
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>, pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
pub(crate) activation_inputs: crate::handlers::activation::InputHistory,
pub(crate) pending_screencopies: Vec<PendingScreencopy>, pub(crate) pending_screencopies: Vec<PendingScreencopy>,
// Screenshot requests outlive the readback that filled them: the // Screenshot requests outlive the readback that filled them: the
// coordinator owns them until encoding finishes and the caller is // coordinator owns them until encoding finishes and the caller is
@@ -721,6 +724,7 @@ impl Ferese {
direct_backend: None, direct_backend: None,
_ipc_socket: None, _ipc_socket: None,
pending_dmabuf_imports: Vec::new(), pending_dmabuf_imports: Vec::new(),
activation_inputs: Default::default(),
pending_screencopies: Vec::new(), pending_screencopies: Vec::new(),
screenshot: Coordinator::new(), screenshot: Coordinator::new(),
screenshot_parts: None, screenshot_parts: None,
+1 -1
View File
@@ -138,4 +138,4 @@ impl Ferese {
#[cfg(test)] #[cfg(test)]
#[path = "capture_privacy_tests.rs"] #[path = "capture_privacy_tests.rs"]
mod tests; pub(super) mod tests;
@@ -22,7 +22,7 @@ use smithay::wayland::shell::xdg::{XdgShellHandler, XdgToplevelSurfaceData, XdgW
use crate::Ferese; use crate::Ferese;
use crate::state::{ClientState, DesktopOutput}; use crate::state::{ClientState, DesktopOutput};
fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Option<RawFd>) { pub(crate) fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Option<RawFd>) {
let bytes = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode] let bytes = [object, (((args.len() + 2) * 4) as u32) << 16 | opcode]
.into_iter() .into_iter()
.chain(args.iter().copied()) .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(); events.dispatch(Duration::from_millis(1), state).unwrap();
state.display_handle.flush_clients().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(); wire.set_nonblocking(true).unwrap();
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let _ = wire.read_to_end(&mut bytes); let _ = wire.read_to_end(&mut bytes);
@@ -79,7 +79,7 @@ fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
serial.unwrap() 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 (server, mut wire) = UnixStream::pair().unwrap();
let client = state let client = state
.display_handle .display_handle
@@ -208,7 +208,7 @@ fn pixels(
Rectangle::from_size((320, 240).into()), Rectangle::from_size((320, 240).into()),
)); ));
let mut target = Some(texture.clone()); 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; let mut result = receiver.try_recv().unwrap().result.unwrap().pixels;
for pixel in result.chunks_exact_mut(4) { for pixel in result.chunks_exact_mut(4) {
pixel.swap(0, 2); pixel.swap(0, 2);
@@ -424,8 +424,7 @@ fn capture_privacy_pixels_and_policy_transitions() {
&mut Some(capture_texture.clone()), &mut Some(capture_texture.clone()),
&output, &output,
&scene, &scene,
) );
.unwrap();
let region = receiver.try_recv().unwrap().result.unwrap(); let region = receiver.try_recv().unwrap().result.unwrap();
assert_eq!((region.width, region.height), (160, 120)); assert_eq!((region.width, region.height), (160, 120));
assert!( assert!(
+4 -3
View File
@@ -246,12 +246,14 @@ impl Ferese {
let is_maximized = let is_maximized =
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id); 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) { let rect = if workspace_fullscreen == Some(*id) {
fullscreen_bounds 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) maximized_rect(bounds, self.gap_config.outer)
} else { } else {
match self.workspaces.placement(*id) { match placement {
Some(WindowPlacement::Tiled) => { Some(WindowPlacement::Tiled) => {
let Some(rect) = layout.geometry.get(id).copied() else { let Some(rect) = layout.geometry.get(id).copied() else {
continue; continue;
@@ -271,7 +273,6 @@ impl Ferese {
}; };
let is_fullscreen = workspace_fullscreen == Some(*id); let is_fullscreen = workspace_fullscreen == Some(*id);
let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. }));
visible.insert(*id); visible.insert(*id);
let scrolling = if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) { let scrolling = if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) {
-2
View File
@@ -111,8 +111,6 @@ impl Ferese {
true true
} }
}); });
self.session_lock.surfaces.remove(output);
self.session_lock.backgrounds.remove(output);
self.session_lock.output_removed(output); self.session_lock.output_removed(output);
} }
@@ -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::<WlShm, (), Ferese>(&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));
}
+18 -11
View File
@@ -1,6 +1,20 @@
use super::*; use super::*;
impl Ferese { impl Ferese {
pub(crate) fn window_for_surface(&self, surface: &WlSurface) -> Option<Window> {
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) { pub(crate) fn restore_initial_floating_size(&mut self, window: &Window) {
let Some(toplevel) = window.toplevel() else { return }; let Some(toplevel) = window.toplevel() else { return };
let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { 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) { pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) {
let Some((window, id)) = self let Some(id) = self.windows.id_for_surface(surface) else {
.windows return;
.ids() };
.iter() let Some(window) = self.windows.window(id).cloned() else {
.find(|(window, _)| {
window
.toplevel()
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
})
.map(|(window, id)| (window.clone(), *id))
else {
return; return;
}; };
+4 -5
View File
@@ -97,7 +97,7 @@ static SYSTEM_APPEARANCE: std::sync::OnceLock<Mutex<Option<ferese_config::theme:
fn auto_context() -> ferese_config::theme::AutoContext { fn auto_context() -> ferese_config::theme::AutoContext {
let appearance = SYSTEM_APPEARANCE.get_or_init(|| { 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"]) .args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output() .output()
.ok() .ok()
@@ -323,7 +323,7 @@ pub(crate) fn init(
.spawn(move || { .spawn(move || {
use std::io::BufRead; use std::io::BufRead;
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
let mut command = std::process::Command::new("gsettings"); let mut command = crate::process::command("gsettings");
unsafe { unsafe {
command.pre_exec(|| { command.pre_exec(|| {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
@@ -338,7 +338,7 @@ pub(crate) fn init(
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.spawn(); .spawn();
if let Ok(mut monitor) = monitor { 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"]) .args(["get", "org.gnome.desktop.interface", "color-scheme"])
.output() .output()
{ {
@@ -430,8 +430,7 @@ impl Ferese {
self.poll_theme(); self.poll_theme();
return; return;
} }
let wallpaper = (self.wallpaper.configuration() != &wallpaper) let wallpaper = (self.wallpaper.configuration() != &wallpaper).then(|| self.wallpaper.replacement(wallpaper));
.then(|| WallpaperState::with_wakeup(wallpaper, Some(self.loop_signal.clone())));
self.theme_engine.pending = Some(Pending { self.theme_engine.pending = Some(Pending {
candidate, candidate,
wallpaper, wallpaper,
+102 -32
View File
@@ -18,8 +18,11 @@ use smithay::utils::{Buffer, Physical, Rectangle, Size};
use crate::presentation::NativeTextureElement; use crate::presentation::NativeTextureElement;
mod decoder;
mod pixels; mod pixels;
use decoder::Decoder;
use pixels::Pixels; use pixels::Pixels;
use std::sync::Arc;
const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5); const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5);
@@ -59,6 +62,8 @@ struct WallpaperTexture {
pub(crate) struct WallpaperState { pub(crate) struct WallpaperState {
config: WallpaperConfig, config: WallpaperConfig,
pending: Option<WallpaperConfig>, pending: Option<WallpaperConfig>,
loading: Option<WallpaperConfig>,
decoder: Arc<Decoder>,
commit: CommitCounter, commit: CommitCounter,
owned: bool, owned: bool,
mode: WallpaperMode, mode: WallpaperMode,
@@ -78,26 +83,35 @@ impl WallpaperState {
} }
pub fn with_wakeup(config: WallpaperConfig, wakeup: Option<smithay::reexports::calloop::LoopSignal>) -> Self { pub fn with_wakeup(config: WallpaperConfig, wakeup: Option<smithay::reexports::calloop::LoopSignal>) -> 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<smithay::reexports::calloop::LoopSignal>,
decoder: Arc<Decoder>,
) -> Self {
let retained = config.clone(); let retained = config.clone();
let (sender, receiver) = mpsc::channel();
let owned = config.path.as_ref().is_some_and(|path| path.is_file()); let owned = config.path.as_ref().is_some_and(|path| path.is_file());
if owned { let receiver = if owned {
let path = config.path.unwrap(); Some(decoder.submit(config.path.clone().unwrap(), wakeup.clone()))
let wakeup = wakeup.clone(); } else {
std::thread::spawn(move || { decoder.cancel_pending();
let _ = sender.send(pixels::load(&path)); None
if let Some(wakeup) = wakeup { };
wakeup.wakeup();
}
});
}
Self { Self {
config: retained, config: retained,
pending: None, pending: None,
loading: None,
decoder,
commit: CommitCounter::default(), commit: CommitCounter::default(),
owned, owned,
mode: config.mode, mode: config.mode,
receiver: owned.then_some(receiver), receiver,
pixels: None, pixels: None,
textures: HashMap::new(), textures: HashMap::new(),
upload_retries: HashMap::new(), upload_retries: HashMap::new(),
@@ -121,8 +135,22 @@ impl WallpaperState {
} }
pub fn reload(&mut self, config: WallpaperConfig) { 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() { 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; return;
} }
if self.config == config { if self.config == config {
@@ -134,19 +162,18 @@ impl WallpaperState {
self.commit.increment(); self.commit.increment();
return; return;
} }
if config.path.as_ref().is_some_and(|path| !path.is_file()) { if let Some(path) = config.path.clone() {
tracing::warn!("new wallpaper is unavailable; retaining previous image"); self.receiver = Some(self.decoder.submit(path, self.wakeup.clone()));
return; self.loading = Some(config);
} } else {
let mut replacement = Self::with_wakeup(config, self.wakeup.clone()); self.decoder.cancel_pending();
self.config = replacement.config; self.config = config;
self.mode = replacement.mode; self.mode = self.config.mode;
self.owned = replacement.owned; self.owned = false;
self.receiver = replacement.receiver.take();
if self.config.path.is_none() {
self.pixels = None; self.pixels = None;
self.textures.clear(); self.textures.clear();
self.upload_retries.clear(); self.upload_retries.clear();
self.commit.increment();
} }
} }
@@ -205,16 +232,15 @@ impl WallpaperState {
match receiver.try_recv() { match receiver.try_recv() {
Ok(result) => { Ok(result) => {
self.receiver = None; self.receiver = None;
if let Some(pending) = self.pending.take() { let loading = self.loading.take();
if pending.path != self.config.path {
self.reload(pending);
return true;
}
self.config = pending;
self.mode = self.config.mode;
}
match result { match result {
Ok(pixels) => { Ok(pixels) => {
if let Some(config) = loading {
self.config = config;
self.mode = self.config.mode;
self.owned = true;
}
tracing::info!( tracing::info!(
width = pixels.width(), width = pixels.width(),
height = pixels.height(), height = pixels.height(),
@@ -227,13 +253,17 @@ impl WallpaperState {
self.commit.increment(); self.commit.increment();
} }
Err(error) => { 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 true
} }
Err(mpsc::TryRecvError::Disconnected) => { Err(mpsc::TryRecvError::Disconnected) => {
self.receiver = None; self.receiver = None;
self.loading = None;
false false
} }
Err(mpsc::TryRecvError::Empty) => false, Err(mpsc::TryRecvError::Empty) => false,
@@ -323,6 +353,46 @@ fn image_geometry(
mod tests { mod tests {
use super::*; 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] #[test]
fn portal_wallpaper_decodes_without_a_filename_extension() { fn portal_wallpaper_decodes_without_a_filename_extension() {
let directory = tempfile::tempdir().unwrap(); let directory = tempfile::tempdir().unwrap();
+114
View File
@@ -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<Result<Pixels, String>>,
wakeup: Option<smithay::reexports::calloop::LoopSignal>,
}
#[derive(Default)]
struct Queue {
pending: Option<Request>,
closed: bool,
}
pub(super) struct Decoder {
shared: Arc<(Mutex<Queue>, Condvar)>,
}
impl Decoder {
pub fn new(load: impl Fn(&Path) -> Result<Pixels, String> + Send + 'static) -> Arc<Self> {
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<smithay::reexports::calloop::LoopSignal>,
) -> mpsc::Receiver<Result<Pixels, String>> {
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());
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
&mut capture_texture, &mut capture_texture,
&output, &output,
&scene, &scene,
)?; );
capture_changed_binding = true; capture_changed_binding = true;
} }
if capture_changed_binding { if capture_changed_binding {
+70
View File
@@ -83,6 +83,30 @@ cargo test --locked -p ferese backends::direct::capture::tests -- --ignored
cargo test --locked -p ferese mirrored_pixels_fit -- --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 ## Idle inhibition checks
The playback test runs a fake MPRIS player on a private bus and opens a client in 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 suspended, and unplug/reconnect the managed GPU where supported. Verify current
output geometry, idle notifications, recovery after failures and continued lock output geometry, idle notifications, recovery after failures and continued lock
protection. Nested sessions do not validate DRM reacquisition. 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.