fix compositor edge cases, process handing and other cool improvements
This commit is contained in:
1 parent
5706a71941
commit
405c74af9f
38 files changed
+3363
-847
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
|
||||
@@ -36,3 +36,5 @@ tempfile.workspace = true
|
||||
[features]
|
||||
# Opt-in counters/histograms with a per-client exit summary.
|
||||
resize-metrics = []
|
||||
# One-shot KMS recovery checks; never enabled by the installer.
|
||||
drm-fault-injection = []
|
||||
@@ -10,6 +10,7 @@ mod lid;
|
||||
mod mirror;
|
||||
mod output_power;
|
||||
mod planes;
|
||||
mod recovery;
|
||||
mod redraw;
|
||||
mod scheduling;
|
||||
mod topology;
|
||||
@@ -260,6 +261,8 @@ struct DirectOutput {
|
||||
capture_texture: Option<GlesTexture>,
|
||||
render_metrics: RenderMetrics,
|
||||
frame_pending: bool,
|
||||
completion: recovery::Completion,
|
||||
completion_timer: Option<RegistrationToken>,
|
||||
animation_fallback: AnimationFallback,
|
||||
cursor_visible: bool,
|
||||
scheduler: crate::frame_scheduler::FrameScheduler,
|
||||
@@ -800,23 +803,43 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
|
||||
.loop_handle
|
||||
.insert_source(notifier, move |event, metadata, state| match event {
|
||||
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 {
|
||||
let mut retired = false;
|
||||
let mut retirement_failed = false;
|
||||
let feedback = state
|
||||
.direct_backend
|
||||
.as_mut()
|
||||
.and_then(|backend| backend.devices.get_mut(&node))
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
.and_then(|output| match output.surface.frame_submitted() {
|
||||
Ok(feedback) => {
|
||||
retired = feedback.is_some();
|
||||
feedback
|
||||
.and_then(|output| {
|
||||
#[cfg(feature = "drm-fault-injection")]
|
||||
if output.frame_pending && recovery::inject("retirement") {
|
||||
tracing::warn!(?node, ?crtc, "injecting DRM retirement failure");
|
||||
retirement_failed = true;
|
||||
return None;
|
||||
}
|
||||
|
||||
Err(error) => {
|
||||
let (feedback, completed, error) = recovery::retirement(output.surface.frame_submitted());
|
||||
retired = completed;
|
||||
if let Some(error) = error {
|
||||
tracing::error!(?node, ?crtc, %error, "failed to retire DRM frame");
|
||||
None
|
||||
retirement_failed = true;
|
||||
}
|
||||
|
||||
feedback
|
||||
});
|
||||
|
||||
if let (Some(mut feedback), DrmEventTime::Monotonic(time)) = (feedback, metadata.time) {
|
||||
@@ -852,6 +875,7 @@ fn open_device(state: &mut Ferese, path: &Path) -> Result<DrmNode, Box<dyn Error
|
||||
.and_then(|device| device.outputs.get_mut(&crtc))
|
||||
&& retired
|
||||
{
|
||||
output.completion.retired();
|
||||
output.frame_pending = false;
|
||||
output.power.presented();
|
||||
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();
|
||||
}
|
||||
|
||||
if retirement_failed {
|
||||
recovery::reconcile_device(state, node);
|
||||
return;
|
||||
}
|
||||
|
||||
apply_output_power(state);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_after_monitor_recovery(state: &mut Ferese) {
|
||||
lid::request_reconcile(state);
|
||||
}
|
||||
|
||||
fn internal_connector(interface: connector::Interface) -> bool {
|
||||
matches!(
|
||||
interface,
|
||||
@@ -1268,8 +1301,9 @@ fn render_output(
|
||||
primary.sync.wait()?;
|
||||
}
|
||||
|
||||
if result.is_empty {
|
||||
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 {
|
||||
capture::capture_output(
|
||||
state,
|
||||
@@ -1277,10 +1311,9 @@ fn render_output(
|
||||
&mut output.capture_texture,
|
||||
&output.output,
|
||||
&frame,
|
||||
)?;
|
||||
);
|
||||
}
|
||||
|
||||
if result.is_empty {
|
||||
output
|
||||
.render_metrics
|
||||
.record_no_damage(render_started.elapsed(), missed_deadlines);
|
||||
@@ -1327,7 +1360,8 @@ fn render_output(
|
||||
};
|
||||
let visibility = result.states.clone();
|
||||
drop(result);
|
||||
output.surface.queue_frame(presentation)?;
|
||||
|
||||
capture::after_queue(output.surface.queue_frame(presentation), || {
|
||||
if output.mirror_source.is_none() {
|
||||
state.display_presentation.queued(&output.output, &visibility);
|
||||
}
|
||||
@@ -1336,7 +1370,25 @@ fn render_output(
|
||||
.render_metrics
|
||||
.record_frame(render_started.elapsed(), &damage, missed_deadlines, effects);
|
||||
output.frame_pending = true;
|
||||
if !recovery::arm_completion(&state.loop_handle, node, crtc, &mut output) {
|
||||
recovery::reconcile_device(state, node);
|
||||
}
|
||||
|
||||
output.lock_frame_pending = state.session_lock.active();
|
||||
|
||||
if output.mirror_source.is_some() {
|
||||
capture::capture_mirror(state, &mut device.renderer, &scene_output, &frame, &mut output);
|
||||
} else {
|
||||
capture::capture_output(
|
||||
state,
|
||||
&mut device.renderer,
|
||||
&mut output.capture_texture,
|
||||
&output.output,
|
||||
&frame,
|
||||
);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(true)
|
||||
})()
|
||||
};
|
||||
@@ -2042,6 +2094,8 @@ fn create_direct_output(
|
||||
capture_texture: None,
|
||||
render_metrics,
|
||||
frame_pending: false,
|
||||
completion: Default::default(),
|
||||
completion_timer: None,
|
||||
animation_fallback: AnimationFallback::default(),
|
||||
cursor_visible: false,
|
||||
power: OutputPower::default(),
|
||||
|
||||
@@ -3,6 +3,7 @@ use smithay::backend::renderer::element::RenderElement;
|
||||
use smithay::backend::renderer::gles::{GlesError, GlesRenderer, GlesTarget, GlesTexture};
|
||||
use smithay::backend::renderer::{Bind, Offscreen, Texture};
|
||||
use smithay::output::Output;
|
||||
use std::fmt::Display;
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::render::{capture_output_elements, redraw_output};
|
||||
@@ -14,7 +15,7 @@ pub(crate) fn capture_output(
|
||||
texture: &mut Option<GlesTexture>,
|
||||
output: &Output,
|
||||
scene: &FrameScene,
|
||||
) -> Result<(), GlesError> {
|
||||
) {
|
||||
for include_cursor in [true, false] {
|
||||
if !state.has_pending_screencopy(output, include_cursor) {
|
||||
continue;
|
||||
@@ -23,11 +24,13 @@ pub(crate) fn capture_output(
|
||||
// Sample the same scene into an independent target: the displayed
|
||||
// primary buffer may omit both a scanned-out client and hardware cursor.
|
||||
let elements = capture_output_elements(state, renderer, output, include_cursor, scene);
|
||||
let target = render_capture(renderer, texture, output, &elements)?;
|
||||
let Some(target) = capture_request(state, output, include_cursor, || {
|
||||
render_capture(renderer, texture, output, &elements)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
state.process_screencopies(renderer, &target, output, include_cursor);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn capture_mirror(
|
||||
@@ -36,7 +39,7 @@ pub(super) fn capture_mirror(
|
||||
source: &Output,
|
||||
scene: &FrameScene,
|
||||
output: &mut super::DirectOutput,
|
||||
) -> Result<(), GlesError> {
|
||||
) {
|
||||
for include_cursor in [true, false] {
|
||||
if !state.has_pending_screencopy(&output.output, include_cursor) {
|
||||
continue;
|
||||
@@ -44,17 +47,46 @@ pub(super) fn capture_mirror(
|
||||
let elements = capture_output_elements(state, renderer, source, include_cursor, scene);
|
||||
// Never sample mirror_texture: it contains the unfiltered display.
|
||||
let canvas = output.mirror_canvas.as_ref().expect("mirror display composed");
|
||||
super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements)?;
|
||||
let Some(()) = capture_request(state, &output.output, include_cursor, || {
|
||||
super::mirror::compose(renderer, &mut output.mirror_capture_texture, canvas, source, &elements)
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
let element = super::mirror::fitted_texture(
|
||||
output.mirror_capture_texture.as_ref().unwrap().clone(),
|
||||
&output.output,
|
||||
smithay::backend::renderer::element::Id::new(),
|
||||
Default::default(),
|
||||
);
|
||||
let target = render_capture(renderer, &mut output.capture_texture, &output.output, &[element])?;
|
||||
let Some(target) = capture_request(state, &output.output, include_cursor, || {
|
||||
render_capture(renderer, &mut output.capture_texture, &output.output, &[element])
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
state.process_screencopies(renderer, &target, &output.output, include_cursor);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_request<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>>(
|
||||
@@ -178,3 +210,93 @@ mod tests {
|
||||
assert_eq!(capture.as_ref().unwrap().size(), (48, 16).into());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod failure_tests {
|
||||
use std::cell::Cell;
|
||||
|
||||
use calloop::channel::channel;
|
||||
use smithay::output::Output;
|
||||
use smithay::utils::{Buffer, Rectangle};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn capture_allocation_failure_fails_one_request_after_display_submission() {
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"backends::direct::capture::failure_tests::capture_allocation_failure_fails_one_request_after_display_submission",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut events = calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let output = Output::new(
|
||||
"capture-failure".into(),
|
||||
smithay::output::PhysicalProperties {
|
||||
size: (0, 0).into(),
|
||||
subpixel: smithay::output::Subpixel::Unknown,
|
||||
make: "test".into(),
|
||||
model: "test".into(),
|
||||
},
|
||||
);
|
||||
let (sender, receiver) = channel();
|
||||
state
|
||||
.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
41,
|
||||
0,
|
||||
sender,
|
||||
output.clone(),
|
||||
Rectangle::<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub(super) struct Probe {
|
||||
serial: u64,
|
||||
revision: u64,
|
||||
reactivate: bool,
|
||||
wake: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -33,12 +34,13 @@ impl LidState {
|
||||
changed
|
||||
}
|
||||
|
||||
fn begin(&mut self, reactivate: bool) -> Probe {
|
||||
fn begin(&mut self, reactivate: bool, wake: bool) -> Probe {
|
||||
self.serial = self.serial.wrapping_add(1);
|
||||
let probe = Probe {
|
||||
serial: self.serial,
|
||||
revision: self.revision,
|
||||
reactivate: reactivate || self.pending.is_some_and(|pending| pending.reactivate),
|
||||
wake: wake || self.pending.is_some_and(|pending| pending.wake),
|
||||
};
|
||||
self.pending = Some(probe);
|
||||
probe
|
||||
@@ -269,6 +271,15 @@ impl Reader {
|
||||
}
|
||||
|
||||
pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
|
||||
request(state, reactivate, true);
|
||||
}
|
||||
|
||||
/// Listener recovery reconciles hardware without resetting idle activity.
|
||||
pub(super) fn request_reconcile(state: &mut Ferese) {
|
||||
request(state, false, false);
|
||||
}
|
||||
|
||||
fn request(state: &mut Ferese, reactivate: bool, wake: bool) {
|
||||
let Some(backend) = state.direct_backend.as_mut() else {
|
||||
return;
|
||||
};
|
||||
@@ -289,7 +300,7 @@ pub(super) fn request_refresh(state: &mut Ferese, reactivate: bool) {
|
||||
}
|
||||
backend.lid_reader.suspend_requested = false;
|
||||
backend.lid_reader.lease.lock().unwrap().suspend = false;
|
||||
let probe = backend.lid.begin(reactivate);
|
||||
let probe = backend.lid.begin(reactivate, wake);
|
||||
let sent = backend.lid_reader.requests.send(probe).is_ok();
|
||||
let timer = state
|
||||
.loop_handle
|
||||
@@ -329,10 +340,13 @@ fn complete_refresh(state: &mut Ferese, probe: Probe, value: Option<bool>) {
|
||||
// Waking a locked session may request redraws. Hold them until the fresh
|
||||
// device/connector snapshot has been reconciled.
|
||||
backend.lid_reader.suspend_requested = false;
|
||||
if probe.wake {
|
||||
backend.topology.reconciling = true;
|
||||
state.reset_animation_clock();
|
||||
state.lock_input_activity();
|
||||
state.direct_backend.as_mut().unwrap().topology.reconciling = false;
|
||||
}
|
||||
|
||||
reconcile_outputs(state, reactivate);
|
||||
}
|
||||
|
||||
@@ -431,7 +445,7 @@ mod tests {
|
||||
fn newer_input_wins_over_a_delayed_resume_read() {
|
||||
let mut lid = LidState::default();
|
||||
lid.observe(true);
|
||||
let probe = lid.begin(true);
|
||||
let probe = lid.begin(true, true);
|
||||
lid.observe(false);
|
||||
assert_eq!(lid.complete(probe, Some(true)), Some(true));
|
||||
assert!(!lid.closed());
|
||||
@@ -440,11 +454,11 @@ mod tests {
|
||||
#[test]
|
||||
fn failed_reads_preserve_known_state_and_unknown_is_not_an_open_observation() {
|
||||
let mut lid = LidState::default();
|
||||
let probe = lid.begin(false);
|
||||
let probe = lid.begin(false, true);
|
||||
lid.complete(probe, None);
|
||||
assert_eq!(lid.value, None);
|
||||
lid.observe(true);
|
||||
let probe = lid.begin(true);
|
||||
let probe = lid.begin(true, true);
|
||||
lid.complete(probe, None);
|
||||
assert_eq!(lid.value, Some(true));
|
||||
}
|
||||
@@ -452,21 +466,39 @@ mod tests {
|
||||
#[test]
|
||||
fn pause_and_newer_resume_invalidate_old_replies() {
|
||||
let mut lid = LidState::default();
|
||||
let old = lid.begin(true);
|
||||
let old = lid.begin(true, true);
|
||||
lid.pause();
|
||||
assert_eq!(lid.complete(old, Some(true)), None);
|
||||
let current = lid.begin(true);
|
||||
let current = lid.begin(true, true);
|
||||
assert_eq!(lid.complete(old, Some(true)), None);
|
||||
assert!(lid.pending());
|
||||
assert_eq!(lid.complete(current, Some(false)), Some(true));
|
||||
assert!(!lid.pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_recovery_does_not_request_idle_wake_or_reactivation() {
|
||||
let mut lid = LidState::default();
|
||||
let recovery = lid.begin(false, false);
|
||||
assert!(!recovery.wake);
|
||||
assert_eq!(lid.complete(recovery, Some(false)), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_recovery_preserves_a_pending_real_wake() {
|
||||
let mut lid = LidState::default();
|
||||
let resume = lid.begin(true, true);
|
||||
let recovery = lid.begin(false, false);
|
||||
assert!(recovery.wake);
|
||||
assert_eq!(lid.complete(resume, Some(false)), None);
|
||||
assert_eq!(lid.complete(recovery, Some(false)), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_wake_cannot_cancel_pending_session_reactivation() {
|
||||
let mut lid = LidState::default();
|
||||
lid.begin(true);
|
||||
let probe = lid.begin(false);
|
||||
lid.begin(true, true);
|
||||
let probe = lid.begin(false, true);
|
||||
assert_eq!(lid.complete(probe, Some(false)), Some(true));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,12 @@ pub(super) fn cancel_output_timers(
|
||||
handle: &smithay::reexports::calloop::LoopHandle<'static, Ferese>,
|
||||
output: &mut DirectOutput,
|
||||
) {
|
||||
for token in [output.render_timer.take(), output.callback_timer.take()]
|
||||
output.completion.retired();
|
||||
for token in [
|
||||
output.render_timer.take(),
|
||||
output.callback_timer.take(),
|
||||
output.completion_timer.take(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Session-owned services. Never grant shell/effects privileges to daemons.
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
use std::process::Child;
|
||||
use std::rc::Rc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -13,6 +12,7 @@ use smithay::reexports::calloop::{Interest, Mode, PostAction, RegistrationToken}
|
||||
use crate::Ferese;
|
||||
use crate::config::DaemonConfig;
|
||||
use crate::private_client::ClientCapabilities;
|
||||
use crate::process::{pidfd, spawn_client, terminate_child};
|
||||
|
||||
struct Service {
|
||||
config: DaemonConfig,
|
||||
@@ -27,16 +27,6 @@ pub(crate) struct Runner(
|
||||
Option<(RegistrationToken, Instant)>,
|
||||
);
|
||||
|
||||
fn pidfd(pid: u32) -> std::io::Result<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 {
|
||||
pub fn start(state: &mut Ferese) -> Rc<RefCell<Self>> {
|
||||
let runner = Rc::new(RefCell::new(Self::new(
|
||||
@@ -153,7 +143,7 @@ impl Runner {
|
||||
// TERM/grace/reaping must not pause the compositor's event loop.
|
||||
for mut removed in previous {
|
||||
if let Some(mut child) = removed.child.take() {
|
||||
std::thread::spawn(move || crate::terminate_child(&mut child));
|
||||
std::thread::spawn(move || terminate_child(&mut child));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,11 +185,7 @@ impl Runner {
|
||||
}
|
||||
}
|
||||
if !service.finished && now >= service.next_start {
|
||||
service.child = crate::spawn_client(
|
||||
state,
|
||||
service.config.command.iter().map(Into::into).collect(),
|
||||
ClientCapabilities::default(),
|
||||
);
|
||||
service.child = spawn_client(state, service.config.command.iter(), ClientCapabilities::default());
|
||||
service.next_start = now + Duration::from_secs(5);
|
||||
if service.child.is_none() && !service.config.restart {
|
||||
service.finished = true;
|
||||
@@ -211,7 +197,7 @@ impl Runner {
|
||||
pub fn stop(&mut self) {
|
||||
for service in &mut self.0 {
|
||||
if let Some(mut child) = service.child.take() {
|
||||
crate::terminate_child(&mut child);
|
||||
terminate_child(&mut child);
|
||||
}
|
||||
service.finished = true;
|
||||
}
|
||||
|
||||
@@ -2,4 +2,4 @@ mod move_grab;
|
||||
mod resize_grab;
|
||||
|
||||
pub use move_grab::MoveSurfaceGrab;
|
||||
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
|
||||
pub use resize_grab::{ResizeEdge, ResizeSurfaceGrab};
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::cell::RefCell;
|
||||
|
||||
use smithay::desktop::Window;
|
||||
use smithay::input::pointer::{
|
||||
AxisFrame, ButtonEvent, GestureHoldBeginEvent, GestureHoldEndEvent, GesturePinchBeginEvent, GesturePinchEndEvent,
|
||||
@@ -98,10 +96,6 @@ impl ResizeSurfaceGrab {
|
||||
edges: ResizeEdge,
|
||||
initial_rect: Rectangle<i32, Logical>,
|
||||
) -> Self {
|
||||
ResizeState::with(
|
||||
window.toplevel().expect("managed window has a toplevel").wl_surface(),
|
||||
|state| *state = ResizeState::Resizing { edges, initial_rect },
|
||||
);
|
||||
Self {
|
||||
start_data,
|
||||
window,
|
||||
@@ -128,8 +122,8 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
||||
let initial = self.initial_rect;
|
||||
let raw_left = initial.loc.x as f64 + if self.edges.left() { delta.x } else { 0. };
|
||||
let raw_top = initial.loc.y as f64 + if self.edges.top() { delta.y } else { 0. };
|
||||
let raw_right = (initial.loc.x + initial.size.w) as f64 + if self.edges.right() { delta.x } else { 0. };
|
||||
let raw_bottom = (initial.loc.y + initial.size.h) as f64 + if self.edges.bottom() { delta.y } else { 0. };
|
||||
let raw_right = initial.loc.x as f64 + initial.size.w as f64 + if self.edges.right() { delta.x } else { 0. };
|
||||
let raw_bottom = initial.loc.y as f64 + initial.size.h as f64 + if self.edges.bottom() { delta.y } else { 0. };
|
||||
let raw = ferese_layout::Rect::new(
|
||||
raw_left,
|
||||
raw_top,
|
||||
@@ -203,12 +197,6 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
||||
state.size = Some(self.last_size);
|
||||
});
|
||||
surface.send_pending_configure();
|
||||
ResizeState::with(surface.wl_surface(), |state| {
|
||||
*state = ResizeState::WaitingForFinalCommit {
|
||||
edges: self.edges,
|
||||
initial_rect: self.initial_rect,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,57 +295,10 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
|
||||
state.size = Some(self.initial_rect.size);
|
||||
});
|
||||
surface.send_pending_configure();
|
||||
ResizeState::with(surface.wl_surface(), |state| *state = ResizeState::Idle);
|
||||
data.set_floating_window_geometry(&self.window, self.initial_rect.loc, self.initial_rect.size);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
enum ResizeState {
|
||||
#[default]
|
||||
Idle,
|
||||
Resizing {
|
||||
edges: ResizeEdge,
|
||||
initial_rect: Rectangle<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(
|
||||
initial: Size<i32, Logical>,
|
||||
delta: Point<f64, Logical>,
|
||||
@@ -368,21 +309,34 @@ fn constrained_size(
|
||||
let mut width = initial.w;
|
||||
let mut height = initial.h;
|
||||
if edges.left() {
|
||||
width -= delta.x as i32;
|
||||
width = width.saturating_sub(delta.x as i32);
|
||||
} else if edges.right() {
|
||||
width += delta.x as i32;
|
||||
}
|
||||
if edges.top() {
|
||||
height -= delta.y as i32;
|
||||
} else if edges.bottom() {
|
||||
height += delta.y as i32;
|
||||
width = width.saturating_add(delta.x as i32);
|
||||
}
|
||||
|
||||
let maximum_width = if maximum.w == 0 { i32::MAX } else { maximum.w };
|
||||
let maximum_height = if maximum.h == 0 { i32::MAX } else { maximum.h };
|
||||
if edges.top() {
|
||||
height = height.saturating_sub(delta.y as i32);
|
||||
} else if edges.bottom() {
|
||||
height = height.saturating_add(delta.y as i32);
|
||||
}
|
||||
|
||||
// Protocol validation rejects these ranges, but a grab may still be alive
|
||||
// when its client is disconnected. Keep this path safe for any cached state.
|
||||
let minimum_width = minimum.w.max(1);
|
||||
let minimum_height = minimum.h.max(1);
|
||||
let maximum_width = if maximum.w <= 0 {
|
||||
i32::MAX
|
||||
} else {
|
||||
maximum.w.max(minimum_width)
|
||||
};
|
||||
let maximum_height = if maximum.h <= 0 {
|
||||
i32::MAX
|
||||
} else {
|
||||
maximum.h.max(minimum_height)
|
||||
};
|
||||
(
|
||||
width.clamp(minimum.w.max(1), maximum_width),
|
||||
height.clamp(minimum.h.max(1), maximum_height),
|
||||
width.clamp(minimum_width, maximum_width),
|
||||
height.clamp(minimum_height, maximum_height),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
@@ -395,10 +349,11 @@ fn resized_rect(
|
||||
let mut location = initial.loc;
|
||||
|
||||
if edges.left() {
|
||||
location.x += initial.size.w - size.w;
|
||||
location.x = location.x.saturating_add(initial.size.w.saturating_sub(size.w));
|
||||
}
|
||||
|
||||
if edges.top() {
|
||||
location.y += initial.size.h - size.h;
|
||||
location.y = location.y.saturating_add(initial.size.h.saturating_sub(size.h));
|
||||
}
|
||||
|
||||
Rectangle::new(location, size)
|
||||
@@ -469,6 +424,32 @@ mod tests {
|
||||
assert_eq!(size, (1_024, 768).into());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_extreme_client_limits_cannot_panic_or_overflow() {
|
||||
for (minimum, maximum) in [
|
||||
((800, 700), (400, 300)),
|
||||
((-1, -2), (-3, -4)),
|
||||
((i32::MAX, i32::MAX), (1, 1)),
|
||||
((0, 0), (0, 0)),
|
||||
] {
|
||||
for delta in [(f64::MAX, f64::MAX), (-f64::MAX, -f64::MAX), (0.0, 0.0)] {
|
||||
let size = constrained_size(
|
||||
(800, 600).into(),
|
||||
delta.into(),
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::BottomRight),
|
||||
minimum.into(),
|
||||
maximum.into(),
|
||||
);
|
||||
assert!(size.w >= 1 && size.h >= 1);
|
||||
let _ = resized_rect(
|
||||
Rectangle::new((100, 80).into(), (800, 600).into()),
|
||||
size,
|
||||
ResizeEdge(xdg_toplevel::ResizeEdge::TopLeft),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resized_rect_keeps_the_opposite_edge_fixed() {
|
||||
let initial = Rectangle::new((100, 80).into(), (800, 600).into());
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
use std::time::Duration;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use smithay::input::Seat;
|
||||
use smithay::reexports::wayland_server::{Resource, backend::ClientId};
|
||||
use smithay::utils::Serial;
|
||||
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::utils::SERIAL_COUNTER;
|
||||
@@ -9,6 +14,50 @@ use smithay::wayland::xdg_activation::{
|
||||
use crate::Ferese;
|
||||
|
||||
const TOKEN_MAX_AGE: Duration = Duration::from_secs(10);
|
||||
const MAX_INTERACTIONS: usize = 64;
|
||||
|
||||
pub(crate) struct InputHistory<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 {
|
||||
fn activation_state(&mut self) -> &mut XdgActivationState {
|
||||
@@ -16,15 +65,46 @@ impl XdgActivationHandler for Ferese {
|
||||
}
|
||||
|
||||
fn token_created(&mut self, _token: XdgActivationToken, data: XdgActivationTokenData) -> bool {
|
||||
data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE
|
||||
if self.session_lock.active() || self.input_capture.active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some((serial, seat)) = &data.serial else {
|
||||
return false;
|
||||
};
|
||||
let Some(client) = &data.client_id else {
|
||||
return false;
|
||||
};
|
||||
if Seat::<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) {
|
||||
self.xdg_activation_state.remove_token(&token);
|
||||
if self.session_lock.active() || self.input_capture.captures(1) {
|
||||
return;
|
||||
}
|
||||
let valid = data.serial.is_some() && data.timestamp.elapsed() <= TOKEN_MAX_AGE;
|
||||
self.xdg_activation_state.remove_token(&token);
|
||||
|
||||
// The recipient may be a different client: launcher-to-app token handoff.
|
||||
// Retain the original interaction time rather than renewing it at commit.
|
||||
let valid = data.timestamp.elapsed() <= TOKEN_MAX_AGE
|
||||
&& data
|
||||
.user_data
|
||||
.get::<ApprovedInteraction>()
|
||||
.is_some_and(|proof| proof.0.elapsed() <= TOKEN_MAX_AGE);
|
||||
if !valid {
|
||||
tracing::debug!("ignored stale or non-interactive activation request");
|
||||
return;
|
||||
@@ -60,3 +140,163 @@ impl XdgActivationHandler for Ferese {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn token_creation_validates_real_seat_owner_and_input_age() {
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
if !crate::startup_tests::private_runtime(
|
||||
"handlers::activation::tests::token_creation_validates_real_seat_owner_and_input_age",
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut events = EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let launcher = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(crate::state::ClientState::default()))
|
||||
.unwrap();
|
||||
let send = |wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32]| {
|
||||
let bytes: Vec<_> = [object, ((args.len() as u32 + 2) * 4) << 16 | opcode]
|
||||
.into_iter()
|
||||
.chain(args.iter().copied())
|
||||
.flat_map(u32::to_ne_bytes)
|
||||
.collect();
|
||||
wire.write_all(&bytes).unwrap();
|
||||
};
|
||||
send(&mut wire, 1, 1, &[2]); // wl_display.get_registry
|
||||
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut globals = Vec::new();
|
||||
let _ = wire.read_to_end(&mut globals);
|
||||
wire.set_nonblocking(false).unwrap();
|
||||
let mut offset = 0;
|
||||
let mut seat_name = None;
|
||||
while offset < globals.len() {
|
||||
let header = u32::from_ne_bytes(globals[offset + 4..offset + 8].try_into().unwrap());
|
||||
if u32::from_ne_bytes(globals[offset..offset + 4].try_into().unwrap()) == 2 && header & 0xffff == 0 {
|
||||
let length = u32::from_ne_bytes(globals[offset + 12..offset + 16].try_into().unwrap()) as usize;
|
||||
if &globals[offset + 16..offset + 16 + length] == b"wl_seat\0" {
|
||||
seat_name = Some(u32::from_ne_bytes(globals[offset + 8..offset + 12].try_into().unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
offset += (header >> 16) as usize;
|
||||
}
|
||||
|
||||
let mut args = vec![seat_name.expect("seat global"), 8];
|
||||
args.extend(
|
||||
b"wl_seat\0"
|
||||
.chunks_exact(4)
|
||||
.map(|word| u32::from_ne_bytes(word.try_into().unwrap())),
|
||||
);
|
||||
args.extend([7, 3]);
|
||||
send(&mut wire, 2, 0, &args);
|
||||
events.dispatch(Duration::from_millis(1), &mut state).unwrap();
|
||||
let seat: WlSeat = state
|
||||
.seat
|
||||
.client_seats(&launcher)
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("bound seat");
|
||||
let (token, _) = state.xdg_activation_state.create_external_token(None);
|
||||
let token = token.clone();
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
let data = || XdgActivationTokenData {
|
||||
client_id: Some(launcher.id()),
|
||||
serial: Some((serial, seat.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
!state.token_created(token.clone(), data()),
|
||||
"fabricated serial accepted"
|
||||
);
|
||||
state.activation_inputs.record(
|
||||
serial,
|
||||
launcher.id(),
|
||||
Instant::now() - TOKEN_MAX_AGE - Duration::from_millis(1),
|
||||
);
|
||||
assert!(
|
||||
!state.token_created(token.clone(), data()),
|
||||
"fresh token renewed stale input"
|
||||
);
|
||||
state.activation_inputs.record(serial, launcher.id(), Instant::now());
|
||||
let approved = data();
|
||||
assert!(state.token_created(token.clone(), approved.clone()));
|
||||
assert!(approved.user_data.get::<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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use smithay::reexports::wayland_server::protocol::wl_buffer;
|
||||
use smithay::reexports::wayland_server::protocol::wl_surface::WlSurface;
|
||||
use smithay::wayland::buffer::BufferHandler;
|
||||
use smithay::wayland::compositor::{
|
||||
CompositorClientState, CompositorHandler, CompositorState, get_parent, is_sync_subsurface,
|
||||
CompositorClientState, CompositorHandler, CompositorState, get_parent, get_role, is_sync_subsurface,
|
||||
};
|
||||
use smithay::wayland::dmabuf::{DmabufGlobal, DmabufHandler, DmabufState, ImportNotifier};
|
||||
use smithay::wayland::shm::{ShmHandler, ShmState};
|
||||
@@ -35,6 +35,14 @@ impl CompositorHandler for Ferese {
|
||||
}
|
||||
|
||||
fn commit(&mut self, surface: &WlSurface) {
|
||||
if get_role(surface) == Some(smithay::wayland::shell::xdg::XDG_TOPLEVEL_ROLE)
|
||||
&& let Some(window) = self.window_for_surface(surface)
|
||||
&& let Some(toplevel) = window.toplevel()
|
||||
&& !xdg_shell::validate_size_constraints(toplevel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
self.update_surface_preferences(surface);
|
||||
self.capture_resize_before_commit(surface);
|
||||
self.capture_close_before_commit(surface);
|
||||
@@ -45,12 +53,7 @@ impl CompositorHandler for Ferese {
|
||||
while let Some(parent) = get_parent(&root) {
|
||||
root = parent;
|
||||
}
|
||||
let window = {
|
||||
self.space
|
||||
.elements()
|
||||
.find(|window| window.toplevel().is_some_and(|toplevel| toplevel.wl_surface() == &root))
|
||||
.cloned()
|
||||
};
|
||||
let window = self.window_for_surface(&root);
|
||||
|
||||
if let Some(window) = window {
|
||||
window.on_commit();
|
||||
@@ -76,7 +79,7 @@ impl CompositorHandler for Ferese {
|
||||
self.refresh_idle_inhibition();
|
||||
|
||||
layer_shell::handle_commit(self, surface);
|
||||
xdg_shell::handle_commit(&mut self.popups, &mut self.space, surface);
|
||||
xdg_shell::handle_commit(self, surface);
|
||||
crate::backends::direct::render_surface(self, surface);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
mod activation;
|
||||
pub(crate) mod activation;
|
||||
mod compositor;
|
||||
mod input_method;
|
||||
mod layer_shell;
|
||||
@@ -60,6 +60,12 @@ impl SeatHandler for Ferese {
|
||||
}
|
||||
|
||||
fn focus_changed(&mut self, seat: &Seat<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)) {
|
||||
self.focus_history.record(id);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub(crate) enum CaptureSink {
|
||||
part: usize,
|
||||
complete: PartSender,
|
||||
published: AtomicBool,
|
||||
permit: Option<super::screenshot::BudgetPermit>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -59,6 +60,7 @@ impl CaptureSink {
|
||||
part,
|
||||
complete,
|
||||
published: AtomicBool::new(false),
|
||||
permit: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,12 +75,14 @@ impl CaptureSink {
|
||||
part,
|
||||
complete,
|
||||
published,
|
||||
permit,
|
||||
} => {
|
||||
if !published.swap(true, Ordering::AcqRel) {
|
||||
let _ = complete.send(PartOutcome {
|
||||
request: *request,
|
||||
part: *part,
|
||||
result: Err("Screenshot part failed".to_string()),
|
||||
permit: permit.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -93,6 +97,7 @@ impl CaptureSink {
|
||||
part,
|
||||
complete,
|
||||
published,
|
||||
permit,
|
||||
} = self
|
||||
else {
|
||||
return false;
|
||||
@@ -105,12 +110,20 @@ impl CaptureSink {
|
||||
request: *request,
|
||||
part: *part,
|
||||
result: Ok(buffer),
|
||||
permit: permit.clone(),
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingScreencopy {
|
||||
pub(crate) fn with_permit(mut self, permit: Option<super::screenshot::BudgetPermit>) -> Self {
|
||||
if let CaptureSink::Owned { permit: stored, .. } = &mut self.sink {
|
||||
*stored = permit;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn owned(
|
||||
request: u64,
|
||||
part: usize,
|
||||
@@ -333,6 +346,19 @@ impl Ferese {
|
||||
.iter()
|
||||
.any(|capture| capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor))
|
||||
}
|
||||
|
||||
pub(crate) fn fail_screencopies(&mut self, output: &Output, overlay_cursor: bool) {
|
||||
let mut remaining = Vec::new();
|
||||
for capture in std::mem::take(&mut self.pending_screencopies) {
|
||||
if capture.output == *output && cursor_overlay_matches(capture.overlay_cursor, overlay_cursor) {
|
||||
capture.sink.fail();
|
||||
} else {
|
||||
remaining.push(capture);
|
||||
}
|
||||
}
|
||||
|
||||
self.pending_screencopies = remaining;
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_overlay_matches(requested: bool, rendered: bool) -> bool {
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::SyncSender as ReplySender;
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ferese_ipc::Response;
|
||||
@@ -315,6 +319,7 @@ pub(crate) struct PartOutcome {
|
||||
pub(crate) request: u64,
|
||||
pub(crate) part: usize,
|
||||
pub(crate) result: Result<CaptureBuffer, String>,
|
||||
pub(crate) permit: Option<BudgetPermit>,
|
||||
}
|
||||
|
||||
// A calloop channel so publishing a part also wakes the event loop, whichever
|
||||
@@ -352,11 +357,16 @@ struct Request {
|
||||
parts: Vec<(PartSpec, PartState)>,
|
||||
received: usize,
|
||||
deadline: Instant,
|
||||
permit: Option<BudgetPermit>,
|
||||
}
|
||||
|
||||
pub(crate) enum Action {
|
||||
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.
|
||||
Delivered(PathBuf),
|
||||
// The request is gone, so this file is ours to remove.
|
||||
@@ -365,6 +375,45 @@ pub(crate) enum Action {
|
||||
|
||||
const MAX_OUTSTANDING: usize = 8;
|
||||
const MAX_PENDING_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Budget {
|
||||
used: AtomicUsize,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Reservation {
|
||||
budget: Arc<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
|
||||
// produced by the render path, so an output that is never repainted would
|
||||
// otherwise leave the request in Collecting forever, holding one of the few
|
||||
@@ -375,6 +424,7 @@ pub(crate) const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub(crate) struct Coordinator {
|
||||
requests: std::collections::HashMap<u64, Request>,
|
||||
next_id: u64,
|
||||
budget: Arc<Budget>,
|
||||
deadline_changed: Option<Box<dyn Fn(Option<Instant>)>>,
|
||||
notified_deadline: Option<Instant>,
|
||||
}
|
||||
@@ -432,6 +482,7 @@ impl Coordinator {
|
||||
return Err("Screenshot region is too large".into());
|
||||
}
|
||||
|
||||
let permit = self.budget.reserve(pending)?;
|
||||
self.next_id = self.next_id.wrapping_add(1);
|
||||
let id = self.next_id;
|
||||
self.requests.insert(
|
||||
@@ -444,12 +495,17 @@ impl Coordinator {
|
||||
parts: specs.into_iter().map(|spec| (spec, PartState::Pending)).collect(),
|
||||
received: 0,
|
||||
deadline: now + REQUEST_TIMEOUT,
|
||||
permit: Some(permit),
|
||||
},
|
||||
);
|
||||
self.notify_deadline_change();
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub(crate) fn permit(&self, request: u64) -> Option<BudgetPermit> {
|
||||
self.requests.get(&request).and_then(|entry| entry.permit.clone())
|
||||
}
|
||||
|
||||
// Used by the completion tests to assert that a request is still tracked.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn is_live(&self, request: u64) -> bool {
|
||||
@@ -513,7 +569,11 @@ impl Coordinator {
|
||||
})
|
||||
.collect();
|
||||
entry.state = RequestState::Encoding;
|
||||
Action::Encode { request, frames }
|
||||
Action::Encode {
|
||||
request,
|
||||
frames,
|
||||
permit: entry.permit.take().expect("collecting request owns its reservation"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_encoded(&mut self, request: u64, result: Result<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]
|
||||
fn all_parts_ready_hands_frames_to_the_encoder() {
|
||||
let mut harness = Harness::new(2);
|
||||
@@ -1055,7 +1152,7 @@ mod completion {
|
||||
Action::None
|
||||
));
|
||||
let action = harness.coordinator.on_part(id, 1, Ok(buffer(2, 2)));
|
||||
let Action::Encode { request, frames } = action else {
|
||||
let Action::Encode { request, frames, .. } = action else {
|
||||
panic!("expected the request to move to encoding");
|
||||
};
|
||||
assert_eq!(request, id);
|
||||
@@ -1407,7 +1504,9 @@ mod completion {
|
||||
// buffer that was handed off is never replaced.
|
||||
assert!(!sink.publish(buffer(64, 64)));
|
||||
|
||||
let PartOutcome { request, part, result } = receiver.try_recv().expect("published once");
|
||||
let PartOutcome {
|
||||
request, part, result, ..
|
||||
} = receiver.try_recv().expect("published once");
|
||||
assert_eq!((request, part), (7, 1));
|
||||
assert_eq!(result.unwrap().width, 2);
|
||||
assert!(receiver.try_recv().is_err(), "no second buffer was sent");
|
||||
@@ -1422,7 +1521,9 @@ mod completion {
|
||||
// failure has already been reported.
|
||||
assert!(!sink.publish(buffer(2, 2)));
|
||||
|
||||
let PartOutcome { request, part, result } = receiver.try_recv().expect("answered");
|
||||
let PartOutcome {
|
||||
request, part, result, ..
|
||||
} = receiver.try_recv().expect("answered");
|
||||
assert_eq!((request, part), (3, 0));
|
||||
assert!(result.is_err());
|
||||
assert!(receiver.try_recv().is_err());
|
||||
|
||||
@@ -20,6 +20,7 @@ const FILE_MODE: u32 = 0o600;
|
||||
pub(crate) struct Job {
|
||||
pub(crate) request: u64,
|
||||
pub(crate) frames: Vec<OutputFrame>,
|
||||
pub(crate) _permit: Option<super::screenshot::BudgetPermit>,
|
||||
}
|
||||
|
||||
pub(crate) struct Encoded {
|
||||
@@ -54,17 +55,13 @@ impl Worker {
|
||||
fn run(receiver: Receiver<Job>, results: channel::SyncSender<Encoded>) {
|
||||
while let Ok(job) = receiver.recv() {
|
||||
let result = encode(&job);
|
||||
let request = job.request;
|
||||
drop(job);
|
||||
// This send cannot block. Each request produces exactly one Encode, so at
|
||||
// most QUEUE_CAPACITY jobs can be queued while one more is being encoded,
|
||||
// and RESULT_QUEUE_CAPACITY is asserted to exceed that. Blocking here
|
||||
// would stall the worker with no way for the loop to recover it.
|
||||
if results
|
||||
.send(Encoded {
|
||||
request: job.request,
|
||||
result,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
if results.send(Encoded { request, result }).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -131,25 +128,51 @@ fn encode_png(canvas: &Canvas) -> Result<Vec<u8>, String> {
|
||||
|
||||
// The client is told to open this path and unlink it immediately, so the
|
||||
// directory is ours, private, and files are created with the final mode.
|
||||
fn write_private_file(png: &[u8]) -> Result<PathBuf, String> {
|
||||
let directory = private_directory()?;
|
||||
for attempt in 0..64u32 {
|
||||
let path = directory.join(format!("shot-{}-{attempt}.png", std::process::id()));
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true).mode(FILE_MODE);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
use io::Write;
|
||||
file.write_all(png)
|
||||
.and_then(|()| file.sync_all())
|
||||
.map_err(|error| format!("Could not write screenshot: {error}"))?;
|
||||
return Ok(path);
|
||||
struct StagedFile {
|
||||
path: PathBuf,
|
||||
keep: bool,
|
||||
}
|
||||
|
||||
impl Drop for StagedFile {
|
||||
fn drop(&mut self) {
|
||||
if !self.keep {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_private_file(png: &[u8]) -> Result<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) => 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> {
|
||||
@@ -209,6 +232,7 @@ mod tests {
|
||||
let job = Job {
|
||||
request: 1,
|
||||
frames: vec![frame(4, 3)],
|
||||
_permit: None,
|
||||
};
|
||||
let mut canvas = compose(&job.frames).unwrap();
|
||||
for (index, pixel) in canvas.pixels.chunks_exact_mut(4).enumerate() {
|
||||
@@ -227,6 +251,26 @@ mod tests {
|
||||
assert_eq!(decoded.get_pixel(0, 0)[3], 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_has_no_fixed_filename_capacity_and_cleans_partial_writes() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
for _ in 0..100 {
|
||||
write_private_file_in(directory.path(), b"png", |file, bytes| {
|
||||
use io::Write;
|
||||
file.write_all(bytes)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100);
|
||||
let result = write_private_file_in(directory.path(), b"partial", |file, bytes| {
|
||||
use io::Write;
|
||||
file.write_all(bytes)?;
|
||||
Err(io::Error::other("injected write/sync failure"))
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_files_are_owner_only() {
|
||||
let png = b"\x89PNG\r\n\x1a\nnot really a png".to_vec();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use smithay::desktop::{
|
||||
PopupKeyboardGrab, PopupKind, PopupManager, PopupPointerGrab, Space, Window, WindowSurfaceType,
|
||||
find_popup_root_surface, get_popup_toplevel_coords,
|
||||
PopupKeyboardGrab, PopupKind, PopupPointerGrab, Window, WindowSurfaceType, find_popup_root_surface,
|
||||
get_popup_toplevel_coords,
|
||||
};
|
||||
use smithay::input::Seat;
|
||||
use smithay::input::pointer::{Focus, GrabStartData};
|
||||
@@ -17,7 +17,7 @@ use smithay::wayland::shell::xdg::{
|
||||
};
|
||||
|
||||
use crate::Ferese;
|
||||
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab, handle_resize_commit};
|
||||
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab};
|
||||
|
||||
impl XdgShellHandler for Ferese {
|
||||
fn xdg_shell_state(&mut self) -> &mut XdgShellState {
|
||||
@@ -82,19 +82,7 @@ impl XdgShellHandler for Ferese {
|
||||
|
||||
fn toplevel_destroyed(&mut self, surface: ToplevelSurface) {
|
||||
// Hidden workspace windows are managed but unmapped from Space.
|
||||
let Some(window) = self
|
||||
.windows
|
||||
.id_for_surface(surface.wl_surface())
|
||||
.and_then(|id| self.windows.window(id))
|
||||
.or_else(|| {
|
||||
self.space.elements().find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface.wl_surface())
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
else {
|
||||
let Some(window) = self.window_for_surface(surface.wl_surface()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -330,17 +318,27 @@ pub(crate) fn initial_configure_sent(toplevel: &ToplevelSurface) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surface: &WlSurface) {
|
||||
handle_resize_commit(surface);
|
||||
if let Some(window) = space
|
||||
.elements()
|
||||
.find(|window| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
|
||||
})
|
||||
.cloned()
|
||||
{
|
||||
pub(crate) fn validate_size_constraints(toplevel: &ToplevelSurface) -> bool {
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
|
||||
use smithay::wayland::shell::xdg::SurfaceCachedState;
|
||||
|
||||
let valid = with_states(toplevel.wl_surface(), |states| {
|
||||
let mut cached = states.cached_state.get::<SurfaceCachedState>();
|
||||
let state = cached.current();
|
||||
let valid_axis = |min: i32, max: i32| min >= 0 && max >= 0 && (max == 0 || min <= max);
|
||||
valid_axis(state.min_size.w, state.max_size.w) && valid_axis(state.min_size.h, state.max_size.h)
|
||||
});
|
||||
if !valid {
|
||||
toplevel
|
||||
.xdg_toplevel()
|
||||
.post_error(xdg_toplevel::Error::InvalidSize, "invalid minimum/maximum window size");
|
||||
}
|
||||
|
||||
valid
|
||||
}
|
||||
|
||||
pub fn handle_commit(state: &mut Ferese, surface: &WlSurface) {
|
||||
if let Some(window) = state.window_for_surface(surface) {
|
||||
let configured = with_states(surface, |states| {
|
||||
states
|
||||
.data_map
|
||||
@@ -358,8 +356,8 @@ pub fn handle_commit(popups: &mut PopupManager, space: &mut Space<Window>, surfa
|
||||
}
|
||||
}
|
||||
|
||||
popups.commit(surface);
|
||||
if let Some(PopupKind::Xdg(popup)) = popups.find_popup(surface)
|
||||
state.popups.commit(surface);
|
||||
if let Some(PopupKind::Xdg(popup)) = state.popups.find_popup(surface)
|
||||
&& !popup.is_initial_configure_sent()
|
||||
{
|
||||
popup.send_configure().expect("initial popup configure is valid");
|
||||
|
||||
+70
-17
@@ -1,5 +1,3 @@
|
||||
use std::process::Command;
|
||||
|
||||
use ferese_layout::Direction;
|
||||
use smithay::backend::input::{
|
||||
AbsolutePositionEvent, Axis, AxisSource, ButtonState, Event, GestureBeginEvent, GestureEndEvent,
|
||||
@@ -22,6 +20,7 @@ use crate::Ferese;
|
||||
use crate::config::BindingAction;
|
||||
use crate::gestures::SwipeDirection;
|
||||
use crate::grabs::{MoveSurfaceGrab, ResizeEdge, ResizeSurfaceGrab};
|
||||
use crate::process::spawn_client;
|
||||
|
||||
const BTN_LEFT: u32 = 0x110;
|
||||
const BTN_RIGHT: u32 = 0x111;
|
||||
@@ -278,11 +277,16 @@ impl Ferese {
|
||||
keyboard.set_focus(self, None, SERIAL_COUNTER.next_serial());
|
||||
}
|
||||
}
|
||||
keyboard.input::<(), _>(
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
let interaction_focus = keyboard.current_focus();
|
||||
let client_grab = keyboard
|
||||
.with_grab(|_, grab| grab.is::<smithay::desktop::PopupKeyboardGrab<Self>>())
|
||||
.unwrap_or(true);
|
||||
let intercepted = keyboard.input::<(), _>(
|
||||
self,
|
||||
keycode,
|
||||
state,
|
||||
SERIAL_COUNTER.next_serial(),
|
||||
serial,
|
||||
event.time_msec(),
|
||||
|data, modifiers, keysym| {
|
||||
if data.focus_cycle.is_some() {
|
||||
@@ -423,6 +427,10 @@ impl Ferese {
|
||||
FilterResult::Intercept(())
|
||||
},
|
||||
);
|
||||
if state == KeyState::Pressed && intercepted.is_none() && client_grab {
|
||||
self.record_activation_input(serial, interaction_focus);
|
||||
}
|
||||
|
||||
if was_captured && !self.input_capture.active() {
|
||||
self.restore_input_capture_focus();
|
||||
}
|
||||
@@ -534,6 +542,10 @@ impl Ferese {
|
||||
if self.overview.is_active() {
|
||||
let position = pointer.current_location();
|
||||
if self.layer_under(position).is_some() {
|
||||
if event.state() == ButtonState::Pressed {
|
||||
self.record_activation_input(serial, pointer.current_focus());
|
||||
}
|
||||
|
||||
pointer.button(
|
||||
self,
|
||||
&ButtonEvent {
|
||||
@@ -584,6 +596,16 @@ impl Ferese {
|
||||
self.focus_window_at(pointer.current_location(), serial, true);
|
||||
}
|
||||
|
||||
let client_grab = pointer
|
||||
.with_grab(|_, grab| {
|
||||
grab.is::<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(
|
||||
self,
|
||||
&ButtonEvent {
|
||||
@@ -654,6 +676,10 @@ impl Ferese {
|
||||
let touch = self.seat.get_touch().expect("seat has touch capability");
|
||||
let serial = SERIAL_COUNTER.next_serial();
|
||||
|
||||
if !touch.is_grabbed() {
|
||||
self.record_activation_input(serial, self.surface_under(location).map(|(surface, _)| surface));
|
||||
}
|
||||
|
||||
touch.down(
|
||||
self,
|
||||
self.surface_under(location),
|
||||
@@ -1042,20 +1068,10 @@ impl Ferese {
|
||||
match action {
|
||||
BindingAction::None => {}
|
||||
BindingAction::Spawn(argv) => {
|
||||
let (program, args) = argv.split_first().expect("binding commands are validated");
|
||||
|
||||
match Command::new(program)
|
||||
.args(args)
|
||||
.env("WAYLAND_DISPLAY", &self.socket_name)
|
||||
.env_remove("WAYLAND_SOCKET")
|
||||
.spawn()
|
||||
if let Some(child) =
|
||||
spawn_client(self, argv.iter(), crate::private_client::ClientCapabilities::default())
|
||||
{
|
||||
Ok(child) => {
|
||||
tracing::info!(%program, pid = child.id(), "spawned binding command")
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%program, %error, "failed to spawn binding command")
|
||||
}
|
||||
crate::process::watch_client_exit(&self.loop_handle, child);
|
||||
}
|
||||
}
|
||||
BindingAction::Close => self.close_focused_window(),
|
||||
@@ -1166,6 +1182,43 @@ fn overview_escape(symbol: u32, overview_active: bool) -> bool {
|
||||
mod tests {
|
||||
use super::{emergency_shortcut_escape, overview_escape, virtual_terminal};
|
||||
|
||||
#[test]
|
||||
fn binding_commands_are_reaped_while_the_compositor_runs() {
|
||||
if !crate::startup_tests::private_runtime("input::tests::binding_commands_are_reaped_while_the_compositor_runs")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let mut events = smithay::reexports::calloop::EventLoop::try_new().unwrap();
|
||||
let mut state = crate::startup_tests::state(&mut events);
|
||||
let children_path = format!("/proc/self/task/{}/children", unsafe { libc::gettid() });
|
||||
assert!(std::fs::read_to_string(&children_path).unwrap().trim().is_empty());
|
||||
for _ in 0..24 {
|
||||
state.execute_binding(crate::config::BindingAction::Spawn(vec!["/bin/true".into()].into()));
|
||||
}
|
||||
|
||||
let children: Vec<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]
|
||||
fn escape_closes_only_an_active_overview() {
|
||||
assert!(overview_escape(keysyms::KEY_Escape, true));
|
||||
|
||||
+29
-9
@@ -908,14 +908,16 @@ impl Ferese {
|
||||
|
||||
for (position, part) in planned.iter().enumerate() {
|
||||
let (output, _) = &targets[part.index];
|
||||
self.pending_screencopies
|
||||
.push(crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
self.pending_screencopies.push(
|
||||
crate::handlers::screencopy::PendingScreencopy::owned(
|
||||
id,
|
||||
position,
|
||||
parts.clone(),
|
||||
output.clone(),
|
||||
part.buffer,
|
||||
));
|
||||
)
|
||||
.with_permit(self.screenshot.permit(id)),
|
||||
);
|
||||
}
|
||||
|
||||
// The nested backend redraws on its refresh timer; this drives the
|
||||
@@ -1006,11 +1008,20 @@ impl Ferese {
|
||||
request: capture,
|
||||
part: 0,
|
||||
result,
|
||||
permit: self.screenshot.permit(capture),
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn on_screenshot_part(&mut self, outcome: PartOutcome) {
|
||||
let action = self.screenshot.on_part(outcome.request, outcome.part, outcome.result);
|
||||
let PartOutcome {
|
||||
request,
|
||||
part,
|
||||
result,
|
||||
permit,
|
||||
} = outcome;
|
||||
let action = self.screenshot.on_part(request, part, result);
|
||||
// Pixels have moved into the coordinator/job before the channel lease is dropped.
|
||||
drop(permit);
|
||||
self.apply_screenshot_action(action);
|
||||
}
|
||||
|
||||
@@ -1022,11 +1033,20 @@ impl Ferese {
|
||||
fn apply_screenshot_action(&mut self, action: Action) {
|
||||
match action {
|
||||
Action::None => {}
|
||||
Action::Encode { request, frames } => {
|
||||
let submitted = self
|
||||
.screenshot_worker
|
||||
.as_ref()
|
||||
.is_some_and(|worker| worker.submit(Job { request, frames }).is_ok());
|
||||
Action::Encode {
|
||||
request,
|
||||
frames,
|
||||
permit,
|
||||
} => {
|
||||
let submitted = self.screenshot_worker.as_ref().is_some_and(|worker| {
|
||||
worker
|
||||
.submit(Job {
|
||||
request,
|
||||
frames,
|
||||
_permit: Some(permit),
|
||||
})
|
||||
.is_ok()
|
||||
});
|
||||
if !submitted {
|
||||
self.screenshot.reject(request, "Screenshot encoding queue is full");
|
||||
}
|
||||
|
||||
+70
-176
@@ -1,5 +1,3 @@
|
||||
#![allow(irrefutable_let_patterns)]
|
||||
|
||||
mod backends;
|
||||
mod config;
|
||||
mod cursor;
|
||||
@@ -26,6 +24,7 @@ mod portal_shortcuts;
|
||||
mod presentation;
|
||||
mod presentation_policy;
|
||||
mod private_client;
|
||||
mod process;
|
||||
mod reload;
|
||||
mod render;
|
||||
#[cfg(feature = "resize-metrics")]
|
||||
@@ -35,6 +34,8 @@ mod resume;
|
||||
mod session_lock;
|
||||
mod shell_control;
|
||||
mod stacking;
|
||||
#[cfg(test)]
|
||||
mod startup_tests;
|
||||
mod state;
|
||||
mod theme;
|
||||
mod wallpaper;
|
||||
@@ -42,11 +43,10 @@ mod window_rules;
|
||||
mod winit;
|
||||
|
||||
use std::error::Error;
|
||||
use std::process::{Child, Command};
|
||||
use std::time::Duration;
|
||||
use std::{io, thread};
|
||||
use std::io;
|
||||
|
||||
use calloop::signals::{Signal, Signals};
|
||||
use process::{spawn_client, terminate_child, watch_client_exit};
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
pub use state::{Ferese, RuntimeConfig};
|
||||
@@ -64,13 +64,8 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
if args.len() != 2 {
|
||||
return Err("usage: ferese --check-config PATH".into());
|
||||
}
|
||||
let source = std::fs::read_to_string(&args[1])?;
|
||||
theme::prepare(
|
||||
&source,
|
||||
std::path::Path::new(&args[1])
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new(".")),
|
||||
)?;
|
||||
|
||||
check_config(std::path::Path::new(&args[1]))?;
|
||||
println!("Configuration is valid");
|
||||
return Ok(());
|
||||
}
|
||||
@@ -81,11 +76,10 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
.filter(|path| path.exists())
|
||||
.map(|path| theme::read_source(&path))
|
||||
.transpose()?;
|
||||
let source = initial_source.as_deref().unwrap_or("");
|
||||
let directory = config::config_path()
|
||||
.and_then(|path| path.parent().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let (_, runtime, candidate) = theme::prepare(source, &directory)?;
|
||||
let (_, runtime, candidate) = theme::prepare(initial_source.as_deref().unwrap_or(""), &directory)?;
|
||||
let mut event_loop = EventLoop::try_new()?;
|
||||
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
|
||||
event_loop
|
||||
@@ -109,56 +103,18 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
value
|
||||
});
|
||||
theme::init(&mut event_loop, &mut state, candidate)?;
|
||||
resume::init(&mut event_loop)?;
|
||||
let _resume_monitor = resume::init(&mut event_loop)?;
|
||||
idle_inhibition::media::init(&mut event_loop)?;
|
||||
overview::init_font_loader(&mut event_loop, &mut state)?;
|
||||
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
||||
|
||||
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
|
||||
let mut child = spawn_client(&mut state, launch.client, launch.client_capabilities);
|
||||
|
||||
let child = spawn_client(&mut state, launch.client, launch.client_capabilities)
|
||||
.map(|child| watch_client_exit(&state.loop_handle, child));
|
||||
let runner = daemon::Runner::start(&mut state);
|
||||
state.daemons = Some(runner.clone());
|
||||
let reload_handle = event_loop.handle();
|
||||
let result = event_loop.run(None, &mut state, |state| {
|
||||
state.poll_theme();
|
||||
// The decoder wakes the loop after publishing its result, including
|
||||
// when no output has a pending frame.
|
||||
let wallpaper_changed = state.wallpaper.poll();
|
||||
// All input/Wayland callbacks have returned, releasing seat locks.
|
||||
if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) {
|
||||
state.cancel_focus_cycle();
|
||||
}
|
||||
// Coalesce cursor changes and redraw here, never inside cursor_image.
|
||||
if state.input_capture.restore_focus {
|
||||
state.restore_input_capture_focus();
|
||||
}
|
||||
state.apply_unlocked_pointer_hint();
|
||||
let wallpaper_retry = state.wallpaper.take_retry_wakeup();
|
||||
let cursor_changed = std::mem::take(&mut state.cursor_redraw_pending);
|
||||
let output_redraws = std::mem::take(&mut state.output_redraw_pending);
|
||||
if wallpaper_changed || wallpaper_retry {
|
||||
backends::direct::render_all(state);
|
||||
} else {
|
||||
if cursor_changed {
|
||||
backends::direct::render_cursor(state);
|
||||
}
|
||||
|
||||
if !output_redraws.is_empty() {
|
||||
backends::direct::render_on(state, &output_redraws);
|
||||
}
|
||||
}
|
||||
if let Err(error) = state
|
||||
.wallpaper
|
||||
.arm_retry_timer(&reload_handle, std::time::Instant::now())
|
||||
{
|
||||
warn!(%error, "could not schedule wallpaper upload retry");
|
||||
}
|
||||
// Registry/sync/configure replies must not depend on a submitted
|
||||
// frame: at startup clients need these before they can draw anything.
|
||||
// This also keeps idle DRM sessions responsive when there is no damage.
|
||||
if let Err(error) = state.display_handle.flush_clients() {
|
||||
tracing::warn!(%error, "failed to flush Wayland clients");
|
||||
}
|
||||
});
|
||||
let result = event_loop.run(None, &mut state, after_dispatch);
|
||||
|
||||
if let Some(backend) = state.direct_backend.as_ref() {
|
||||
backend.dump_scheduling_metrics();
|
||||
@@ -168,8 +124,10 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
state.resize_metrics.dump();
|
||||
|
||||
runner.borrow_mut().stop();
|
||||
if let Some(child) = &mut child {
|
||||
terminate_child(child);
|
||||
if let Some(child) = child
|
||||
&& let Some(mut child) = child.borrow_mut().take()
|
||||
{
|
||||
terminate_child(&mut child);
|
||||
}
|
||||
|
||||
result.map_err(Into::into)
|
||||
@@ -181,130 +139,66 @@ fn init_logging() {
|
||||
tracing_subscriber::fmt().with_env_filter(filter).init();
|
||||
}
|
||||
|
||||
fn spawn_client(
|
||||
state: &mut Ferese,
|
||||
mut args: Vec<std::ffi::OsString>,
|
||||
capabilities: private_client::ClientCapabilities,
|
||||
) -> 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 check_config(path: &std::path::Path) -> Result<(), String> {
|
||||
let source = theme::read_source(path)?;
|
||||
theme::prepare(&source, path.parent().unwrap_or(std::path::Path::new(".")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
#[derive(Debug)]
|
||||
enum RedrawRequest<'a> {
|
||||
Global,
|
||||
Cursor,
|
||||
Outputs(&'a [smithay::output::Output]),
|
||||
}
|
||||
|
||||
#[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());
|
||||
fn after_dispatch(state: &mut Ferese) {
|
||||
after_dispatch_with_redraw(state, |state, request| match request {
|
||||
RedrawRequest::Global => backends::direct::render_all(state),
|
||||
RedrawRequest::Cursor => backends::direct::render_cursor(state),
|
||||
RedrawRequest::Outputs(outputs) => backends::direct::render_on(state, outputs),
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
fn after_dispatch_with_redraw(state: &mut Ferese, mut redraw: impl FnMut(&mut Ferese, RedrawRequest<'_>)) {
|
||||
state.poll_theme();
|
||||
// The decoder wakes the loop after publishing its result, including
|
||||
// when no output has a pending frame.
|
||||
let wallpaper_changed = state.wallpaper.poll();
|
||||
// All input/Wayland callbacks have returned, releasing seat locks.
|
||||
if state.focus_cycle.is_some() && (state.session_lock.active() || state.input_capture.captures(1)) {
|
||||
state.cancel_focus_cycle();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wayland_roundtrip_completes_without_a_rendered_frame() {
|
||||
let mut display = Display::<()>::new().unwrap();
|
||||
let (server, mut client) = UnixStream::pair().unwrap();
|
||||
display
|
||||
.handle()
|
||||
.insert_client(server, Arc::new(state::ClientState::default()))
|
||||
.unwrap();
|
||||
// wl_display.sync(new_id=2), using native-endian Wayland wire format.
|
||||
for word in [1u32, 12u32 << 16, 2u32] {
|
||||
client.write_all(&word.to_ne_bytes()).unwrap();
|
||||
if state.input_capture.restore_focus {
|
||||
state.restore_input_capture_focus();
|
||||
}
|
||||
display.dispatch_clients(&mut ()).unwrap();
|
||||
display.handle().flush_clients().unwrap();
|
||||
client.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
|
||||
let mut reply = [0u8; 12];
|
||||
client.read_exact(&mut reply).unwrap();
|
||||
assert_eq!(u32::from_ne_bytes(reply[0..4].try_into().unwrap()), 2);
|
||||
assert_eq!(u32::from_ne_bytes(reply[4..8].try_into().unwrap()) & 0xffff, 0);
|
||||
|
||||
state.apply_unlocked_pointer_hint();
|
||||
let wallpaper_retry = state.wallpaper.take_retry_wakeup();
|
||||
let cursor_changed = std::mem::take(&mut state.cursor_redraw_pending);
|
||||
let output_redraws = std::mem::take(&mut state.output_redraw_pending);
|
||||
if wallpaper_changed || wallpaper_retry {
|
||||
redraw(state, RedrawRequest::Global);
|
||||
} else {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,57 +1,257 @@
|
||||
//! System wake is shared by hardware reconciliation and time-dependent UI state.
|
||||
//! System wake and listener recovery share reconciliation, but not idle activity.
|
||||
use std::error::Error;
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::task::{Poll, Waker};
|
||||
use std::thread::JoinHandle;
|
||||
use std::time::Duration;
|
||||
|
||||
use calloop::EventLoop;
|
||||
use calloop::channel::{Event, channel};
|
||||
use calloop::{EventLoop, LoopHandle, RegistrationToken};
|
||||
use futures_lite::{StreamExt, future};
|
||||
|
||||
use crate::Ferese;
|
||||
|
||||
pub(crate) fn init(event_loop: &mut EventLoop<Ferese>) -> Result<(), Box<dyn Error>> {
|
||||
let (sender, receiver) = channel();
|
||||
event_loop.handle().insert_source(receiver, |event, _, state| {
|
||||
if let Event::Msg(()) = event {
|
||||
crate::backends::direct::system_resumed(state);
|
||||
const RETRY_DELAY: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Refresh {
|
||||
Resumed,
|
||||
Reconcile,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum MonitorExit {
|
||||
Stop,
|
||||
Reconnect,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StopState {
|
||||
requested: bool,
|
||||
waker: Option<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();
|
||||
}
|
||||
|
||||
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())
|
||||
.spawn(move || {
|
||||
let mut warned = false;
|
||||
let mut recovery = Recovery::default();
|
||||
loop {
|
||||
let monitor = || -> zbus::Result<bool> {
|
||||
let connection = zbus::blocking::connection::Builder::system()?
|
||||
.method_timeout(Duration::from_secs(2))
|
||||
.build()?;
|
||||
let proxy = zbus::blocking::Proxy::new(
|
||||
&connection,
|
||||
let result = future::block_on(future::race(
|
||||
async {
|
||||
worker_stop.cancelled().await;
|
||||
Ok(MonitorExit::Stop)
|
||||
},
|
||||
async {
|
||||
let connection = connect()?.method_timeout(Duration::from_secs(2)).build().await?;
|
||||
future::race(
|
||||
async {
|
||||
connection.closed().await;
|
||||
Ok(MonitorExit::Reconnect)
|
||||
},
|
||||
listen(&connection, &mut recovery, &mut send),
|
||||
)
|
||||
.await
|
||||
},
|
||||
));
|
||||
|
||||
match result {
|
||||
Ok(MonitorExit::Stop) => break,
|
||||
Ok(MonitorExit::Reconnect) => {
|
||||
if recovery.unavailable() {
|
||||
tracing::warn!("system resume monitoring interrupted; retrying connection");
|
||||
}
|
||||
}
|
||||
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.Manager",
|
||||
)?;
|
||||
for message in proxy.receive_signal("PrepareForSleep")? {
|
||||
let (sleeping,): (bool,) = message.body().deserialize()?;
|
||||
if !sleeping && sender.send(()).is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
};
|
||||
)
|
||||
.await?;
|
||||
let mut owners = proxy.receive_owner_changed().await?;
|
||||
let mut signals = proxy.receive_signal("PrepareForSleep").await?;
|
||||
// AddMatch succeeds even when logind is absent. Only call the listener
|
||||
// recovered once the service is present, and retry if its owner changes.
|
||||
connection
|
||||
.call_method(
|
||||
Some("org.freedesktop.DBus"),
|
||||
"/org/freedesktop/DBus",
|
||||
Some("org.freedesktop.DBus"),
|
||||
"GetNameOwner",
|
||||
&("org.freedesktop.login1",),
|
||||
)
|
||||
.await?;
|
||||
|
||||
match monitor() {
|
||||
Ok(false) => break,
|
||||
Ok(true) => warned = false,
|
||||
Err(error) if !warned => {
|
||||
tracing::warn!(%error, "system resume monitor unavailable; retrying connection");
|
||||
warned = true;
|
||||
// Subscribe first: a resume during reconciliation must remain queued.
|
||||
if recovery.subscribed() {
|
||||
tracing::debug!("system resume monitoring recovered; reconciling current state");
|
||||
if !send(Refresh::Reconcile) {
|
||||
return Ok(MonitorExit::Stop);
|
||||
}
|
||||
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;
|
||||
@@ -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
@@ -111,7 +111,7 @@ pub(crate) struct Lock {
|
||||
pub(crate) idle_opacity: f32,
|
||||
pub(crate) sleeping: bool,
|
||||
pub(crate) idle_overlays: HashMap<Output, smithay::backend::renderer::element::solid::SolidColorBuffer>,
|
||||
idle_timer: Option<RegistrationToken>,
|
||||
idle_timer: Option<(RegistrationToken, Instant)>,
|
||||
}
|
||||
|
||||
impl Lock {
|
||||
@@ -130,6 +130,8 @@ impl Lock {
|
||||
pub(crate) fn output_removed(&mut self, output: &Output) {
|
||||
self.presented.remove(output);
|
||||
self.idle_overlays.remove(output);
|
||||
self.surfaces.remove(output);
|
||||
self.backgrounds.remove(output);
|
||||
}
|
||||
|
||||
fn activity(&mut self, now: Instant) -> bool {
|
||||
@@ -167,40 +169,64 @@ impl Ferese {
|
||||
crate::backends::direct::wake_locked_outputs(self);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_lock_idle_policy(&mut self) {
|
||||
self.update_lock_idle(Instant::now());
|
||||
self.rearm_lock_idle();
|
||||
}
|
||||
|
||||
fn rearm_lock_idle(&mut self) {
|
||||
if let Some(token) = self.session_lock.idle_timer.take() {
|
||||
self.loop_handle.remove(token);
|
||||
}
|
||||
fn lock_idle_deadline(&self, now: Instant) -> Option<Instant> {
|
||||
if !self.lock_outputs_protected() {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
|
||||
let elapsed = self
|
||||
.session_lock
|
||||
.idle_since
|
||||
.map_or(Duration::ZERO, |since| since.elapsed());
|
||||
let Some(delay) = self
|
||||
.lock_idle
|
||||
.map_or(Duration::ZERO, |since| now.saturating_duration_since(since));
|
||||
self.lock_idle
|
||||
.next_deadline(elapsed, self.animation_duration(Duration::from_millis(500)))
|
||||
else {
|
||||
.map(|delay| now + delay)
|
||||
}
|
||||
|
||||
fn rearm_lock_idle(&mut self) {
|
||||
let deadline = self.lock_idle_deadline(Instant::now());
|
||||
if let Some((_, armed)) = self.session_lock.idle_timer
|
||||
&& deadline.is_some_and(|deadline| armed <= deadline)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some((token, _)) = self.session_lock.idle_timer.take() {
|
||||
self.loop_handle.remove(token);
|
||||
}
|
||||
|
||||
let Some(deadline) = deadline else {
|
||||
return;
|
||||
};
|
||||
match self
|
||||
.loop_handle
|
||||
.insert_source(Timer::from_duration(delay), |_, _, state| {
|
||||
.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.refresh_lock_idle_policy();
|
||||
TimeoutAction::Drop
|
||||
}
|
||||
}) {
|
||||
Ok(token) => self.session_lock.idle_timer = Some(token),
|
||||
Ok(token) => self.session_lock.idle_timer = Some((token, deadline)),
|
||||
Err(error) => tracing::warn!(%error, "could not arm lock idle deadline"),
|
||||
}
|
||||
}
|
||||
@@ -251,8 +277,17 @@ impl Ferese {
|
||||
let surface = self
|
||||
.focused_output()
|
||||
.and_then(|output| self.session_lock.surfaces.get(output))
|
||||
.or_else(|| self.session_lock.surfaces.values().find(|surface| surface.alive()))
|
||||
.filter(|surface| surface.alive())
|
||||
.or_else(|| {
|
||||
// Stable output IDs keep fallback focus independent of HashMap order.
|
||||
self.session_lock
|
||||
.surfaces
|
||||
.iter()
|
||||
.filter(|(_, surface)| surface.alive())
|
||||
.filter_map(|(output, surface)| self.output_id(output).map(|id| (id, surface)))
|
||||
.min_by_key(|(id, _)| *id)
|
||||
.map(|(_, surface)| surface)
|
||||
})
|
||||
.map(|surface| surface.wl_surface().clone());
|
||||
self.seat
|
||||
.get_keyboard()
|
||||
@@ -378,7 +413,7 @@ impl SessionLockHandler for Ferese {
|
||||
}
|
||||
|
||||
fn unlock(&mut self) {
|
||||
if let Some(timer) = self.session_lock.idle_timer.take() {
|
||||
if let Some((timer, _)) = self.session_lock.idle_timer.take() {
|
||||
self.loop_handle.remove(timer);
|
||||
}
|
||||
let sleeping = self.session_lock.sleeping;
|
||||
@@ -456,346 +491,4 @@ smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockMa
|
||||
smithay::reexports::wayland_server::delegate_dispatch!(Ferese: [ExtSessionLockSurfaceV1: smithay::wayland::session_lock::ExtLockSurfaceUserData] => SessionLockManagerState);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a private XDG_RUNTIME_DIR and permission to bind test sockets"]
|
||||
fn zero_outputs_confirm_and_dead_owner_can_be_replaced_without_unlocking() {
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Arc;
|
||||
|
||||
use smithay::reexports::calloop::EventLoop;
|
||||
use smithay::reexports::wayland_server::Display;
|
||||
|
||||
let runtime = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR").unwrap());
|
||||
assert!(runtime.starts_with(std::env::temp_dir()));
|
||||
let mut event_loop = EventLoop::try_new().unwrap();
|
||||
let config = crate::config::Config::default().runtime_config().unwrap();
|
||||
let mut state = Ferese::new(&mut event_loop, Display::new().unwrap(), config).unwrap();
|
||||
let capture_epoch = state.capture_epoch;
|
||||
|
||||
let acquire = |state: &mut Ferese| {
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
.insert_client(server, Arc::new(crate::state::ClientState::default()))
|
||||
.unwrap();
|
||||
let manager = client
|
||||
.create_resource::<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()
|
||||
);
|
||||
}
|
||||
}
|
||||
mod tests;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ mod layout;
|
||||
mod navigation;
|
||||
mod outputs;
|
||||
mod reconciliation;
|
||||
#[cfg(test)]
|
||||
mod window_lifecycle_tests;
|
||||
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
||||
mod prediction;
|
||||
mod presentation_dependencies;
|
||||
@@ -478,6 +480,7 @@ pub struct Ferese {
|
||||
pub direct_backend: Option<DirectBackendState>,
|
||||
_ipc_socket: Option<IpcSocketGuard>,
|
||||
pending_dmabuf_imports: Vec<(Dmabuf, ImportNotifier)>,
|
||||
pub(crate) activation_inputs: crate::handlers::activation::InputHistory,
|
||||
pub(crate) pending_screencopies: Vec<PendingScreencopy>,
|
||||
// Screenshot requests outlive the readback that filled them: the
|
||||
// coordinator owns them until encoding finishes and the caller is
|
||||
@@ -721,6 +724,7 @@ impl Ferese {
|
||||
direct_backend: None,
|
||||
_ipc_socket: None,
|
||||
pending_dmabuf_imports: Vec::new(),
|
||||
activation_inputs: Default::default(),
|
||||
pending_screencopies: Vec::new(),
|
||||
screenshot: Coordinator::new(),
|
||||
screenshot_parts: None,
|
||||
|
||||
@@ -138,4 +138,4 @@ impl Ferese {
|
||||
|
||||
#[cfg(test)]
|
||||
#[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::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]
|
||||
.into_iter()
|
||||
.chain(args.iter().copied())
|
||||
@@ -54,12 +54,12 @@ fn request(wire: &mut UnixStream, object: u32, opcode: u32, args: &[u32], fd: Op
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) {
|
||||
pub(crate) fn dispatch(events: &mut EventLoop<'static, Ferese>, state: &mut Ferese) {
|
||||
events.dispatch(Duration::from_millis(1), state).unwrap();
|
||||
state.display_handle.flush_clients().unwrap();
|
||||
}
|
||||
|
||||
fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
|
||||
pub(crate) fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
|
||||
wire.set_nonblocking(true).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let _ = wire.read_to_end(&mut bytes);
|
||||
@@ -79,7 +79,7 @@ fn ack_configure(wire: &mut UnixStream, xdg: u32) -> u32 {
|
||||
serial.unwrap()
|
||||
}
|
||||
|
||||
fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
|
||||
pub(crate) fn window(state: &mut Ferese, events: &mut EventLoop<'static, Ferese>, color: u32) -> (Window, UnixStream) {
|
||||
let (server, mut wire) = UnixStream::pair().unwrap();
|
||||
let client = state
|
||||
.display_handle
|
||||
@@ -208,7 +208,7 @@ fn pixels(
|
||||
Rectangle::from_size((320, 240).into()),
|
||||
));
|
||||
let mut target = Some(texture.clone());
|
||||
crate::backends::direct::capture::capture_output(state, renderer, &mut target, output, &scene).unwrap();
|
||||
crate::backends::direct::capture::capture_output(state, renderer, &mut target, output, &scene);
|
||||
let mut result = receiver.try_recv().unwrap().result.unwrap().pixels;
|
||||
for pixel in result.chunks_exact_mut(4) {
|
||||
pixel.swap(0, 2);
|
||||
@@ -424,8 +424,7 @@ fn capture_privacy_pixels_and_policy_transitions() {
|
||||
&mut Some(capture_texture.clone()),
|
||||
&output,
|
||||
&scene,
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
let region = receiver.try_recv().unwrap().result.unwrap();
|
||||
assert_eq!((region.width, region.height), (160, 120));
|
||||
assert!(
|
||||
|
||||
@@ -246,12 +246,14 @@ impl Ferese {
|
||||
|
||||
let is_maximized =
|
||||
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id);
|
||||
let placement = self.workspaces.placement(*id);
|
||||
let is_floating = matches!(placement, Some(WindowPlacement::Floating { .. }));
|
||||
let rect = if workspace_fullscreen == Some(*id) {
|
||||
fullscreen_bounds
|
||||
} else if is_maximized && !is_scrolling_layout {
|
||||
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
||||
maximized_rect(bounds, self.gap_config.outer)
|
||||
} else {
|
||||
match self.workspaces.placement(*id) {
|
||||
match placement {
|
||||
Some(WindowPlacement::Tiled) => {
|
||||
let Some(rect) = layout.geometry.get(id).copied() else {
|
||||
continue;
|
||||
@@ -271,7 +273,6 @@ impl Ferese {
|
||||
};
|
||||
|
||||
let is_fullscreen = workspace_fullscreen == Some(*id);
|
||||
let is_floating = matches!(self.workspaces.placement(*id), Some(WindowPlacement::Floating { .. }));
|
||||
|
||||
visible.insert(*id);
|
||||
let scrolling = if !is_fullscreen && !is_floating && (!is_maximized || is_scrolling_layout) {
|
||||
|
||||
@@ -111,8 +111,6 @@ impl Ferese {
|
||||
true
|
||||
}
|
||||
});
|
||||
self.session_lock.surfaces.remove(output);
|
||||
self.session_lock.backgrounds.remove(output);
|
||||
self.session_lock.output_removed(output);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,6 +1,20 @@
|
||||
use super::*;
|
||||
|
||||
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) {
|
||||
let Some(toplevel) = window.toplevel() else { return };
|
||||
let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| {
|
||||
@@ -449,17 +463,10 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub(crate) fn capture_resize_before_commit(&mut self, surface: &WlSurface) {
|
||||
let Some((window, id)) = self
|
||||
.windows
|
||||
.ids()
|
||||
.iter()
|
||||
.find(|(window, _)| {
|
||||
window
|
||||
.toplevel()
|
||||
.is_some_and(|toplevel| toplevel.wl_surface() == surface)
|
||||
})
|
||||
.map(|(window, id)| (window.clone(), *id))
|
||||
else {
|
||||
let Some(id) = self.windows.id_for_surface(surface) else {
|
||||
return;
|
||||
};
|
||||
let Some(window) = self.windows.window(id).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ static SYSTEM_APPEARANCE: std::sync::OnceLock<Mutex<Option<ferese_config::theme:
|
||||
|
||||
fn auto_context() -> ferese_config::theme::AutoContext {
|
||||
let appearance = SYSTEM_APPEARANCE.get_or_init(|| {
|
||||
let value = std::process::Command::new("gsettings")
|
||||
let value = crate::process::command("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
.ok()
|
||||
@@ -323,7 +323,7 @@ pub(crate) fn init(
|
||||
.spawn(move || {
|
||||
use std::io::BufRead;
|
||||
use std::os::unix::process::CommandExt;
|
||||
let mut command = std::process::Command::new("gsettings");
|
||||
let mut command = crate::process::command("gsettings");
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 {
|
||||
@@ -338,7 +338,7 @@ pub(crate) fn init(
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn();
|
||||
if let Ok(mut monitor) = monitor {
|
||||
if let Ok(output) = std::process::Command::new("gsettings")
|
||||
if let Ok(output) = crate::process::command("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
{
|
||||
@@ -430,8 +430,7 @@ impl Ferese {
|
||||
self.poll_theme();
|
||||
return;
|
||||
}
|
||||
let wallpaper = (self.wallpaper.configuration() != &wallpaper)
|
||||
.then(|| WallpaperState::with_wakeup(wallpaper, Some(self.loop_signal.clone())));
|
||||
let wallpaper = (self.wallpaper.configuration() != &wallpaper).then(|| self.wallpaper.replacement(wallpaper));
|
||||
self.theme_engine.pending = Some(Pending {
|
||||
candidate,
|
||||
wallpaper,
|
||||
|
||||
+102
-32
@@ -18,8 +18,11 @@ use smithay::utils::{Buffer, Physical, Rectangle, Size};
|
||||
|
||||
use crate::presentation::NativeTextureElement;
|
||||
|
||||
mod decoder;
|
||||
mod pixels;
|
||||
use decoder::Decoder;
|
||||
use pixels::Pixels;
|
||||
use std::sync::Arc;
|
||||
|
||||
const UPLOAD_RETRY_DELAY: Duration = Duration::from_secs(5);
|
||||
|
||||
@@ -59,6 +62,8 @@ struct WallpaperTexture {
|
||||
pub(crate) struct WallpaperState {
|
||||
config: WallpaperConfig,
|
||||
pending: Option<WallpaperConfig>,
|
||||
loading: Option<WallpaperConfig>,
|
||||
decoder: Arc<Decoder>,
|
||||
commit: CommitCounter,
|
||||
owned: bool,
|
||||
mode: WallpaperMode,
|
||||
@@ -78,26 +83,35 @@ impl WallpaperState {
|
||||
}
|
||||
|
||||
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 (sender, receiver) = mpsc::channel();
|
||||
let owned = config.path.as_ref().is_some_and(|path| path.is_file());
|
||||
if owned {
|
||||
let path = config.path.unwrap();
|
||||
let wakeup = wakeup.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = sender.send(pixels::load(&path));
|
||||
if let Some(wakeup) = wakeup {
|
||||
wakeup.wakeup();
|
||||
}
|
||||
});
|
||||
}
|
||||
let receiver = if owned {
|
||||
Some(decoder.submit(config.path.clone().unwrap(), wakeup.clone()))
|
||||
} else {
|
||||
decoder.cancel_pending();
|
||||
None
|
||||
};
|
||||
Self {
|
||||
config: retained,
|
||||
pending: None,
|
||||
loading: None,
|
||||
decoder,
|
||||
commit: CommitCounter::default(),
|
||||
owned,
|
||||
mode: config.mode,
|
||||
receiver: owned.then_some(receiver),
|
||||
receiver,
|
||||
pixels: None,
|
||||
textures: HashMap::new(),
|
||||
upload_retries: HashMap::new(),
|
||||
@@ -121,8 +135,22 @@ impl WallpaperState {
|
||||
}
|
||||
|
||||
pub fn reload(&mut self, config: WallpaperConfig) {
|
||||
if self.config.path != config.path && config.path.as_ref().is_some_and(|path| !path.is_file()) {
|
||||
tracing::warn!("new wallpaper is unavailable; retaining previous image");
|
||||
return;
|
||||
}
|
||||
|
||||
if self.receiver.is_some() {
|
||||
self.pending = (self.config != config).then_some(config);
|
||||
if self.loading.is_some() && self.config == config {
|
||||
self.receiver = None;
|
||||
self.loading = None;
|
||||
self.pending = None;
|
||||
self.decoder.cancel_pending();
|
||||
return;
|
||||
}
|
||||
|
||||
let requested = self.loading.as_ref().unwrap_or(&self.config);
|
||||
self.pending = (requested != &config).then_some(config);
|
||||
return;
|
||||
}
|
||||
if self.config == config {
|
||||
@@ -134,19 +162,18 @@ impl WallpaperState {
|
||||
self.commit.increment();
|
||||
return;
|
||||
}
|
||||
if config.path.as_ref().is_some_and(|path| !path.is_file()) {
|
||||
tracing::warn!("new wallpaper is unavailable; retaining previous image");
|
||||
return;
|
||||
}
|
||||
let mut replacement = Self::with_wakeup(config, self.wakeup.clone());
|
||||
self.config = replacement.config;
|
||||
self.mode = replacement.mode;
|
||||
self.owned = replacement.owned;
|
||||
self.receiver = replacement.receiver.take();
|
||||
if self.config.path.is_none() {
|
||||
if let Some(path) = config.path.clone() {
|
||||
self.receiver = Some(self.decoder.submit(path, self.wakeup.clone()));
|
||||
self.loading = Some(config);
|
||||
} else {
|
||||
self.decoder.cancel_pending();
|
||||
self.config = config;
|
||||
self.mode = self.config.mode;
|
||||
self.owned = false;
|
||||
self.pixels = None;
|
||||
self.textures.clear();
|
||||
self.upload_retries.clear();
|
||||
self.commit.increment();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,16 +232,15 @@ impl WallpaperState {
|
||||
match receiver.try_recv() {
|
||||
Ok(result) => {
|
||||
self.receiver = None;
|
||||
if let Some(pending) = self.pending.take() {
|
||||
if pending.path != self.config.path {
|
||||
self.reload(pending);
|
||||
return true;
|
||||
}
|
||||
self.config = pending;
|
||||
self.mode = self.config.mode;
|
||||
}
|
||||
let loading = self.loading.take();
|
||||
match result {
|
||||
Ok(pixels) => {
|
||||
if let Some(config) = loading {
|
||||
self.config = config;
|
||||
self.mode = self.config.mode;
|
||||
self.owned = true;
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
width = pixels.width(),
|
||||
height = pixels.height(),
|
||||
@@ -227,13 +253,17 @@ impl WallpaperState {
|
||||
self.commit.increment();
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "wallpaper unavailable; using output clear color")
|
||||
tracing::warn!(%error, "wallpaper decode failed; retaining previous image")
|
||||
}
|
||||
}
|
||||
if let Some(pending) = self.pending.take() {
|
||||
self.reload(pending);
|
||||
}
|
||||
true
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.receiver = None;
|
||||
self.loading = None;
|
||||
false
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => false,
|
||||
@@ -323,6 +353,46 @@ fn image_geometry(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn failed_reload_preserves_accepted_config_and_unavailable_pending_keeps_success() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut state = WallpaperState::new(WallpaperConfig {
|
||||
path: None,
|
||||
mode: WallpaperMode::Fill,
|
||||
});
|
||||
let accepted = state.config.clone();
|
||||
state.pixels = Some(pixels::from_rgba(image::RgbaImage::new(2, 2)).unwrap());
|
||||
let bad = directory.path().join("bad-image");
|
||||
std::fs::write(&bad, b"not an image").unwrap();
|
||||
state.reload(WallpaperConfig {
|
||||
path: Some(bad),
|
||||
mode: WallpaperMode::Fit,
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while !state.poll() {
|
||||
assert!(Instant::now() < deadline);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
|
||||
assert_eq!(state.config, accepted);
|
||||
assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (2, 2));
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
state.receiver = Some(receiver);
|
||||
state.reload(WallpaperConfig {
|
||||
path: Some(directory.path().join("missing")),
|
||||
mode: WallpaperMode::Fit,
|
||||
});
|
||||
sender
|
||||
.send(Ok(pixels::from_rgba(image::RgbaImage::new(3, 4)).unwrap()))
|
||||
.unwrap();
|
||||
assert!(state.poll());
|
||||
assert_eq!(state.pixels.as_ref().unwrap().dimensions(), (3, 4));
|
||||
assert_eq!(state.config, accepted);
|
||||
|
||||
let replacement = state.replacement(accepted);
|
||||
assert!(Arc::ptr_eq(&state.decoder, &replacement.decoder));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portal_wallpaper_decodes_without_a_filename_extension() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
&mut capture_texture,
|
||||
&output,
|
||||
&scene,
|
||||
)?;
|
||||
);
|
||||
capture_changed_binding = true;
|
||||
}
|
||||
if capture_changed_binding {
|
||||
|
||||
@@ -83,6 +83,30 @@ cargo test --locked -p ferese backends::direct::capture::tests -- --ignored
|
||||
cargo test --locked -p ferese mirrored_pixels_fit -- --ignored
|
||||
```
|
||||
|
||||
## Session lock checks
|
||||
|
||||
```sh
|
||||
cargo test --release --locked -p ferese session_lock::tests -- --nocapture
|
||||
```
|
||||
|
||||
These tests create private runtime directories and Wayland clients. They run in
|
||||
normal test suites and in the compositor lifecycle CI job, without a host desktop or DRM.
|
||||
They cover confirmation, rejected unlocks, owner death and replacement, output
|
||||
removal, keyboard focus and idle timers. Physical display protection still needs
|
||||
hardware testing.
|
||||
|
||||
## Resume listener checks
|
||||
|
||||
```sh
|
||||
cargo test --release --locked -p ferese resume::tests -- --nocapture
|
||||
```
|
||||
|
||||
These tests need `dbus-daemon`. They use a private bus to test missed resumes,
|
||||
logind replacement and listener shutdown during connection setup, signal waits
|
||||
and retries. They run in the compositor lifecycle CI job and never contact system logind.
|
||||
Recovery requests fresh hardware and theme state without resetting idle activity.
|
||||
Hardware suspend/resume still needs testing on a direct session.
|
||||
|
||||
## Idle inhibition checks
|
||||
|
||||
The playback test runs a fake MPRIS player on a private bus and opens a client in
|
||||
@@ -131,3 +155,49 @@ another VT, suspend/resume with a dock disconnected, change the lid while
|
||||
suspended, and unplug/reconnect the managed GPU where supported. Verify current
|
||||
output geometry, idle notifications, recovery after failures and continued lock
|
||||
protection. Nested sessions do not validate DRM reacquisition.
|
||||
|
||||
## Capture and DRM failure checks
|
||||
|
||||
The compositor tests cover screenshot admission across requests, cancellation
|
||||
while buffers remain owned, staging-file cleanup, activation serials, child
|
||||
reaping and wallpaper decode coalescing:
|
||||
|
||||
```sh
|
||||
cargo test --release --locked -p ferese
|
||||
```
|
||||
|
||||
Screenshot requests share a 256 MiB readback reservation. The reservation follows
|
||||
pending readbacks, queued results and encoder jobs, including after cancellation.
|
||||
It does not include GPU targets, the encoder's composition canvas or encoded PNG
|
||||
bytes; it is not a cap on total process memory.
|
||||
|
||||
The offscreen pixel tests need EGL. CI runs them with software Mesa and private
|
||||
Wayland sockets:
|
||||
|
||||
```sh
|
||||
LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless cargo test --release --locked -p ferese state::capture_privacy::tests -- --ignored
|
||||
LIBGL_ALWAYS_SOFTWARE=1 EGL_PLATFORM=surfaceless cargo test --release --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored
|
||||
```
|
||||
|
||||
A retirement error or a frame pending for two seconds triggers DRM device
|
||||
reconciliation. Ferese keeps the frame marked pending until device teardown;
|
||||
clearing that flag alone would leave Smithay's buffer ownership uncertain.
|
||||
The watchdog keeps one timer per output while frames are flowing. Session pause
|
||||
and output removal cancel it.
|
||||
|
||||
To exercise both failures on hardware, build with the test feature and start
|
||||
Ferese from an unused VT. These commands each inject one failure. Device
|
||||
recreation may briefly blank the displays; do not run them inside your active
|
||||
desktop.
|
||||
|
||||
```sh
|
||||
cargo build --release --locked -p ferese --features drm-fault-injection
|
||||
FERESE_TEST_DRM_FAILURE=retirement target/release/ferese --backend drm
|
||||
FERESE_TEST_DRM_FAILURE=missing-completion target/release/ferese --backend drm
|
||||
```
|
||||
|
||||
Run them separately. Check that redraws resume, connected outputs retain their
|
||||
identities, windows remain reachable, and lock protection survives the recovery.
|
||||
The unit tests check retirement-result handling and pending-frame ownership;
|
||||
they cannot prove hardware recovery succeeds. The installer leaves fault
|
||||
injection disabled.
|
||||
Reference in new issue
Block a user