#![cfg(target_os = "macos")]
use std::time::Duration;
use crate::launchd::{LaunchdConfig, current_uid};
pub const LAUNCHD_DEFAULT_EXIT_TIMEOUT_SECS: u64 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GraceVerdict {
Sufficient {
active_secs: u64,
},
TooShort {
active_secs: u64,
required_secs: u64,
},
Unknown,
}
impl GraceVerdict {
#[must_use]
pub fn is_too_short(&self) -> bool {
matches!(self, GraceVerdict::TooShort { .. })
}
}
#[must_use]
pub fn grace_verdict(active_secs: Option<u64>, required_secs: u64) -> GraceVerdict {
match active_secs {
None => GraceVerdict::Unknown,
Some(active_secs) if active_secs >= required_secs => {
GraceVerdict::Sufficient { active_secs }
}
Some(active_secs) => GraceVerdict::TooShort {
active_secs,
required_secs,
},
}
}
#[must_use]
pub fn parse_launchctl_exit_timeout(printed: &str) -> Option<u64> {
keyed_value(printed, "exit timeout")?.parse().ok()
}
#[must_use]
pub fn parse_launchctl_pid(printed: &str) -> Option<u32> {
keyed_value(printed, "pid")?.parse().ok()
}
fn keyed_value<'a>(printed: &'a str, key: &str) -> Option<&'a str> {
printed.lines().find_map(|line| {
let (found, value) = line.split_once('=')?;
found.trim().eq_ignore_ascii_case(key).then(|| value.trim())
})
}
#[must_use]
pub fn parse_plist_exit_timeout(xml: &str) -> Option<u64> {
let after_key = xml.split_once("<key>ExitTimeOut</key>")?.1;
let open = after_key.find("<integer>")? + "<integer>".len();
let close = after_key[open..].find("</integer>")? + open;
after_key[open..close].trim().parse().ok()
}
#[must_use]
pub fn plist_grace_secs(xml: &str) -> u64 {
parse_plist_exit_timeout(xml).unwrap_or(LAUNCHD_DEFAULT_EXIT_TIMEOUT_SECS)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Quiesce {
NotRunning,
Exited {
waited_secs: u64,
},
StillRunning,
}
pub fn quiesce_job(
pid: Option<u32>,
required_secs: u64,
terminate: impl FnOnce(u32) -> bool,
mut still_alive: impl FnMut(u32) -> bool,
mut tick: impl FnMut(),
) -> Quiesce {
let Some(pid) = pid else {
return Quiesce::NotRunning;
};
if !still_alive(pid) {
return Quiesce::NotRunning;
}
if !terminate(pid) {
return Quiesce::StillRunning;
}
for waited_secs in 1..=required_secs {
tick();
if !still_alive(pid) {
return Quiesce::Exited { waited_secs };
}
}
Quiesce::StillRunning
}
impl LaunchdConfig {
#[must_use]
pub fn launchctl_print(&self) -> Option<String> {
let target = format!("gui/{}/{}", current_uid(), self.label);
let output = std::process::Command::new("launchctl")
.args(["print", &target])
.output()
.ok()?;
output
.status
.success()
.then(|| String::from_utf8_lossy(&output.stdout).into_owned())
}
#[must_use]
pub fn active_exit_timeout_secs(&self) -> Option<u64> {
if let Some(printed) = self.launchctl_print()
&& let Some(secs) = parse_launchctl_exit_timeout(&printed)
{
return Some(secs);
}
let path = self.plist_path().ok()?;
let xml = std::fs::read_to_string(path).ok()?;
Some(plist_grace_secs(&xml))
}
#[must_use]
pub fn active_grace_verdict(&self, required_secs: u64) -> GraceVerdict {
grace_verdict(self.active_exit_timeout_secs(), required_secs)
}
pub fn quiesce_before_bootout(&self, required_secs: u64) -> Quiesce {
let pid = self
.launchctl_print()
.as_deref()
.and_then(parse_launchctl_pid);
quiesce_job(
pid,
required_secs,
|pid| signal_process(pid, libc::SIGTERM),
|pid| signal_process(pid, 0),
|| std::thread::sleep(Duration::from_secs(1)),
)
}
}
fn signal_process(pid: u32, sig: i32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, sig) == 0 }
}
#[cfg(test)]
mod tests {
use super::*;
const PRINTED: &str = "\
com.trusty.search = {
\tactive count = 1
\tpath = /Users/test/Library/LaunchAgents/com.trusty.search.plist
\tstate = running
\tpid = 23570
\toriginal pid = 86880
\tprogram = /Users/test/.cargo/bin/trusty-search
\truns = 18
\texit timeout = 5
\tlast exit code = 1
}";
#[test]
fn grace_verdict_flags_the_measured_launchd_default() {
assert_eq!(
grace_verdict(Some(LAUNCHD_DEFAULT_EXIT_TIMEOUT_SECS), 60),
GraceVerdict::TooShort {
active_secs: 5,
required_secs: 60,
},
"launchd's 5 s default cannot cover a 55 s snapshot flush"
);
assert!(grace_verdict(Some(5), 60).is_too_short());
}
#[test]
fn grace_verdict_accepts_an_equal_window() {
assert_eq!(
grace_verdict(Some(60), 60),
GraceVerdict::Sufficient { active_secs: 60 }
);
assert_eq!(
grace_verdict(Some(120), 60),
GraceVerdict::Sufficient { active_secs: 120 }
);
assert!(!grace_verdict(Some(60), 60).is_too_short());
}
#[test]
fn grace_verdict_is_unknown_when_unreadable() {
assert_eq!(grace_verdict(None, 60), GraceVerdict::Unknown);
assert!(!GraceVerdict::Unknown.is_too_short());
}
#[test]
fn parse_launchctl_exit_timeout_reads_the_loaded_window() {
assert_eq!(parse_launchctl_exit_timeout(PRINTED), Some(5));
}
#[test]
fn parse_launchctl_exit_timeout_ignores_unrelated_lines() {
assert_eq!(parse_launchctl_exit_timeout("\tstate = running\n"), None);
assert_eq!(parse_launchctl_exit_timeout(""), None);
}
#[test]
fn parse_launchctl_pid_reads_a_running_job() {
assert_eq!(parse_launchctl_pid(PRINTED), Some(23570));
}
#[test]
fn parse_launchctl_pid_is_none_for_an_idle_job() {
assert_eq!(parse_launchctl_pid("\tstate = waiting\n\truns = 3\n"), None);
}
#[test]
fn parse_plist_exit_timeout_reads_a_declared_window() {
let xml = " <key>ExitTimeOut</key>\n <integer>60</integer>\n";
assert_eq!(parse_plist_exit_timeout(xml), Some(60));
}
#[test]
fn parse_plist_exit_timeout_is_none_when_undeclared() {
let xml = " <key>ThrottleInterval</key>\n <integer>30</integer>\n";
assert_eq!(parse_plist_exit_timeout(xml), None);
}
#[test]
fn plist_grace_falls_back_to_the_system_default() {
let legacy = " <key>KeepAlive</key>\n <dict/>\n";
assert_eq!(plist_grace_secs(legacy), LAUNCHD_DEFAULT_EXIT_TIMEOUT_SECS);
assert!(grace_verdict(Some(plist_grace_secs(legacy)), 60).is_too_short());
}
#[test]
fn plist_grace_prefers_a_declared_window() {
let current = " <key>ExitTimeOut</key>\n <integer>60</integer>\n";
assert_eq!(plist_grace_secs(current), 60);
}
#[test]
fn quiesce_reports_not_running_without_a_pid() {
let outcome = quiesce_job(
None,
60,
|_| unreachable!("nothing to signal"),
|_| unreachable!("nothing to probe"),
|| unreachable!("nothing to wait for"),
);
assert_eq!(outcome, Quiesce::NotRunning);
}
#[test]
fn quiesce_reports_not_running_when_the_pid_is_already_gone() {
let outcome = quiesce_job(
Some(4242),
60,
|_| unreachable!("a dead process must not be signalled"),
|_| false,
|| unreachable!("nothing to wait for"),
);
assert_eq!(outcome, Quiesce::NotRunning);
}
#[test]
fn quiesce_waits_for_a_clean_exit() {
let mut probes = 0_u64;
let mut ticks = 0_u64;
let outcome = quiesce_job(
Some(4242),
60,
|_| true,
|_| {
probes += 1;
probes <= 16
},
|| ticks += 1,
);
assert_eq!(
outcome,
Quiesce::Exited { waited_secs: 16 },
"a direct SIGTERM must be allowed to outlive launchd's short window"
);
assert_eq!(ticks, 16, "one probe per second, no busy loop");
assert!(
ticks > LAUNCHD_DEFAULT_EXIT_TIMEOUT_SECS,
"a wait that fits inside launchd's default would not have helped"
);
}
#[test]
fn quiesce_gives_up_at_the_window() {
let mut ticks = 0_u64;
let outcome = quiesce_job(Some(4242), 3, |_| true, |_| true, || ticks += 1);
assert_eq!(outcome, Quiesce::StillRunning);
assert_eq!(ticks, 3, "the wait is bounded by the required window");
}
#[test]
fn quiesce_reports_still_running_when_the_signal_fails() {
let outcome = quiesce_job(
Some(4242),
60,
|_| false,
|_| true,
|| unreachable!("no wait after a signal that never landed"),
);
assert_eq!(outcome, Quiesce::StillRunning);
}
}