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:
1 parent
a00f1c6f3a
commit
a6f24e8188
30 files changed
+809
-43
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -21,6 +21,7 @@ pub struct LaunchConfig {
|
||||
pub(crate) session_policy: SessionPolicy,
|
||||
pub client: Vec<OsString>,
|
||||
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]
|
||||
|
||||
@@ -150,11 +150,8 @@ impl PointerGrab<Ferese> 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,
|
||||
|
||||
+51
-5
@@ -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<IpcEvent
|
||||
owner,
|
||||
sender: sender.clone(),
|
||||
};
|
||||
let mut idle = Some(transport::FRAME_TIMEOUT);
|
||||
let mut owns_lease = false;
|
||||
loop {
|
||||
let request: Request = match read_frame(&mut stream) {
|
||||
let request: Request = match transport::read_request(&stream, idle) {
|
||||
Ok(request) => request,
|
||||
Err(ferese_ipc::FrameError::Io(error))
|
||||
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),
|
||||
},
|
||||
};
|
||||
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<IpcEvent
|
||||
let event_stream_requested = request.command == "event-stream";
|
||||
let (events, event_receiver) = sync_channel(crate::ipc_events::QUEUE_CAPACITY);
|
||||
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 call = IpcEvent::Call(IpcCall {
|
||||
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 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 {
|
||||
discard_undelivered_screenshot(&response);
|
||||
}
|
||||
@@ -580,7 +594,7 @@ fn relay_events(stream: &mut UnixStream, events: std::sync::mpsc::Receiver<Arc<f
|
||||
loop {
|
||||
match events.recv_timeout(Duration::from_millis(250)) {
|
||||
Ok(event) => {
|
||||
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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,7 @@ impl Subscribers {
|
||||
generation: self.generation,
|
||||
last: true,
|
||||
change: Change::Snapshot {
|
||||
desktop: snapshot.clone(),
|
||||
desktop: Box::new(snapshot.clone()),
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
+19
-2
@@ -122,8 +122,22 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
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<dyn Error>> {
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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<OsStr>) -> 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<S: AsRef<OsStr>>(
|
||||
state: &mut Ferese,
|
||||
args: impl IntoIterator<Item = S>,
|
||||
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> {
|
||||
let mut args = args.into_iter();
|
||||
let Some(program) = args.next() else {
|
||||
@@ -51,6 +63,10 @@ pub(crate) fn spawn_client<S: AsRef<OsStr>>(
|
||||
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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Snapshot> },
|
||||
OutputsChanged { outputs: Value },
|
||||
WorkspacesChanged { workspaces: Value },
|
||||
WindowsChanged { windows: Value },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<()> {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
let mut fields = fields.iter().collect::<Vec<_>>();
|
||||
fields.sort_unstable_by_key(|(key, _)| *key);
|
||||
for (key, value) in fields {
|
||||
write!(writer, "{:indent$}{key}:", "")?;
|
||||
if value.is_object() || value.is_array() {
|
||||
|
||||
@@ -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[@]}"
|
||||
|
||||
@@ -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:-}}" \
|
||||
# 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=$!
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
"xdg-desktop-portal-ferese"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"source": "LICENSE",
|
||||
"target": "licenses/Ferese-LICENSE.txt",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese",
|
||||
"target": "ferese",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -490,6 +490,8 @@ fn encode_regions(regions: &[[f32; 5]]) -> Vec<u8> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
type EffectPresentation = (Vec<[f32; 5]>, u32, Vec<u32>, ferese_surface_effects_v1::Role);
|
||||
|
||||
pub(super) struct EffectsBinding {
|
||||
connection: Connection,
|
||||
_manager: FereseEffectsManagerV1,
|
||||
@@ -497,7 +499,7 @@ pub(super) struct EffectsBinding {
|
||||
_queue: EventQueue<EffectsState>,
|
||||
regions: std::cell::RefCell<Option<Vec<[f32; 5]>>>,
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user