Harden desktop recovery and pre-release stability checks

Bound IPC transport deadlines while preserving portal leases, supervise desktop helper and agent recovery, and reserve final floating window geometry during placement.

Stabilize CLI output and isolated tests, expand workspace CI, resolve formatting and lint failures, and bundle the MIT license with installer compatibility.
This commit is contained in:
Samuel Onoja committed 2026-10-07 09:08:23 +01:00
1 parent a00f1c6f3a
commit a6f24e8188
30 files changed
+810 -44

No files matched your search

+55 -1
View File
@@ -1,9 +1,18 @@
name: Compositor lifecycle and capture tests name: Workspace release checks
on: on:
pull_request: pull_request:
paths: paths:
- "compositor/**" - "compositor/**"
- "crates/**" - "crates/**"
- "shell/**"
- "portal/**"
- "feresectl/**"
- "scripts/**"
- "packaging/**"
- "assets/**"
- "LICENSE"
- "rust-toolchain.toml"
- "rustfmt.toml"
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- ".github/workflows/compositor-lifecycle.yml" - ".github/workflows/compositor-lifecycle.yml"
@@ -12,6 +21,15 @@ on:
paths: paths:
- "compositor/**" - "compositor/**"
- "crates/**" - "crates/**"
- "shell/**"
- "portal/**"
- "feresectl/**"
- "scripts/**"
- "packaging/**"
- "assets/**"
- "LICENSE"
- "rust-toolchain.toml"
- "rustfmt.toml"
- "Cargo.toml" - "Cargo.toml"
- "Cargo.lock" - "Cargo.lock"
- ".github/workflows/compositor-lifecycle.yml" - ".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 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 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 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
+21
View File
@@ -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.
+30
View File
@@ -21,6 +21,7 @@ pub struct LaunchConfig {
pub(crate) session_policy: SessionPolicy, pub(crate) session_policy: SessionPolicy,
pub client: Vec<OsString>, pub client: Vec<OsString>,
pub client_capabilities: ClientCapabilities, pub client_capabilities: ClientCapabilities,
pub supervise_client: bool,
} }
impl LaunchConfig { impl LaunchConfig {
@@ -35,6 +36,7 @@ impl LaunchConfig {
let mut requested_backend = None; let mut requested_backend = None;
let mut client = Vec::new(); let mut client = Vec::new();
let mut client_capabilities = ClientCapabilities::default(); let mut client_capabilities = ClientCapabilities::default();
let mut supervise_client = false;
while let Some(argument) = args.next() { while let Some(argument) = args.next() {
if argument == "--" { if argument == "--" {
@@ -52,6 +54,10 @@ impl LaunchConfig {
client_capabilities.insert(ClientCapabilities::EFFECTS); client_capabilities.insert(ClientCapabilities::EFFECTS);
continue; continue;
} }
if argument == "--supervise-client" {
supervise_client = true;
continue;
}
if argument == "--grant-shell-control" { if argument == "--grant-shell-control" {
client_capabilities.insert(ClientCapabilities::SHELL_CONTROL); client_capabilities.insert(ClientCapabilities::SHELL_CONTROL);
continue; continue;
@@ -75,6 +81,9 @@ impl LaunchConfig {
BackendKind::Drm BackendKind::Drm
}); });
if supervise_client && client.is_empty() {
return Err("--supervise-client requires a client command".into());
}
Ok(Self { Ok(Self {
backend, backend,
session_policy: match backend { session_policy: match backend {
@@ -83,6 +92,7 @@ impl LaunchConfig {
}, },
client, client,
client_capabilities, client_capabilities,
supervise_client,
}) })
} }
} }
@@ -119,6 +129,26 @@ mod tests {
assert_eq!(config.session_policy, SessionPolicy::Embedded); assert_eq!(config.session_policy, SessionPolicy::Embedded);
assert_eq!(config.client, [OsString::from("foot")]); assert_eq!(config.client, [OsString::from("foot")]);
assert!(config.client_capabilities.is_empty()); 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] #[test]
+2 -5
View File
@@ -150,11 +150,8 @@ impl PointerGrab<Ferese> for ResizeSurfaceGrab {
let state = cached.current(); let state = cached.current();
(state.min_size, state.max_size) (state.min_size, state.max_size)
}); });
let (minimum, maximum) = data.effective_size_constraints( let (minimum, maximum) =
&self.window, data.effective_size_constraints(&self.window, (minimum.w, minimum.h), (maximum.w, maximum.h));
(minimum.w, minimum.h),
(maximum.w, maximum.h),
);
self.last_size = constrained_size( self.last_size = constrained_size(
self.initial_rect.size, self.initial_rect.size,
delta, delta,
+51 -5
View File
@@ -11,7 +11,9 @@ use std::time::{Duration, Instant};
use std::{fs, io, thread}; use std::{fs, io, thread};
use ferese_core::LayoutMode; 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 ferese_layout::Direction;
use serde_json::{Value, json}; use serde_json::{Value, json};
use smithay::output::Output; use smithay::output::Output;
@@ -478,8 +480,10 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
owner, owner,
sender: sender.clone(), sender: sender.clone(),
}; };
let mut idle = Some(transport::FRAME_TIMEOUT);
let mut owns_lease = false;
loop { loop {
let request: Request = match read_frame(&mut stream) { let request: Request = match transport::read_request(&stream, idle) {
Ok(request) => request, Ok(request) => request,
Err(ferese_ipc::FrameError::Io(error)) Err(ferese_ipc::FrameError::Io(error))
if matches!( if matches!(
@@ -502,7 +506,7 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
Err(error) => Response::error(request.id, "invalid_config", error), Err(error) => Response::error(request.id, "invalid_config", error),
}, },
}; };
if write_frame(&mut stream, &response).is_err() { if transport::write_response(&stream, &response).is_err() {
return; return;
} }
continue; continue;
@@ -511,6 +515,14 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
let event_stream_requested = request.command == "event-stream"; let event_stream_requested = request.command == "event-stream";
let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY); let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY);
let exit_requested = request.command == "exit"; let exit_requested = request.command == "exit";
let registers_lease = matches!(
request.command.as_str(),
"portal-inhibit"
| "portal-monitor-register"
| "portal-shortcuts-register"
| "input-capture-register"
| "begin-session-end"
);
let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window"); let screenshot_requested = matches!(request.command.as_str(), "screenshot" | "screenshot-window");
let call = IpcEvent::Call(IpcCall { let call = IpcEvent::Call(IpcCall {
native_portal, native_portal,
@@ -554,7 +566,9 @@ fn serve_connection(mut stream: UnixStream, sender: channel::SyncSender<IpcEvent
}; };
let exit_accepted = exit_requested && response.error.is_none(); let exit_accepted = exit_requested && response.error.is_none();
let event_stream_accepted = event_stream_requested && response.error.is_none(); let event_stream_accepted = event_stream_requested && response.error.is_none();
if let Err(error) = write_frame(&mut stream, &response) { owns_lease |= registers_lease && response.error.is_none();
idle = (!owns_lease).then_some(transport::IDLE_TIMEOUT);
if let Err(error) = transport::write_response(&stream, &response) {
if screenshot_requested { if screenshot_requested {
discard_undelivered_screenshot(&response); discard_undelivered_screenshot(&response);
} }
@@ -580,7 +594,7 @@ fn relay_events(stream: &mut UnixStream, events: std::sync::mpsc::Receiver<Arc<f
loop { loop {
match events.recv_timeout(Duration::from_millis(250)) { match events.recv_timeout(Duration::from_millis(250)) {
Ok(event) => { Ok(event) => {
if write_frame(stream, event.as_ref()).is_err() { if transport::write_response(stream, event.as_ref()).is_err() {
return; return;
} }
} }
@@ -1810,6 +1824,38 @@ mod tests {
assert_eq!(peer_uid(&right).unwrap(), effective_uid()); 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] #[test]
fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() { fn malformed_ipc_connection_is_closed_without_reaching_the_request_queue() {
let (mut client, server) = UnixStream::pair().unwrap(); let (mut client, server) = UnixStream::pair().unwrap();
+123
View File
@@ -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<Duration> {
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<usize> {
self.stream.set_read_timeout(Some(self.remaining()?))?;
self.stream.read(bytes)
}
}
impl Write for DeadlineIo<'_> {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
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<Duration>) -> Result<Request, FrameError> {
read_request_with_budget(stream, idle, FRAME_TIMEOUT)
}
fn read_request_with_budget(
mut stream: &UnixStream,
idle: Option<Duration>,
budget: Duration,
) -> Result<Request, FrameError> {
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();
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ impl Subscribers {
generation: self.generation, generation: self.generation,
last: true, last: true,
change: Change::Snapshot { change: Change::Snapshot {
desktop: snapshot.clone(), desktop: Box::new(snapshot.clone()),
}, },
}) })
}); });
+19 -2
View File
@@ -122,8 +122,22 @@ fn main() -> Result<(), Box<dyn Error>> {
info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients"); info!(socket = ?state.socket_name, backend = ?launch.backend, "Ferese is accepting Wayland clients");
let child = spawn_client(&mut state, launch.client, launch.client_capabilities) let (supervisor, child) = if launch.supervise_client {
.map(|child| watch_client_exit(&state.loop_handle, child)); (
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); let runner = daemon::Runner::start(&mut state);
state.daemons = Some(runner.clone()); state.daemons = Some(runner.clone());
let result = event_loop.run(None, &mut state, after_dispatch); let result = event_loop.run(None, &mut state, after_dispatch);
@@ -136,6 +150,9 @@ fn main() -> Result<(), Box<dyn Error>> {
state.resize_metrics.dump(); state.resize_metrics.dump();
runner.borrow_mut().stop(); runner.borrow_mut().stop();
if let Some(supervisor) = supervisor {
supervisor.borrow_mut().stop(&state.loop_handle);
}
if let Some(child) = child if let Some(child) = child
&& let Some(mut child) = child.borrow_mut().take() && let Some(mut child) = child.borrow_mut().take()
{ {
+16
View File
@@ -15,6 +15,9 @@ use tracing::{info, warn};
use crate::Ferese; use crate::Ferese;
use crate::private_client::{self, ClientCapabilities}; use crate::private_client::{self, ClientCapabilities};
mod supervisor;
pub(crate) use supervisor::Supervisor;
pub(crate) fn command(program: impl AsRef<OsStr>) -> Command { pub(crate) fn command(program: impl AsRef<OsStr>) -> Command {
let mut command = Command::new(program); let mut command = Command::new(program);
// Calloop blocks these on the compositor thread; fork/exec inherits that // Calloop blocks these on the compositor thread; fork/exec inherits that
@@ -41,6 +44,15 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
state: &mut Ferese, state: &mut Ferese,
args: impl IntoIterator<Item = S>, args: impl IntoIterator<Item = S>,
capabilities: ClientCapabilities, capabilities: ClientCapabilities,
) -> Option<Child> {
spawn_client_inner(state, args, capabilities, None)
}
fn spawn_client_inner<S: AsRef<OsStr>>(
state: &mut Ferese,
args: impl IntoIterator<Item = S>,
capabilities: ClientCapabilities,
supervised_restart: Option<bool>,
) -> Option<Child> { ) -> Option<Child> {
let mut args = args.into_iter(); let mut args = args.into_iter();
let Some(program) = args.next() else { let Some(program) = args.next() else {
@@ -51,6 +63,10 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
let program = program.as_ref(); let program = program.as_ref();
let mut command = command(program); let mut command = command(program);
command.args(args); 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. // Workers may already exist. Change only this child's environment.
// Apply before granting the child a private Wayland connection. // Apply before granting the child a private Wayland connection.
state.session_environment.apply_public(&mut command); state.session_environment.apply_public(&mut command);
+288
View File
@@ -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<OsString>,
capabilities: ClientCapabilities,
child: Option<Child>,
source: Option<RegistrationToken>,
failures: u32,
started: Instant,
restarted: bool,
stopped: bool,
}
impl Supervisor {
pub(crate) fn start(
state: &mut Ferese,
args: Vec<OsString>,
capabilities: ClientCapabilities,
) -> Rc<RefCell<Self>> {
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<RefCell<Self>>, 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<RefCell<Self>>, 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<RefCell<Self>>, 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());
}
}
+3 -1
View File
@@ -308,7 +308,9 @@ impl Ferese {
{ {
return None; 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)) crate::floating::intersection(rect, work).map(|visible| (id.0, visible))
}) })
.collect() .collect()
+2 -5
View File
@@ -572,11 +572,8 @@ impl Ferese {
let state = cached.current(); let state = cached.current();
(state.min_size, state.max_size) (state.min_size, state.max_size)
}); });
let (minimum, maximum) = self.effective_size_constraints( let (minimum, maximum) =
window, self.effective_size_constraints(window, (minimum.w, minimum.h), (maximum.w, maximum.h));
(minimum.w, minimum.h),
(maximum.w, maximum.h),
);
Some(( Some((
*id, *id,
+44 -4
View File
@@ -31,6 +31,34 @@ fn fixture() -> (smithay::reexports::calloop::EventLoop<'static, Ferese>, Ferese
(events, state, output) (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] #[test]
fn keyboard_floating_move_and_resize_work_in_both_layouts() { fn keyboard_floating_move_and_resize_work_in_both_layouts() {
if !crate::startup_tests::private_runtime( 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, 4, 8, &[800, 600], None);
request(&mut wire, 2, 6, &[], None); request(&mut wire, 2, 6, &[], None);
dispatch(&mut events, &mut state); 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_width, 800.0);
assert_eq!(state.window_constraints()[&id].min_height, 600.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() .unwrap()
.window_rules() .window_rules()
.unwrap(); .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(); let constraints = state.window_constraints();
assert_eq!(constraints[&id].min_width, 500.0); assert_eq!(constraints[&id].min_width, 500.0);
assert_eq!(constraints[&id].min_height, 400.0); assert_eq!(constraints[&id].min_height, 400.0);
@@ -678,8 +712,14 @@ fn window_rule_minimum_size_replaces_the_client_advertised_minimum() {
.unwrap() .unwrap()
.window_rules() .window_rules()
.unwrap(); .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(); 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)
);
} }
+7 -6
View File
@@ -62,16 +62,17 @@ impl VelocityTracker {
let dt = last.saturating_sub(first).as_secs_f64(); let dt = last.saturating_sub(first).as_secs_f64();
return if dt > 0.0 { (origin - position) / dt } else { 0.0 }; return if dt > 0.0 { (origin - position) / dt } else { 0.0 };
} }
for entry in col..=degree + 1 { for entry in &mut matrix[col][col..=degree + 1] {
matrix[col][entry] /= divisor; *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 { if row == col {
continue; continue;
} }
let factor = matrix[row][col]; let factor = values[col];
for entry in col..=degree + 1 { for (entry, pivot) in values[col..=degree + 1].iter_mut().zip(&pivot_row[col..=degree + 1]) {
matrix[row][entry] -= factor * matrix[col][entry]; *entry -= factor * pivot;
} }
} }
} }
+1 -1
View File
@@ -32,7 +32,7 @@ pub struct Snapshot {
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum Change { pub enum Change {
Snapshot { desktop: Snapshot }, Snapshot { desktop: Box<Snapshot> },
OutputsChanged { outputs: Value }, OutputsChanged { outputs: Value },
WorkspacesChanged { workspaces: Value }, WorkspacesChanged { workspaces: Value },
WindowsChanged { windows: Value }, WindowsChanged { windows: Value },
+9
View File
@@ -6,12 +6,21 @@ Install the [build dependencies](installation.md#requirements), then run:
cargo build --release --locked --workspace cargo build --release --locked --workspace
cargo test --workspace --locked cargo test --workspace --locked
cargo fmt --all --check 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 Use release builds for performance and animation checks. Preview changes in a
[nested session](installation.md#preview-and-logs) before testing hardware behavior. [nested session](installation.md#preview-and-logs) before testing hardware behavior.
Keep another desktop available for direct-session recovery. 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 ## Test guides
- [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership. - [Desktop reconciliation](desktop-reconciliation.md): topology publication, hardware failure and presentation ownership.
+8
View File
@@ -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 documentation](https://sr.ht/~kennylevinsen/greetd/). The launcher starts the
compositor, shell and session services together. 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 ### From a TTY
Log out of your graphical session and switch to a local console with **Ctrl+Alt+F2**, or Log out of your graphical session and switch to a local console with **Ctrl+Alt+F2**, or
+9
View File
@@ -76,6 +76,15 @@ Clients launched by a nested compositor inherit its `FERESE_SOCKET`, so
`feresectl outputs` inside that session connects to that instance. `feresectl outputs` inside that session connects to that instance.
`autostart` launches desktop entries locally and does not use IPC. `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 ## Follow changes
```sh ```sh
+2
View File
@@ -17,6 +17,8 @@ pub fn print(value: &Value, json: bool) -> Result<(), Box<dyn Error>> {
fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Result<()> { fn write_plain(writer: &mut impl Write, value: &Value, indent: usize) -> io::Result<()> {
match value { match value {
Value::Object(fields) => { Value::Object(fields) => {
let mut fields = fields.iter().collect::<Vec<_>>();
fields.sort_unstable_by_key(|(key, _)| *key);
for (key, value) in fields { for (key, value) in fields {
write!(writer, "{:indent$}{key}:", "")?; write!(writer, "{:indent$}{key}:", "")?;
if value.is_object() || value.is_array() { if value.is_object() || value.is_array() {
+1 -1
View File
@@ -41,7 +41,7 @@ if [[ $backend == drm ]] && command -v systemctl >/dev/null; then
systemctl --user stop swaync.service 2>/dev/null || true systemctl --user stop swaync.service 2>/dev/null || true
fi 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") --grant-shell-control -- "$release_dir/ferese-session-shell")
if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then if [[ -z ${DBUS_SESSION_BUS_ADDRESS:-} ]]; then
exec dbus-run-session -- "${command[@]}" exec dbus-run-session -- "${command[@]}"
+24 -3
View File
@@ -70,12 +70,33 @@ if ((manage_session)); then
fi fi
fi fi
release_dir=$(dirname -- "$(readlink -f -- "$0")") release_dir=$(dirname -- "$(readlink -f -- "$0")")
env -u WAYLAND_SOCKET WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \ # Agent failures must not take down a healthy shell. The compositor supervises
"$release_dir/ferese-polkit-agent" & # 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=$! agent_pid=$!
"$release_dir/ferese-shell" & "$release_dir/ferese-shell" &
shell_pid=$! 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:-}}" \ env WAYLAND_DISPLAY="${FERESE_PUBLIC_WAYLAND_DISPLAY:-${WAYLAND_DISPLAY:-}}" \
"$release_dir/feresectl" autostart & "$release_dir/feresectl" autostart &
autostart_pid=$! autostart_pid=$!
+3 -2
View File
@@ -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()} actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()}
required = expected.keys() required = expected.keys()
if installed: 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 # New bundles must include them; upgrades and rollback verify the older
# release's recorded files without requiring these later additions. # 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'}: if not required <= seen or actual != seen | {'manifest.json'}:
fail('Bundle is incomplete or contains unlisted files') fail('Bundle is incomplete or contains unlisted files')
+5
View File
@@ -9,6 +9,11 @@
"xdg-desktop-portal-ferese" "xdg-desktop-portal-ferese"
], ],
"files": [ "files": [
{
"source": "LICENSE",
"target": "licenses/Ferese-LICENSE.txt",
"mode": "0644"
},
{ {
"source": "target/release/ferese", "source": "target/release/ferese",
"target": "ferese", "target": "ferese",
@@ -69,13 +69,27 @@ class FloatingPlacementTest(unittest.TestCase):
return child return child
def command(*args): 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: with (root / "compositor.log").open("w") as log:
try: try:
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True) bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE, text=True)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() 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 deadline = time.monotonic() + 10
while not (ipc_socket(runtime)).is_socket(): while not (ipc_socket(runtime)).is_socket():
if compositor.poll() is not None or time.monotonic() > deadline: if compositor.poll() is not None or time.monotonic() > deadline:
@@ -83,6 +97,9 @@ class FloatingPlacementTest(unittest.TestCase):
time.sleep(.025) time.sleep(.025)
socket = next(p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")) socket = next(p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock"))
env["WAYLAND_DISPLAY"] = str(socket) 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"]: for app in ["remembered", "second", "third"]:
launch([str(root / "client"), "ferese.test.placement." + app, "240", "160"], stdout=log, stderr=log) launch([str(root / "client"), "ferese.test.placement." + app, "240", "160"], stdout=log, stderr=log)
deadline = time.monotonic() + 5 deadline = time.monotonic() + 5
@@ -93,6 +110,10 @@ class FloatingPlacementTest(unittest.TestCase):
if time.monotonic() > deadline: if time.monotonic() > deadline:
self.fail((root / "compositor.log").read_text() + repr(windows)) self.fail((root / "compositor.log").read_text() + repr(windows))
time.sleep(.025) 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")) 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"]) output = next(o for o in command("get-outputs") if o["enabled"])
self.assertAlmostEqual(remembered["x"], output["x"] + .1 * output["width"], delta=1) self.assertAlmostEqual(remembered["x"], output["x"] + .1 * output["width"], delta=1)
+20 -1
View File
@@ -113,15 +113,34 @@ m.main()
self.assertEqual((self.bundles[0] / name).stat().st_mode & 0o777, 0o755) self.assertEqual((self.bundles[0] / name).stat().st_mode & 0o777, 0o755)
def without_appearance_wallpapers(self, bundle): 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_path = bundle / 'manifest.json'
manifest = json.loads(manifest_path.read_text()) manifest = json.loads(manifest_path.read_text())
removed = {'wallpapers/ferese-wallpaper-dark.jpg', 'wallpapers/ferese-wallpaper-light.png'}
for target in removed: for target in removed:
(bundle / target).unlink() (bundle / target).unlink()
manifest['files'] = [item for item in manifest['files'] if item['target'] not in removed] manifest['files'] = [item for item in manifest['files'] if item['target'] not in removed]
manifest_path.write_text(json.dumps(manifest)) 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): def test_upgrade_and_rollback_accept_installed_release_before_appearance_wallpapers(self):
self.install() self.install()
legacy = self.base / 'releases/first' legacy = self.base / 'releases/first'
+3 -1
View File
@@ -14,6 +14,7 @@ import subprocess
import tempfile import tempfile
import time import time
import unittest import unittest
from session_socket import ipc_environment
@unittest.skipUnless(os.environ.get("FERESE_TEST_PORTAL") == "1", "nested portal test is opt-in") @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): def launch(name, command, **kwargs):
log = (root / (name + ".log")).open("w") log = (root / (name + ".log")).open("w")
logs.append(log) 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) stdout=kwargs.pop("stdout", log), start_new_session=True, **kwargs)
processes.append(child) processes.append(child)
return 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")] sockets = lambda: [p for p in runtime.glob("wayland-*") if not p.name.endswith(".lock")]
until(sockets) until(sockets)
env["WAYLAND_DISPLAY"] = str(sockets()[0]) env["WAYLAND_DISPLAY"] = str(sockets()[0])
env = ipc_environment(env)
# Private bus, including the real portal frontend and permission store. # Private bus, including the real portal frontend and permission store.
bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE) bus = launch("bus", ["dbus-daemon", "--session", "--nofork", "--print-address=1"], stdout=subprocess.PIPE)
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip() env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().decode().strip()
+33 -1
View File
@@ -13,7 +13,7 @@ SCRIPT = Path(__file__).resolve().parents[2] / 'packaging/ferese-session-shell'
class SessionStartupTest(unittest.TestCase): class SessionStartupTest(unittest.TestCase):
def run_session(self, direct=True, fail_target=False, shell_status=0, terminate=False, 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: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
helper = root / 'ferese-session-shell' helper = root / 'ferese-session-shell'
@@ -27,6 +27,7 @@ class SessionStartupTest(unittest.TestCase):
'ferese-polkit-agent': 'exec sleep 30\n', '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', '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(): for name, body in programs.items():
path = root / name path = root / name
path.write_text('#!/bin/bash\n' + body) path.write_text('#!/bin/bash\n' + body)
@@ -128,6 +129,37 @@ class SessionStartupTest(unittest.TestCase):
self.assertEqual(status, 143) self.assertEqual(status, 143)
self.assertEqual(events[-1], '--user stop ferese-session.target') 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+1 -1
View File
@@ -98,6 +98,7 @@ impl PopupMotion {
self.sample(now).current as f32 self.sample(now).current as f32
} }
#[cfg(test)]
pub(crate) fn progress(&self) -> f32 { pub(crate) fn progress(&self) -> f32 {
self.progress_at(std::time::Instant::now()) self.progress_at(std::time::Instant::now())
} }
@@ -544,7 +545,6 @@ mod tests {
duration_ms: Some(240.0), duration_ms: Some(240.0),
bounce: 0.3, bounce: 0.3,
overshoot: true, overshoot: true,
..Default::default()
}, },
..Default::default() ..Default::default()
}; };
+3
View File
@@ -1,4 +1,5 @@
use std::ffi::{OsStr, OsString}; use std::ffi::{OsStr, OsString};
use std::os::unix::process::CommandExt;
use std::process::Command; use std::process::Command;
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -18,6 +19,8 @@ pub(crate) fn configure_shell() {
} }
pub(crate) fn configure_app(command: &mut Command) { 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() { if let Some(backend) = APP_BACKEND.get() {
apply_app_backend(command, backend.as_deref()); apply_app_backend(command, backend.as_deref());
} }
+3 -1
View File
@@ -490,6 +490,8 @@ fn encode_regions(regions: &[[f32; 5]]) -> Vec<u8> {
.collect() .collect()
} }
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
pub(super) struct EffectsBinding { pub(super) struct EffectsBinding {
connection: Connection, connection: Connection,
_manager: FereseEffectsManagerV1, _manager: FereseEffectsManagerV1,
@@ -497,7 +499,7 @@ pub(super) struct EffectsBinding {
_queue: EventQueue<EffectsState>, _queue: EventQueue<EffectsState>,
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>, regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
opacity: std::cell::Cell<Option<u32>>, opacity: std::cell::Cell<Option<u32>>,
presentation: std::cell::RefCell<Option<(Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role)>>, presentation: std::cell::RefCell<Option<EffectPresentation>>,
} }
impl EffectsBinding { impl EffectsBinding {