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
+810
-44
No files matched your search
@@ -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)
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user