diff --git a/.github/workflows/compositor-lifecycle.yml b/.github/workflows/compositor-lifecycle.yml index c3e9633..7d57da6 100644 --- a/.github/workflows/compositor-lifecycle.yml +++ b/.github/workflows/compositor-lifecycle.yml @@ -1,9 +1,18 @@ -name: Compositor lifecycle and capture tests +name: Workspace release checks on: pull_request: paths: - "compositor/**" - "crates/**" + - "shell/**" + - "portal/**" + - "feresectl/**" + - "scripts/**" + - "packaging/**" + - "assets/**" + - "LICENSE" + - "rust-toolchain.toml" + - "rustfmt.toml" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/compositor-lifecycle.yml" @@ -12,6 +21,15 @@ on: paths: - "compositor/**" - "crates/**" + - "shell/**" + - "portal/**" + - "feresectl/**" + - "scripts/**" + - "packaging/**" + - "assets/**" + - "LICENSE" + - "rust-toolchain.toml" + - "rustfmt.toml" - "Cargo.toml" - "Cargo.lock" - ".github/workflows/compositor-lifecycle.yml" @@ -45,3 +63,39 @@ jobs: cargo +1.95.0 test --locked -p ferese state::capture_privacy::tests -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese backends::direct::capture::tests::capture_recomposes_cursor_without_touching_the_display_target -- --ignored --nocapture cargo +1.95.0 test --locked -p ferese winit::tests::capture_failure_restores_display_target_and_subsequent_frames_render -- --ignored --nocapture + + workspace: + runs-on: ubuntu-24.04 + container: fedora:44 + timeout-minutes: 60 + env: + CARGO_BUILD_JOBS: "2" + CARGO_INCREMENTAL: "0" + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" + steps: + - name: Install desktop build and test dependencies + run: | + dnf install -y --allowerasing python3 python3-gobject python3-pillow git curl gcc gcc-c++ make cmake pkgconf-pkg-config \ + wayland-devel wayland-protocols-devel libxkbcommon-devel libinput-devel systemd-devel libseat-devel \ + mesa-libgbm-devel mesa-libEGL-devel mesa-dri-drivers libdrm-devel fontconfig-devel \ + freetype-devel expat-devel dbus-daemon dbus-tools desktop-file-utils foot pam \ + clang clang-devel pipewire-devel pipewire wireplumber polkit polkit-libs libei \ + xdg-desktop-portal xdg-desktop-portal-gtk \ + gstreamer1-devel gstreamer1-plugins-base gstreamer1-plugins-good pipewire-gstreamer + - uses: actions/checkout@v6 + - name: Install pinned Rust and checks + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup.sh + sh /tmp/rustup.sh -y --profile minimal --default-toolchain 1.95.0 --component rustfmt --component clippy + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + - name: Check formatting and all workspace lints + run: | + cargo fmt --all --check + cargo clippy --locked --workspace --all-targets -- -D warnings + - name: Build desktop binaries and test the complete workspace + run: | + cargo build --locked --workspace + cargo test --locked --workspace --no-fail-fast + - name: Test installer, session recovery and private portal contracts + run: python3 -m unittest discover -s scripts/tests -v diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..54b1264 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ferese contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/compositor/src/backends/mod.rs b/compositor/src/backends/mod.rs index 70f4106..0491909 100644 --- a/compositor/src/backends/mod.rs +++ b/compositor/src/backends/mod.rs @@ -21,6 +21,7 @@ pub struct LaunchConfig { pub(crate) session_policy: SessionPolicy, pub client: Vec, pub client_capabilities: ClientCapabilities, + pub supervise_client: bool, } impl LaunchConfig { @@ -35,6 +36,7 @@ impl LaunchConfig { let mut requested_backend = None; let mut client = Vec::new(); let mut client_capabilities = ClientCapabilities::default(); + let mut supervise_client = false; while let Some(argument) = args.next() { if argument == "--" { @@ -52,6 +54,10 @@ impl LaunchConfig { client_capabilities.insert(ClientCapabilities::EFFECTS); continue; } + if argument == "--supervise-client" { + supervise_client = true; + continue; + } if argument == "--grant-shell-control" { client_capabilities.insert(ClientCapabilities::SHELL_CONTROL); continue; @@ -75,6 +81,9 @@ impl LaunchConfig { BackendKind::Drm }); + if supervise_client && client.is_empty() { + return Err("--supervise-client requires a client command".into()); + } Ok(Self { backend, session_policy: match backend { @@ -83,6 +92,7 @@ impl LaunchConfig { }, client, client_capabilities, + supervise_client, }) } } @@ -119,6 +129,26 @@ mod tests { assert_eq!(config.session_policy, SessionPolicy::Embedded); assert_eq!(config.client, [OsString::from("foot")]); assert!(config.client_capabilities.is_empty()); + assert!(!config.supervise_client); + } + + #[test] + fn supervision_requires_an_explicit_client() { + assert!(LaunchConfig::parse([OsString::from("--supervise-client")], true).is_err()); + let config = LaunchConfig::parse( + [ + "--supervise-client", + "--grant-shell-control", + "--", + "ferese-session-shell", + ] + .map(OsString::from), + true, + ) + .unwrap(); + assert!(config.supervise_client); + assert_eq!(config.client, [OsString::from("ferese-session-shell")]); + assert!(config.client_capabilities.contains(ClientCapabilities::SHELL_CONTROL)); } #[test] diff --git a/compositor/src/grabs/resize_grab.rs b/compositor/src/grabs/resize_grab.rs index f907c10..df80094 100644 --- a/compositor/src/grabs/resize_grab.rs +++ b/compositor/src/grabs/resize_grab.rs @@ -150,11 +150,8 @@ impl PointerGrab for ResizeSurfaceGrab { let state = cached.current(); (state.min_size, state.max_size) }); - let (minimum, maximum) = data.effective_size_constraints( - &self.window, - (minimum.w, minimum.h), - (maximum.w, maximum.h), - ); + let (minimum, maximum) = + data.effective_size_constraints(&self.window, (minimum.w, minimum.h), (maximum.w, maximum.h)); self.last_size = constrained_size( self.initial_rect.size, delta, diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index 7175b4a..03f3c0c 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -11,7 +11,9 @@ use std::time::{Duration, Instant}; use std::{fs, io, thread}; use ferese_core::LayoutMode; -use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; +use ferese_ipc::{Request, Response, VERSION}; + +mod transport; use ferese_layout::Direction; use serde_json::{Value, json}; use smithay::output::Output; @@ -478,8 +480,10 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender request, Err(ferese_ipc::FrameError::Io(error)) if matches!( @@ -502,7 +506,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender Response::error(request.id, "invalid_config", error), }, }; - if write_frame(&mut stream, &response).is_err() { + if transport::write_response(&stream, &response).is_err() { return; } continue; @@ -511,6 +515,14 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender { - if write_frame(stream, event.as_ref()).is_err() { + if transport::write_response(stream, event.as_ref()).is_err() { return; } } @@ -1810,6 +1824,38 @@ mod tests { assert_eq!(peer_uid(&right).unwrap(), effective_uid()); } + #[test] + fn silent_clients_release_all_connection_slots_after_the_initial_deadline() { + let active = Arc::new(AtomicUsize::new(0)); + let (sender, _receiver) = channel::sync_channel(MAX_CONNECTIONS); + let event_loop = EventLoop::<()>::try_new().unwrap(); + let mut clients = Vec::new(); + let mut workers = Vec::new(); + for owner in 0..MAX_CONNECTIONS { + let (client, server) = UnixStream::pair().unwrap(); + clients.push(client); + let permit = try_acquire_connection(&active).unwrap(); + let sender = sender.clone(); + let signal = event_loop.get_signal(); + workers.push(thread::spawn(move || { + let _permit = permit; + serve_connection(server, sender, signal, owner as u64); + })); + } + assert!(try_acquire_connection(&active).is_none()); + let deadline = Instant::now() + transport::FRAME_TIMEOUT + Duration::from_secs(3); + while active.load(Ordering::Acquire) != 0 { + assert!(Instant::now() < deadline, "silent peers retained connection slots"); + thread::sleep(Duration::from_millis(20)); + } + assert!(try_acquire_connection(&active).is_some()); + // Clients deliberately stay open: the server must recover on its own. + assert_eq!(clients.len(), MAX_CONNECTIONS); + for worker in workers { + worker.join().unwrap(); + } + } + #[test] fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() { let (mut client, server) = UnixStream::pair().unwrap(); diff --git a/compositor/src/ipc/transport.rs b/compositor/src/ipc/transport.rs new file mode 100644 index 0000000..0931699 --- /dev/null +++ b/compositor/src/ipc/transport.rs @@ -0,0 +1,123 @@ +//! Bound complete frames, including peers that trickle bytes before each timeout. +use std::io::{self, Read, Write}; +use std::os::unix::net::UnixStream; +use std::time::{Duration, Instant}; + +use ferese_ipc::{FrameError, Request}; + +pub(super) const FRAME_TIMEOUT: Duration = Duration::from_secs(5); +pub(super) const IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const WRITE_TIMEOUT: Duration = Duration::from_secs(2); + +struct DeadlineIo<'a> { + stream: &'a UnixStream, + deadline: Instant, +} + +impl DeadlineIo<'_> { + fn remaining(&self) -> io::Result { + self.deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "IPC frame deadline expired")) + } +} + +impl Read for DeadlineIo<'_> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.stream.set_read_timeout(Some(self.remaining()?))?; + self.stream.read(bytes) + } +} + +impl Write for DeadlineIo<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.stream.set_write_timeout(Some(self.remaining()?))?; + self.stream.write(bytes) + } + + fn flush(&mut self) -> io::Result<()> { + self.stream.flush() + } +} + +pub(super) fn read_request(stream: &UnixStream, idle: Option) -> Result { + read_request_with_budget(stream, idle, FRAME_TIMEOUT) +} + +fn read_request_with_budget( + mut stream: &UnixStream, + idle: Option, + budget: Duration, +) -> Result { + stream.set_read_timeout(idle)?; + let mut first = [0]; + stream.read_exact(&mut first)?; + let reader = DeadlineIo { + stream, + deadline: Instant::now() + budget, + }; + ferese_ipc::read_frame(&mut first.as_slice().chain(reader)) +} + +pub(super) fn write_response(stream: &UnixStream, value: &impl serde::Serialize) -> Result<(), FrameError> { + // Tests may install a tighter socket timeout. + let previous = stream.write_timeout()?; + let budget = previous.unwrap_or(WRITE_TIMEOUT).min(WRITE_TIMEOUT); + let result = ferese_ipc::write_frame( + &mut DeadlineIo { + stream, + deadline: Instant::now() + budget, + }, + value, + ); + stream.set_write_timeout(previous)?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn silent_and_partial_clients_expire_but_lease_idle_is_allowed() { + let (mut client, server) = UnixStream::pair().unwrap(); + let short = Duration::from_millis(40); + assert!(read_request_with_budget(&server, Some(short), short).is_err()); + client.write_all(&[0, 0]).unwrap(); + assert!(read_request_with_budget(&server, Some(short), short).is_err()); + + let (mut client, server) = UnixStream::pair().unwrap(); + let worker = std::thread::spawn(move || read_request_with_budget(&server, None, short).unwrap()); + std::thread::sleep(short * 3); + let request = Request { + version: 1, + id: 9, + kind: "command".into(), + command: "get-outputs".into(), + args: serde_json::json!({}), + }; + ferese_ipc::write_frame(&mut client, &request).unwrap(); + assert_eq!(worker.join().unwrap().id, 9); + } + + #[test] + fn trickling_does_not_extend_a_frame_deadline() { + let (mut client, server) = UnixStream::pair().unwrap(); + let worker = std::thread::spawn(move || { + for byte in [0, 0, 0, 100, b'{', b'"', b'a'] { + if client.write_all(&[byte]).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(30)); + } + }); + let error = + read_request_with_budget(&server, Some(Duration::from_secs(1)), Duration::from_millis(70)).unwrap_err(); + assert!( + matches!(error, FrameError::Io(error) if matches!(error.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock)) + ); + drop(server); + worker.join().unwrap(); + } +} diff --git a/compositor/src/ipc_events.rs b/compositor/src/ipc_events.rs index 1f0bd49..a9c5f94 100644 --- a/compositor/src/ipc_events.rs +++ b/compositor/src/ipc_events.rs @@ -71,7 +71,7 @@ impl Subscribers { generation: self.generation, last: true, change: Change::Snapshot { - desktop: snapshot.clone(), + desktop: Box::new(snapshot.clone()), }, }) }); diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 26a177b..c80a9df 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -122,8 +122,22 @@ fn main() -> Result<(), Box> { info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); - let child = spawn_client(&mut state, launch.client, launch.client_capabilities) - .map(|child| watch_client_exit(&state.loop_handle, child)); + let (supervisor, child) = if launch.supervise_client { + ( + Some(process::Supervisor::start( + &mut state, + launch.client, + launch.client_capabilities, + )), + None, + ) + } else { + ( + None, + 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 result = event_loop.run(None, &mut state, after_dispatch); @@ -136,6 +150,9 @@ fn main() -> Result<(), Box> { state.resize_metrics.dump(); runner.borrow_mut().stop(); + if let Some(supervisor) = supervisor { + supervisor.borrow_mut().stop(&state.loop_handle); + } if let Some(child) = child && let Some(mut child) = child.borrow_mut().take() { diff --git a/compositor/src/process.rs b/compositor/src/process.rs index baf38f4..d9ea4ee 100644 --- a/compositor/src/process.rs +++ b/compositor/src/process.rs @@ -15,6 +15,9 @@ use tracing::{info, warn}; use crate::Ferese; use crate::private_client::{self, ClientCapabilities}; +mod supervisor; +pub(crate) use supervisor::Supervisor; + pub(crate) fn command(program: impl AsRef) -> Command { let mut command = Command::new(program); // Calloop blocks these on the compositor thread; fork/exec inherits that @@ -41,6 +44,15 @@ pub(crate) fn spawn_client>( state: &mut Ferese, args: impl IntoIterator, capabilities: ClientCapabilities, +) -> Option { + spawn_client_inner(state, args, capabilities, None) +} + +fn spawn_client_inner>( + state: &mut Ferese, + args: impl IntoIterator, + capabilities: ClientCapabilities, + supervised_restart: Option, ) -> Option { let mut args = args.into_iter(); let Some(program) = args.next() else { @@ -51,6 +63,10 @@ pub(crate) fn spawn_client>( let program = program.as_ref(); let mut command = command(program); command.args(args); + if let Some(restarted) = supervised_restart { + command.process_group(0); + command.env("FERESE_CLIENT_RESTART", if restarted { "1" } else { "0" }); + } // Workers may already exist. Change only this child's environment. // Apply before granting the child a private Wayland connection. state.session_environment.apply_public(&mut command); diff --git a/compositor/src/process/supervisor.rs b/compositor/src/process/supervisor.rs new file mode 100644 index 0000000..c11385c --- /dev/null +++ b/compositor/src/process/supervisor.rs @@ -0,0 +1,288 @@ +//! Supervise the explicitly opted-in desktop helper. Each generation gets new +//! private Wayland connections; a dead generation's descriptors are never reused. +use super::*; +use calloop::RegistrationToken; +use std::ffi::OsString; +use std::time::Instant; + +pub(crate) struct Supervisor { + args: Vec, + capabilities: ClientCapabilities, + child: Option, + source: Option, + failures: u32, + started: Instant, + restarted: bool, + stopped: bool, +} + +impl Supervisor { + pub(crate) fn start( + state: &mut Ferese, + args: Vec, + capabilities: ClientCapabilities, + ) -> Rc> { + let owner = Rc::new(RefCell::new(Self { + args, + capabilities, + child: None, + source: None, + failures: 0, + started: Instant::now(), + restarted: false, + stopped: false, + })); + Self::launch(&owner, state); + owner + } + + fn launch(owner: &Rc>, state: &mut Ferese) { + let mut this = owner.borrow_mut(); + if this.stopped { + return; + } + this.started = Instant::now(); + this.child = spawn_client_inner(state, &this.args, this.capabilities, Some(this.restarted)); + this.restarted |= this.child.is_some(); + let pid = this.child.as_ref().map(Child::id); + drop(this); + if let Some(pid) = pid { + let weak = Rc::downgrade(owner); + let watched = pidfd(pid).and_then(|fd| { + state + .loop_handle + .insert_source(Generic::new(fd, Interest::READ, Mode::Level), move |_, _, state| { + if let Some(owner) = weak.upgrade() { + owner.borrow_mut().source = None; + Self::exited(&owner, state); + } + Ok(PostAction::Remove) + }) + .map_err(io::Error::other) + }); + if let Ok(token) = watched { + owner.borrow_mut().source = Some(token); + return; + } + // pidfd is unavailable on older kernels. Keep child ownership and + // bounded polling instead of losing restart and shutdown tracking. + let weak = Rc::downgrade(owner); + let token = state + .loop_handle + .insert_source(Timer::from_duration(Duration::from_millis(250)), move |_, _, state| { + let Some(owner) = weak.upgrade() else { + return TimeoutAction::Drop; + }; + let done = owner + .borrow_mut() + .child + .as_mut() + .is_none_or(|child| child.try_wait().ok().flatten().is_some()); + if done { + owner.borrow_mut().source = None; + Self::exited(&owner, state); + TimeoutAction::Drop + } else { + TimeoutAction::ToDuration(Duration::from_millis(250)) + } + }) + .expect("register desktop child watcher"); + owner.borrow_mut().source = Some(token); + } else { + Self::retry(owner, state); + } + } + + fn exited(owner: &Rc>, state: &mut Ferese) { + let mut this = owner.borrow_mut(); + if let Some(mut child) = this.child.take() { + // Every supervised generation owns a separate process group. Reap + // abandoned shell/agent descendants even if the helper was killed. + kill_group(&child, libc::SIGKILL); + if let Ok(Some(status)) = child.try_wait() { + warn!(pid = child.id(), %status, "desktop helper exited"); + } + } + if this.started.elapsed() >= Duration::from_secs(60) { + this.failures = 0; + } + drop(this); + Self::retry(owner, state); + } + + fn retry(owner: &Rc>, state: &mut Ferese) { + let mut this = owner.borrow_mut(); + if this.stopped { + return; + } + let delay = Duration::from_secs((1u64 << this.failures.min(5)).min(30)); + this.failures = this.failures.saturating_add(1); + warn!(?delay, "desktop helper exited; scheduling recovery"); + let weak = Rc::downgrade(owner); + this.source = Some( + state + .loop_handle + .insert_source(Timer::from_duration(delay), move |_, _, state| { + if let Some(owner) = weak.upgrade() { + owner.borrow_mut().source = None; + Self::launch(&owner, state); + } + TimeoutAction::Drop + }) + .expect("register desktop recovery timer"), + ); + } + + pub(crate) fn stop(&mut self, handle: &LoopHandle<'static, Ferese>) { + self.stopped = true; + if let Some(source) = self.source.take() { + handle.remove(source); + } + if let Some(mut child) = self.child.take() { + // Let the helper run its session cleanup before removing descendants. + terminate_child(&mut child); + kill_group(&child, libc::SIGKILL); + } + } +} + +fn kill_group(child: &Child, signal: i32) { + // SAFETY: spawn_client_inner creates this child's private process group. + unsafe { + libc::kill(-(child.id() as i32), signal); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restarts_with_fresh_connections_and_cancels_pending_recovery_on_stop() { + exercise_recovery( + "process::supervisor::tests::restarts_with_fresh_connections_and_cancels_pending_recovery_on_stop", + false, + ); + } + + #[test] + fn polling_recovers_when_pidfd_is_unavailable() { + exercise_recovery( + "process::supervisor::tests::polling_recovers_when_pidfd_is_unavailable", + true, + ); + } + + fn exercise_recovery(test: &str, polling: bool) { + if !crate::startup_tests::private_runtime(test) { + return; + } + crate::xwayland::test_hooks::PIDFD_UNAVAILABLE.store(polling, std::sync::atomic::Ordering::SeqCst); + let mut events = calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + let directory = tempfile::tempdir().unwrap(); + let record = directory.path().join("generations"); + let code = r#"import os, sys, time +with open(sys.argv[1], 'a') as output: + output.write(str(os.fstat(int(os.environ['WAYLAND_SOCKET'])).st_ino) + ' ' + os.environ['FERESE_CLIENT_RESTART'] + '\n') +if os.environ['FERESE_CLIENT_RESTART'] == '1': + time.sleep(60) +"#; + let owner = Supervisor::start( + &mut state, + vec![ + "python3".into(), + "-c".into(), + code.into(), + record.as_os_str().to_owned(), + ], + ClientCapabilities::EFFECTS, + ); + let deadline = Instant::now() + Duration::from_secs(5); + let rows = loop { + events.dispatch(Duration::from_millis(20), &mut state).unwrap(); + let text = std::fs::read_to_string(&record).unwrap_or_default(); + let rows: Vec<_> = text.lines().map(str::to_owned).collect(); + if rows.len() == 2 { + break rows; + } + assert!(Instant::now() < deadline, "supervised child did not restart: {text}"); + }; + assert!(rows[0].ends_with(" 0")); + assert!(rows[1].ends_with(" 1")); + assert_ne!(rows[0].split_whitespace().next(), rows[1].split_whitespace().next()); + let child_pid = owner.borrow().child.as_ref().unwrap().id(); + unsafe { + libc::kill(child_pid as i32, libc::SIGKILL); + } + while owner.borrow().child.is_some() { + events.dispatch(Duration::from_millis(20), &mut state).unwrap(); + assert!(Instant::now() < deadline); + } + assert!(owner.borrow().source.is_some(), "restart timer armed"); + owner.borrow_mut().stop(&state.loop_handle); + let stopped = Instant::now(); + while stopped.elapsed() < Duration::from_millis(2200) { + events.dispatch(Duration::from_millis(20), &mut state).unwrap(); + } + assert_eq!(std::fs::read_to_string(record).unwrap().lines().count(), 2); + assert!(owner.borrow().source.is_none()); + } + + #[test] + fn stopping_a_live_helper_removes_its_descendants() { + if !crate::startup_tests::private_runtime( + "process::supervisor::tests::stopping_a_live_helper_removes_its_descendants", + ) { + return; + } + let mut events = calloop::EventLoop::try_new().unwrap(); + let mut state = crate::startup_tests::state(&mut events); + let directory = tempfile::tempdir().unwrap(); + let record = directory.path().join("child-pid"); + let code = r#"import os, signal, subprocess, sys, time +signal.signal(signal.SIGTERM, signal.SIG_IGN) +child = subprocess.Popen(['sleep', '60']) +with open(sys.argv[1], 'w') as output: + output.write(str(child.pid)) +time.sleep(60) +"#; + let owner = Supervisor::start( + &mut state, + vec![ + "python3".into(), + "-c".into(), + code.into(), + record.as_os_str().to_owned(), + ], + ClientCapabilities::default(), + ); + let deadline = Instant::now() + Duration::from_secs(5); + let pid: u32 = loop { + if let Ok(contents) = std::fs::read_to_string(&record) + && let Ok(pid) = contents.parse() + { + break pid; + } + assert!(Instant::now() < deadline); + events.dispatch(Duration::from_millis(20), &mut state).unwrap(); + }; + owner.borrow_mut().stop(&state.loop_handle); + let status_path = format!("/proc/{pid}/status"); + loop { + // An orphan may briefly remain a zombie until the process reaper runs. + let running = std::fs::read_to_string(&status_path).is_ok_and(|status| { + !status + .lines() + .any(|line| line.starts_with("State:") && line.contains("Z (zombie)")) + }); + if !running { + break; + } + assert!(Instant::now() < deadline, "helper descendant survived shutdown"); + std::thread::sleep(Duration::from_millis(10)); + } + assert!(owner.borrow().child.is_none()); + assert!(owner.borrow().source.is_none()); + } +} diff --git a/compositor/src/state/floating.rs b/compositor/src/state/floating.rs index 50d43cc..daea9ef 100644 --- a/compositor/src/state/floating.rs +++ b/compositor/src/state/floating.rs @@ -308,7 +308,9 @@ impl Ferese { { return None; } - let rect = self.presented_window_rect(id)?; + // Opening/overview animation bounds are temporary. Reserve the + // final layout footprint so neighbors cannot grow into a new window. + let rect = self.windows.geometry(&id)?.logical; crate::floating::intersection(rect, work).map(|visible| (id.0, visible)) }) .collect() diff --git a/compositor/src/state/layout.rs b/compositor/src/state/layout.rs index d545440..c3c33d8 100644 --- a/compositor/src/state/layout.rs +++ b/compositor/src/state/layout.rs @@ -572,11 +572,8 @@ impl Ferese { let state = cached.current(); (state.min_size, state.max_size) }); - let (minimum, maximum) = self.effective_size_constraints( - window, - (minimum.w, minimum.h), - (maximum.w, maximum.h), - ); + let (minimum, maximum) = + self.effective_size_constraints(window, (minimum.w, minimum.h), (maximum.w, maximum.h)); Some(( *id, diff --git a/compositor/src/state/window_lifecycle_tests.rs b/compositor/src/state/window_lifecycle_tests.rs index dae713c..bb31ff8 100644 --- a/compositor/src/state/window_lifecycle_tests.rs +++ b/compositor/src/state/window_lifecycle_tests.rs @@ -31,6 +31,34 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese (events, state, output) } +#[test] +fn floating_placement_reserves_the_full_opening_window_footprint() { + if !crate::startup_tests::private_runtime( + "state::window_lifecycle_tests::floating_placement_reserves_the_full_opening_window_footprint", + ) { + return; + } + let (mut events, mut state, _) = fixture(); + let (window, _wire) = window(&mut state, &mut events, 0xff112233); + let id = state.windows.ids()[&window]; + state.toggle_focused_floating(); + let bounds = state.output_bounds().unwrap(); + let logical = state.windows.geometry(&id).unwrap().logical; + state.animations_enabled = true; + state + .windows + .update(id, |record| record.opening = Some(AnimatedValue::new(0.0))); + let visual = state.presented_window_rect(id).unwrap(); + assert!(visual.width < logical.width); + let obstacles = state.floating_obstacles(None, bounds); + assert_eq!( + obstacles, + vec![(id.0, crate::floating::intersection(logical, bounds).unwrap())] + ); + let placed = crate::floating::min_overlap((80., 80.), bounds, &[obstacles[0].1], (logical.x, logical.y)).unwrap(); + assert!(crate::floating::intersection(placed, logical).is_none()); +} + #[test] fn keyboard_floating_move_and_resize_work_in_both_layouts() { if !crate::startup_tests::private_runtime( @@ -660,7 +688,10 @@ fn window_rule_minimum_size_replaces_the_client_advertised_minimum() { request(&mut wire, 4, 8, &[800, 600], None); request(&mut wire, 2, 6, &[], None); dispatch(&mut events, &mut state); - assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 600)); + assert_eq!( + state.effective_size_constraints(&window, (800, 600), (0, 0)).0, + (800, 600) + ); assert_eq!(state.window_constraints()[&id].min_width, 800.0); assert_eq!(state.window_constraints()[&id].min_height, 600.0); @@ -669,7 +700,10 @@ fn window_rule_minimum_size_replaces_the_client_advertised_minimum() { .unwrap() .window_rules() .unwrap(); - assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (500, 400)); + assert_eq!( + state.effective_size_constraints(&window, (800, 600), (0, 0)).0, + (500, 400) + ); let constraints = state.window_constraints(); assert_eq!(constraints[&id].min_width, 500.0); assert_eq!(constraints[&id].min_height, 400.0); @@ -678,8 +712,14 @@ fn window_rule_minimum_size_replaces_the_client_advertised_minimum() { .unwrap() .window_rules() .unwrap(); - assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 400)); + assert_eq!( + state.effective_size_constraints(&window, (800, 600), (0, 0)).0, + (800, 400) + ); state.window_rules.clear(); - assert_eq!(state.effective_size_constraints(&window, (800, 600), (0,0)).0, (800, 600)); + assert_eq!( + state.effective_size_constraints(&window, (800, 600), (0, 0)).0, + (800, 600) + ); } diff --git a/crates/ferese-animation/src/gesture.rs b/crates/ferese-animation/src/gesture.rs index f388d28..0c4f56a 100644 --- a/crates/ferese-animation/src/gesture.rs +++ b/crates/ferese-animation/src/gesture.rs @@ -62,16 +62,17 @@ impl VelocityTracker { let dt = last.saturating_sub(first).as_secs_f64(); return if dt > 0.0 { (origin - position) / dt } else { 0.0 }; } - for entry in col..=degree + 1 { - matrix[col][entry] /= divisor; + for entry in &mut matrix[col][col..=degree + 1] { + *entry /= divisor; } - for row in 0..=degree { + let pivot_row = matrix[col]; + for (row, values) in matrix.iter_mut().enumerate().take(degree + 1) { if row == col { continue; } - let factor = matrix[row][col]; - for entry in col..=degree + 1 { - matrix[row][entry] -= factor * matrix[col][entry]; + let factor = values[col]; + for (entry, pivot) in values[col..=degree + 1].iter_mut().zip(&pivot_row[col..=degree + 1]) { + *entry -= factor * pivot; } } } diff --git a/crates/ferese-ipc/src/events.rs b/crates/ferese-ipc/src/events.rs index 12c5aaf..0639df6 100644 --- a/crates/ferese-ipc/src/events.rs +++ b/crates/ferese-ipc/src/events.rs @@ -32,7 +32,7 @@ pub struct Snapshot { #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Change { - Snapshot { desktop: Snapshot }, + Snapshot { desktop: Box }, OutputsChanged { outputs: Value }, WorkspacesChanged { workspaces: Value }, WindowsChanged { windows: Value }, diff --git a/docs/development.md b/docs/development.md index 8c7f4be..8978ee8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -6,12 +6,21 @@ Install the [build dependencies](installation.md#requirements), then run: cargo build --release --locked --workspace cargo test --workspace --locked cargo fmt --all --check +cargo clippy --workspace --all-targets --locked -- -D warnings +cargo build --workspace --locked +python3 -m unittest discover -s scripts/tests -v ``` Use release builds for performance and animation checks. Preview changes in a [nested session](installation.md#preview-and-logs) before testing hardware behavior. Keep another desktop available for direct-session recovery. +CI checks formatting, lints and tests across the whole workspace, including the +shell, settings, portal and CLI. The Fedora workspace job also builds desktop +binaries and runs installer, session-recovery and private portal-contract checks. The +compositor job runs offscreen capture regressions with software Mesa. Opt-in +nested and hardware checks remain separate. + ## Test guides - [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership. diff --git a/docs/installation.md b/docs/installation.md index a4c32db..e1c3a05 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -199,6 +199,14 @@ For greetd, set this as the session command after authentication; see the [greet documentation](https://sr.ht/~kennylevinsen/greetd/). The launcher starts the compositor, shell and session services together. +The session launcher supervises the desktop helper. If the shell exits, Ferese +restarts the helper with fresh private Wayland connections after a delay of one +second. Repeated failures increase that delay up to 30 seconds; a minute of +uptime resets it. The polkit agent recovers independently, and shell recovery +does not repeat desktop autostart. Compositor shutdown cancels pending restarts +and stops the helper. Standalone clients passed to `ferese -- COMMAND` are only +supervised when `--supervise-client` is explicitly supplied before `--`. + ### From a TTY Log out of your graphical session and switch to a local console with **Ctrl+Alt+F2**, or diff --git a/docs/ipc.md b/docs/ipc.md index 8465238..0a55c05 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -76,6 +76,15 @@ Clients launched by a nested compositor inherit its `FERESE_SOCKET`, so `feresectl outputs` inside that session connects to that instance. `autostart` launches desktop entries locally and does not use IPC. +## Connection lifetimes + +The server permits 64 simultaneous connections. A new connection must begin its +first request within five seconds; each frame then has five seconds to finish. +Ordinary connections close after 30 idle seconds and blocked response writes +expire after two seconds. These bounds release slots held by stalled clients. +Accepted portal leases remain connected while idle, and pending long polls and +event subscriptions do not have the ordinary idle deadline. + ## Follow changes ```sh diff --git a/feresectl/src/output.rs b/feresectl/src/output.rs index 1f53433..c63d9f2 100644 --- a/feresectl/src/output.rs +++ b/feresectl/src/output.rs @@ -17,6 +17,8 @@ pub fn print(value: &Value, json: bool) -> Result<(), Box> { fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Result<()> { match value { Value::Object(fields) => { + let mut fields = fields.iter().collect::>(); + fields.sort_unstable_by_key(|(key, _)| *key); for (key, value) in fields { write!(writer, "{:indent$}{key}:", "")?; if value.is_object() || value.is_array() { diff --git a/packaging/ferese-session b/packaging/ferese-session index ee89a26..d47c7b2 100644 --- a/packaging/ferese-session +++ b/packaging/ferese-session @@ -41,7 +41,7 @@ if [[ $backend == drm ]] && command -v systemctl >/dev/null; then systemctl --user stop swaync.service 2>/dev/null || true fi -command=("$release_dir/ferese" --backend "$backend" --grant-effects +command=("$release_dir/ferese" --backend "$backend" --supervise-client --grant-effects --grant-shell-control -- "$release_dir/ferese-session-shell") if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then exec dbus-run-session -- "${command[@]}" diff --git a/packaging/ferese-session-shell b/packaging/ferese-session-shell index acd3af1..50ce54c 100755 --- a/packaging/ferese-session-shell +++ b/packaging/ferese-session-shell @@ -70,12 +70,33 @@ if ((manage_session)); then fi fi release_dir=$(dirname -- "$(readlink -f -- "$0")") -env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ - "$release_dir/ferese-polkit-agent" & +# Agent failures must not take down a healthy shell. The compositor supervises +# this helper (and creates fresh private connections) when the shell itself exits. +supervise_agent() { + local child= delay=1 started + trap 'trap - TERM INT; if [[ -n $child ]]; then kill "$child" 2>/dev/null || true; wait "$child" 2>/dev/null || true; fi; exit 0' TERM INT + while true; do + started=$SECONDS + env -u WAYLAND_SOCKET -u FERESE_SHELL_CONTROL_SOCKET \ + WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ + "$release_dir/ferese-polkit-agent" & + child=$! + wait "$child" || true + child= + if ((SECONDS - started >= 60)); then delay=1; fi + sleep "$delay" & + child=$! + wait "$child" || true + child= + if ((delay < 30)); then delay=$((delay * 2)); fi + if ((delay > 30)); then delay=30; fi + done +} +supervise_agent & agent_pid=$! "$release_dir/ferese-shell" & shell_pid=$! -if ((manage_session)) && [[ -x $release_dir/feresectl ]]; then +if ((manage_session)) && [[ ${FERESE_CLIENT_RESTART:-0} != 1 && -x $release_dir/feresectl ]]; then env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ "$release_dir/feresectl" autostart & autostart_pid=$! diff --git a/scripts/installer/install.py b/scripts/installer/install.py index 99a444c..c3b9838 100644 --- a/scripts/installer/install.py +++ b/scripts/installer/install.py @@ -164,10 +164,11 @@ def validate_bundle(bundle, *, installed=False): actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()} required = expected.keys() if installed: - # Installed releases may predate the appearance-specific wallpapers. + # Installed releases may predate the appearance wallpapers and license. # New bundles must include them; upgrades and rollback verify the older # release's recorded files without requiring these later additions. - required = required - {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png'} + required = required - {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png', + 'licenses/Ferese-LICENSE.txt'} if not required <= seen or actual != seen | {'manifest.json'}: fail('Bundle is incomplete or contains unlisted files') diff --git a/scripts/installer/inventory.json b/scripts/installer/inventory.json index 142ee79..097274f 100644 --- a/scripts/installer/inventory.json +++ b/scripts/installer/inventory.json @@ -9,6 +9,11 @@ "xdg-desktop-portal-ferese" ], "files": [ + { + "source": "LICENSE", + "target": "licenses/Ferese-LICENSE.txt", + "mode": "0644" + }, { "source": "target/release/ferese", "target": "ferese", diff --git a/scripts/tests/test_floating_placement_isolated.py b/scripts/tests/test_floating_placement_isolated.py index 2fb7485..ab0905a 100644 --- a/scripts/tests/test_floating_placement_isolated.py +++ b/scripts/tests/test_floating_placement_isolated.py @@ -69,13 +69,27 @@ class FloatingPlacementTest(unittest.TestCase): return child def command(*args): - return json.loads(subprocess.check_output([str(repo / "target/debug/feresectl"), "-j", *args], env=ipc_environment(env), timeout=5)) + return json.loads(subprocess.check_output([str(repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")), "-j", *args], env=ipc_environment(env), timeout=5)) + + def settled_geometry(query, fields): + deadline = time.monotonic() + 5 + stable_since, previous = time.monotonic(), None + while True: + values = command(query) + geometry = [tuple(value[field] for field in fields) for value in values] + if geometry != previous: + stable_since, previous = time.monotonic(), geometry + if geometry and time.monotonic() - stable_since >= .5: + return values + if time.monotonic() > deadline: + self.fail(f"{query} geometry did not settle: {geometry}") + time.sleep(.025) with (root / "compositor.log").open("w") as log: try: bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True) env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() - compositor = launch([str(repo / "target/debug/ferese"), "--backend", "nested"], stdout=log, stderr=log) + compositor = launch([str(repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")), "--backend", "nested"], stdout=log, stderr=log) deadline = time.monotonic() + 10 while not (ipc_socket(runtime)).is_socket(): if compositor.poll() is not None or time.monotonic() > deadline: @@ -83,6 +97,9 @@ class FloatingPlacementTest(unittest.TestCase): time.sleep(.025) socket = next(p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")) env["WAYLAND_DISPLAY"] = str(socket) + # The host configures the nested window after its initial + # output is advertised. Restore memory against that final size. + settled_geometry("outputs", ["name", "x", "y", "width", "height", "scale"]) for app in ["remembered", "second", "third"]: launch([str(root / "client"), "ferese.test.placement." + app, "240", "160"], stdout=log, stderr=log) deadline = time.monotonic() + 5 @@ -93,6 +110,10 @@ class FloatingPlacementTest(unittest.TestCase): if time.monotonic() > deadline: self.fail((root / "compositor.log").read_text() + repr(windows)) time.sleep(.025) + # IPC reports presented bounds. Let opening animations settle + # before comparing the remembered rectangle with a later restore. + # Mapping above remains rapid to exercise placement during opening. + windows = settled_geometry("windows", ["app_id", "x", "y", "width", "height"]) remembered = next(w for w in windows if w["app_id"].endswith("remembered")) output = next(o for o in command("get-outputs") if o["enabled"]) self.assertAlmostEqual(remembered["x"], output["x"] + .1 * output["width"], delta=1) diff --git a/scripts/tests/test_portal_install.py b/scripts/tests/test_portal_install.py index 4a51324..0f7d3de 100644 --- a/scripts/tests/test_portal_install.py +++ b/scripts/tests/test_portal_install.py @@ -113,15 +113,34 @@ m.main() self.assertEqual((self.bundles[0] / name).stat().st_mode & 0o777, 0o755) def without_appearance_wallpapers(self, bundle): + self.without_files(bundle, {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png'}) + + def without_files(self, bundle, removed): manifest_path = bundle / 'manifest.json' manifest = json.loads(manifest_path.read_text()) - removed = {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png'} for target in removed: (bundle / target).unlink() manifest['files'] = [item for item in manifest['files'] if item['target'] not in removed] manifest_path.write_text(json.dumps(manifest)) + def test_license_is_bundled_and_required_for_new_releases(self): + self.assertEqual((self.bundles[0] / 'licenses/Ferese-LICENSE.txt').read_bytes(), (REPO / 'LICENSE').read_bytes()) + incomplete = Path(self.temporary.name) / 'incomplete' + shutil.copytree(self.bundles[0], incomplete) + self.without_files(incomplete, {'licenses/Ferese-LICENSE.txt'}) + result = self.run_command('install', incomplete, success=False) + self.assertIn('Bundle is incomplete', result.stderr) + self.assertFalse((self.base / 'current').exists()) + + def test_upgrade_and_rollback_accept_installed_release_before_license(self): + self.install() + self.without_files(self.base / 'releases/first', {'licenses/Ferese-LICENSE.txt'}) + self.install(1) + self.run_command('rollback') + self.assertEqual(os.readlink(self.base / 'current'), 'releases/first') + self.assertEqual(installer.Installation(self.root).manifest('first')['release'], 'first') + def test_upgrade_and_rollback_accept_installed_release_before_appearance_wallpapers(self): self.install() legacy = self.base / 'releases/first' diff --git a/scripts/tests/test_portal_isolated.py b/scripts/tests/test_portal_isolated.py index f56c2d1..dea218a 100644 --- a/scripts/tests/test_portal_isolated.py +++ b/scripts/tests/test_portal_isolated.py @@ -14,6 +14,7 @@ import subprocess import tempfile import time import unittest +from session_socket import ipc_environment @unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in") @@ -45,7 +46,7 @@ class PortalTest(unittest.TestCase): def launch(name, command, **kwargs): log = (root / (name + ".log")).open("w") logs.append(log) - child = subprocess.Popen(command, env=env, stderr=log, + child = subprocess.Popen(command, env=ipc_environment(env), stderr=log, stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs) processes.append(child) return child @@ -95,6 +96,7 @@ class PortalTest(unittest.TestCase): sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")] until(sockets) env["WAYLAND_DISPLAY"] = str(sockets()[0]) + env = ipc_environment(env) # Private bus, including the real portal frontend and permission store. bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE) env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip() diff --git a/scripts/tests/test_session_startup.py b/scripts/tests/test_session_startup.py index c41e533..a24c6cb 100644 --- a/scripts/tests/test_session_startup.py +++ b/scripts/tests/test_session_startup.py @@ -13,7 +13,7 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell' class SessionStartupTest(unittest.TestCase): def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False, - display=None, xauthority=None, extra_env=None): + display=None, xauthority=None, extra_env=None, programs_override=None): with tempfile.TemporaryDirectory() as directory: root = Path(directory) helper = root / 'ferese-session-shell' @@ -27,6 +27,7 @@ class SessionStartupTest(unittest.TestCase): 'ferese-polkit-agent': 'exec sleep 30\n', 'ferese-shell': 'printf "shell:%s\\n" "$WAYLAND_SOCKET" >> "$TEST_ROOT/events"\nif [[ $TEST_WAIT == 1 ]]; then exec sleep 30; fi\nexit "$TEST_STATUS"\n', } + programs.update(programs_override or {}) for name, body in programs.items(): path = root / name path.write_text('#!/bin/bash\n' + body) @@ -128,6 +129,37 @@ class SessionStartupTest(unittest.TestCase): self.assertEqual(status, 143) self.assertEqual(events[-1], '--user stop ferese-session.target') + def test_agent_failure_recovers_without_restarting_shell(self): + status, events = self.run_session(programs_override={ + 'ferese-polkit-agent': ( + 'echo agent >> "$TEST_ROOT/events"\n' + 'if [[ ! -f $TEST_ROOT/agent-failed ]]; then touch "$TEST_ROOT/agent-failed"; exit 7; fi\n' + 'touch "$TEST_ROOT/agent-recovered"\nexec sleep 30\n'), + 'ferese-shell': ( + 'echo shell >> "$TEST_ROOT/events"\n' + 'for ((i=0; i<200; i++)); do\n' + ' [[ -f $TEST_ROOT/agent-recovered ]] && exit 0\n' + ' sleep .01\ndone\nexit 1\n'), + }) + self.assertEqual(status, 0) + self.assertEqual(events.count('agent'), 2) + self.assertEqual(events.count('shell'), 1) + self.assertEqual(events[-1], '--user stop ferese-session.target') + + def test_recovered_shell_does_not_repeat_autostart(self): + programs = { + 'feresectl': 'touch "$TEST_ROOT/autostarted"\necho autostart >> "$TEST_ROOT/events"\n', + 'ferese-shell': ( + 'if [[ ${FERESE_CLIENT_RESTART:-0} == 1 ]]; then sleep .1; exit 0; fi\n' + 'for ((i=0; i<100; i++)); do\n' + ' [[ -f $TEST_ROOT/autostarted ]] && exit 0\n' + ' sleep .01\ndone\nexit 1\n'), + } + for restarted in ('0', '1'): + status, events = self.run_session(extra_env={'FERESE_CLIENT_RESTART': restarted}, programs_override=programs) + self.assertEqual(status, 0) + self.assertEqual(events.count('autostart'), int(restarted == '0')) + if __name__ == '__main__': unittest.main() diff --git a/shell/ferese-shell/src/motion.rs b/shell/ferese-shell/src/motion.rs index aef3076..14b2855 100644 --- a/shell/ferese-shell/src/motion.rs +++ b/shell/ferese-shell/src/motion.rs @@ -98,6 +98,7 @@ impl PopupMotion { self.sample(now).current as f32 } + #[cfg(test)] pub(crate) fn progress(&self) -> f32 { self.progress_at(std::time::Instant::now()) } @@ -544,7 +545,6 @@ mod tests { duration_ms: Some(240.0), bounce: 0.3, overshoot: true, - ..Default::default() }, ..Default::default() }; diff --git a/shell/ferese-shell/src/renderer.rs b/shell/ferese-shell/src/renderer.rs index 993ac56..6017f14 100644 --- a/shell/ferese-shell/src/renderer.rs +++ b/shell/ferese-shell/src/renderer.rs @@ -1,4 +1,5 @@ use std::ffi::{OsStr, OsString}; +use std::os::unix::process::CommandExt; use std::process::Command; use std::sync::OnceLock; @@ -18,6 +19,8 @@ pub(crate) fn configure_shell() { } pub(crate) fn configure_app(command: &mut Command) { + // Applications survive cleanup of a crashed desktop helper generation. + command.process_group(0); if let Some(backend) = APP_BACKEND.get() { apply_app_backend(command, backend.as_deref()); } diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index bc287ab..ac8d2be 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -490,6 +490,8 @@ fn encode_regions(regions: &[[f32; 5]]) -> Vec { .collect() } +type EffectPresentation = (Vec<[f32; 5]>, u32, Vec, ferese_surface_effects_v1::Role); + pub(super) struct EffectsBinding { connection: Connection, _manager: FereseEffectsManagerV1, @@ -497,7 +499,7 @@ pub(super) struct EffectsBinding { _queue: EventQueue, regions: std::cell::RefCell>>, opacity: std::cell::Cell>, - presentation: std::cell::RefCell, u32, Vec, ferese_surface_effects_v1::Role)>>, + presentation: std::cell::RefCell>, } impl EffectsBinding {