Files
feresaria/compositor/src/process/supervisor.rs
T

316 lines
11 KiB
Rust

//! Supervise the explicitly opted-in desktop helper. Each generation gets new
//! private Wayland connections; a dead generation's descriptors are never reused.
use super::{
Child, ClientCapabilities, Duration, Ferese, Generic, Interest, LoopHandle, Mode, PostAction,
Rc, RefCell, TimeoutAction, Timer, io, pidfd, spawn_client_inner, terminate_child, warn,
};
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());
}
}