use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitStatus;
use std::time::{Duration, Instant};
pub const LABEL: &str = "ai.zakuro.agent";
pub type Launchctl<'a> = &'a dyn Fn(&[&str]) -> io::Result<ExitStatus>;
pub fn plist_path(home: &Path) -> PathBuf {
home.join("Library")
.join("LaunchAgents")
.join(format!("{LABEL}.plist"))
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub const CARRIED_ENV: [&str; 5] = [
"ZAKURO_HOME",
"ZAKURO_AGENT_PORT",
"ZAKURO_WORKER_DIR",
"ZAKURO_AGENT_MAX_WORKERS",
"ZAKURO_AGENT_WORKER_CMD",
];
pub fn carried_env(lookup: impl Fn(&str) -> Option<String>) -> Vec<(&'static str, String)> {
CARRIED_ENV
.iter()
.filter_map(|&k| lookup(k).filter(|v| !v.is_empty()).map(|v| (k, v)))
.collect()
}
pub fn render_plist(zc_path: &str, home: &str, env: &[(&str, String)], log_path: &str) -> String {
let path = format!(
"/opt/homebrew/bin:/usr/local/bin:{home}/.local/bin:{home}/.cargo/bin:/usr/bin:/bin"
);
let mut vars = format!(
"\t\t<key>PATH</key>\n\t\t<string>{}</string>\n",
xml_escape(&path)
);
for (key, value) in env {
vars.push_str(&format!(
"\t\t<key>{}</key>\n\t\t<string>{}</string>\n",
xml_escape(key),
xml_escape(value)
));
}
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{LABEL}</string>
<key>ProgramArguments</key>
<array>
<string>{zc}</string>
<string>agent</string>
<string>run</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ExitTimeOut</key>
<integer>60</integer>
<key>EnvironmentVariables</key>
<dict>
{vars} </dict>
<key>StandardOutPath</key>
<string>{log}</string>
<key>StandardErrorPath</key>
<string>{log}</string>
</dict>
</plist>
"#,
zc = xml_escape(zc_path),
log = xml_escape(log_path),
)
}
#[cfg(target_os = "macos")]
pub fn gui_domain() -> String {
format!("gui/{}", unsafe { libc::getuid() })
}
#[cfg(target_os = "macos")]
pub fn run_launchctl(args: &[&str]) -> io::Result<ExitStatus> {
let mut cmd = std::process::Command::new("launchctl");
cmd.args(args);
if args.first() == Some(&"print") {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
}
cmd.status()
}
#[derive(Debug, Clone, Copy)]
struct ReloadTiming {
poll_interval: Duration,
removal_deadline: Duration,
notice_after: Duration,
bootstrap_retries: u32,
retry_interval: Duration,
}
impl ReloadTiming {
const LAUNCHD: Self = Self {
poll_interval: Duration::from_millis(100),
removal_deadline: Duration::from_secs(65),
notice_after: Duration::from_secs(2),
bootstrap_retries: 4,
retry_interval: Duration::from_millis(500),
};
}
pub fn install(
launchctl: Launchctl,
domain: &str,
zc_path: &Path,
home: &Path,
env: &[(&str, String)],
agent_dir: &Path,
port: u16,
) -> Result<(), String> {
crate::agent::files::load_or_init_agent_file(agent_dir, port, true)
.map_err(|e| e.to_string())?;
let plist = plist_path(home);
std::fs::create_dir_all(plist.parent().expect("LaunchAgents has a parent"))
.map_err(|e| e.to_string())?;
let log = agent_dir.join(crate::agent::files::LOG_FILE);
std::fs::write(
&plist,
render_plist(
&zc_path.to_string_lossy(),
&home.to_string_lossy(),
env,
&log.to_string_lossy(),
),
)
.map_err(|e| e.to_string())?;
reload(launchctl, domain, &plist, &ReloadTiming::LAUNCHD)
}
fn reload(
launchctl: Launchctl,
domain: &str,
plist: &Path,
t: &ReloadTiming,
) -> Result<(), String> {
let service = format!("{domain}/{LABEL}");
let _ = launchctl(&["bootout", &service]);
wait_until_removed(launchctl, &service, t);
let plist = plist.to_string_lossy();
let mut retries_left = t.bootstrap_retries;
loop {
let st = launchctl(&["bootstrap", domain, &plist]).map_err(|e| e.to_string())?;
if st.success() {
return Ok(());
}
if retries_left == 0 {
return Err(format!("launchctl bootstrap failed ({st})"));
}
retries_left -= 1;
std::thread::sleep(t.retry_interval);
}
}
fn wait_until_removed(launchctl: Launchctl, service: &str, t: &ReloadTiming) {
let start = Instant::now();
let mut told = false;
while matches!(launchctl(&["print", service]), Ok(st) if st.success()) {
let waited = start.elapsed();
if waited >= t.removal_deadline {
return;
}
if !told && waited >= t.notice_after {
println!(" waiting for the old agent to stopโฆ");
told = true;
}
std::thread::sleep(t.poll_interval);
}
}
pub fn uninstall(launchctl: Launchctl, domain: &str, home: &Path) -> Result<(), String> {
let _ = launchctl(&["bootout", &format!("{domain}/{LABEL}")]);
let p = plist_path(home);
if p.exists() {
std::fs::remove_file(&p).map_err(|e| e.to_string())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plist_runs_zc_agent_run_under_launchd_with_the_spec_settings() {
let p = render_plist(
"/usr/local/bin/zc",
"/Users/j",
&[],
"/Users/j/.zakuro/agent/agent.log",
);
assert!(p.contains("<string>ai.zakuro.agent</string>"));
assert!(p.contains("<string>/usr/local/bin/zc</string>\n\t\t<string>agent</string>\n\t\t<string>run</string>"));
assert!(p.contains("<key>RunAtLoad</key>\n\t<true/>"));
assert!(p.contains("<key>KeepAlive</key>\n\t<true/>"));
assert!(p.contains("<key>ExitTimeOut</key>\n\t<integer>60</integer>"));
assert!(p.contains("<string>/opt/homebrew/bin:/usr/local/bin:/Users/j/.local/bin:/Users/j/.cargo/bin:/usr/bin:/bin</string>"));
for key in CARRIED_ENV {
assert!(
!p.contains(key),
"{key} is only written when the installing shell set it"
);
}
assert_eq!(
p.matches("<string>/Users/j/.zakuro/agent/agent.log</string>")
.count(),
2,
"stdout and stderr"
);
}
#[test]
fn plist_carries_zakuro_home_and_escapes_xml() {
let p = render_plist(
"/opt/a&b/zc",
"/Users/j",
&[("ZAKURO_HOME", "/Volumes/z<1>".to_string())],
"/tmp/agent.log",
);
assert!(p.contains("<string>/opt/a&b/zc</string>"));
assert!(p.contains("<key>ZAKURO_HOME</key>\n\t\t<string>/Volumes/z<1></string>"));
}
fn plist_with(key: &'static str, value: &str) -> String {
render_plist(
"/usr/local/bin/zc",
"/Users/j",
&[(key, value.to_string())],
"/tmp/agent.log",
)
}
fn entry(key: &str, escaped_value: &str) -> String {
format!("\t\t<key>{key}</key>\n\t\t<string>{escaped_value}</string>\n")
}
#[test]
fn plist_carries_the_agent_port() {
assert!(
plist_with("ZAKURO_AGENT_PORT", "5000").contains(&entry("ZAKURO_AGENT_PORT", "5000"))
);
}
#[test]
fn plist_carries_the_worker_dir_escaped() {
assert!(plist_with("ZAKURO_WORKER_DIR", "/Users/j/R&D/zak-zakuro")
.contains(&entry("ZAKURO_WORKER_DIR", "/Users/j/R&D/zak-zakuro")));
}
#[test]
fn plist_carries_the_worker_cap() {
assert!(plist_with("ZAKURO_AGENT_MAX_WORKERS", "3")
.contains(&entry("ZAKURO_AGENT_MAX_WORKERS", "3")));
}
#[test]
fn plist_carries_the_worker_command_escaped() {
assert!(plist_with(
"ZAKURO_AGENT_WORKER_CMD",
r#"run-worker --tag "<a&b>" {port}"#
)
.contains(&entry(
"ZAKURO_AGENT_WORKER_CMD",
"run-worker --tag "<a&b>" {port}"
)));
}
#[test]
fn carried_env_takes_the_agents_settings_that_are_set_in_order() {
let env = carried_env(|k| match k {
"ZAKURO_AGENT_WORKER_CMD" => Some("run-worker {port}".into()),
"ZAKURO_AGENT_PORT" => Some("5000".into()),
"ZAKURO_WORKER_DIR" => Some(String::new()), "PATH" | "HOME" | "ZAKURO_P2P" => Some("x".into()), _ => None,
});
assert_eq!(
env,
vec![
("ZAKURO_AGENT_PORT", "5000".to_string()),
("ZAKURO_AGENT_WORKER_CMD", "run-worker {port}".to_string()),
]
);
}
#[test]
fn plist_lives_in_the_users_launch_agents() {
assert_eq!(
plist_path(std::path::Path::new("/Users/j")),
std::path::PathBuf::from("/Users/j/Library/LaunchAgents/ai.zakuro.agent.plist")
);
}
#[cfg(target_os = "macos")]
#[test]
fn plutil_accepts_the_plist() {
let dir = crate::agent::files::tests::tmp("plist");
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("a.plist");
std::fs::write(
&f,
render_plist(
"/usr/local/bin/zc",
"/Users/j",
&carried_env(|k| Some(format!("{k}-<&>\"value"))),
"/tmp/a.log",
),
)
.unwrap();
let out = std::process::Command::new("plutil")
.arg("-lint")
.arg(&f)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stdout)
);
}
struct FakeLaunchctl {
calls: std::sync::Mutex<Vec<Vec<String>>>,
loaded_polls: usize,
bootstrap_failures: usize,
ok: std::process::ExitStatus,
failed: std::process::ExitStatus,
}
fn exit_status(success: bool) -> std::process::ExitStatus {
std::process::Command::new(if success { "true" } else { "false" })
.status()
.unwrap()
}
impl FakeLaunchctl {
fn new() -> Self {
Self::scripted(0, 0)
}
fn scripted(loaded_polls: usize, bootstrap_failures: usize) -> Self {
Self {
calls: std::sync::Mutex::new(vec![]),
loaded_polls,
bootstrap_failures,
ok: exit_status(true),
failed: exit_status(false),
}
}
fn call(&self, args: &[&str]) -> std::io::Result<std::process::ExitStatus> {
let mut calls = self.calls.lock().unwrap();
calls.push(args.iter().map(|s| s.to_string()).collect());
let nth = calls.iter().filter(|c| c[0] == args[0]).count();
let ok = match args[0] {
"bootout" => self.loaded_polls > 0,
"print" => nth <= self.loaded_polls,
"bootstrap" => nth > self.bootstrap_failures,
_ => true,
};
Ok(if ok { self.ok } else { self.failed })
}
fn calls(&self) -> Vec<Vec<String>> {
self.calls.lock().unwrap().clone()
}
fn verbs(&self) -> Vec<String> {
self.calls().into_iter().map(|c| c[0].clone()).collect()
}
fn count(&self, verb: &str) -> usize {
self.verbs().iter().filter(|v| *v == verb).count()
}
}
const FAST: ReloadTiming = ReloadTiming {
poll_interval: Duration::from_millis(1),
removal_deadline: Duration::from_secs(10),
notice_after: Duration::from_millis(1),
bootstrap_retries: 4,
retry_interval: Duration::from_millis(1),
};
#[test]
fn install_paces_launchctl_with_the_production_timings() {
let t = ReloadTiming::LAUNCHD;
assert_eq!(t.poll_interval, Duration::from_millis(100));
assert_eq!(t.removal_deadline, Duration::from_secs(65));
assert_eq!(t.notice_after, Duration::from_secs(2));
assert_eq!(t.bootstrap_retries, 4);
assert_eq!(t.retry_interval, Duration::from_millis(500));
}
fn reload_with(fake: &FakeLaunchctl, t: &ReloadTiming) -> Result<(), String> {
reload(
&|a| fake.call(a),
"gui/501",
Path::new("/Users/j/Library/LaunchAgents/ai.zakuro.agent.plist"),
t,
)
}
#[test]
fn install_writes_the_plist_rotates_the_token_and_boots_out_before_bootstrap() {
let home = crate::agent::files::tests::tmp("launchd-install-home");
let agent_dir = crate::agent::files::tests::tmp("launchd-install-agent");
std::fs::create_dir_all(&home).unwrap();
let existing =
crate::agent::files::load_or_init_agent_file(&agent_dir, 4720, false).unwrap();
let fake = FakeLaunchctl::new();
let zc_path = std::path::Path::new("/usr/local/bin/zc");
install(
&|a| fake.call(a),
"gui/501",
zc_path,
&home,
&[],
&agent_dir,
4720,
)
.expect("install succeeds");
let plist = plist_path(&home);
assert!(plist.exists(), "plist written");
let contents = std::fs::read_to_string(&plist).unwrap();
assert!(contents.contains("/usr/local/bin/zc"));
let rotated = crate::agent::files::load_json::<crate::agent::files::AgentFile>(
&agent_dir.join(crate::agent::files::AGENT_FILE),
)
.unwrap();
assert_ne!(rotated.token, existing.token, "install rotates the token");
let calls = fake.calls();
assert_eq!(calls.len(), 3);
assert_eq!(calls[0], vec!["bootout", "gui/501/ai.zakuro.agent"]);
assert_eq!(calls[1], vec!["print", "gui/501/ai.zakuro.agent"]);
assert_eq!(
calls[2],
vec!["bootstrap", "gui/501", &plist.to_string_lossy()]
);
}
#[test]
fn reload_waits_until_launchd_has_removed_the_old_agent() {
let fake = FakeLaunchctl::scripted(3, 0);
reload_with(&fake, &FAST).expect("reload succeeds");
assert_eq!(
fake.verbs(),
["bootout", "print", "print", "print", "print", "bootstrap"]
);
assert_eq!(fake.calls()[1], vec!["print", "gui/501/ai.zakuro.agent"]);
}
#[test]
fn a_failed_bootstrap_is_retried() {
let fake = FakeLaunchctl::scripted(0, 2);
reload_with(&fake, &FAST).expect("the third bootstrap succeeds");
assert_eq!(fake.count("bootstrap"), 3);
}
#[test]
fn a_bootout_failure_is_ignored_but_a_bootstrap_failure_is_reported() {
let fake = FakeLaunchctl::scripted(0, usize::MAX);
let err = reload_with(&fake, &FAST).unwrap_err();
assert_eq!(
err,
format!("launchctl bootstrap failed ({})", exit_status(false))
);
assert_eq!(
fake.count("bootstrap"),
5,
"the first attempt and 4 retries"
);
}
#[test]
fn bootstrap_is_still_attempted_when_the_old_agent_outlives_the_deadline() {
let fake = FakeLaunchctl::scripted(usize::MAX, 0);
let t = ReloadTiming {
removal_deadline: Duration::from_millis(20),
..FAST
};
reload_with(&fake, &t).expect("the bootstrap after the deadline succeeds");
let verbs = fake.verbs();
assert_eq!(
verbs.last().map(String::as_str),
Some("bootstrap"),
"{verbs:?}"
);
assert_eq!(fake.count("bootstrap"), 1);
assert!(fake.count("print") >= 2, "{verbs:?}");
}
#[test]
fn uninstall_boots_out_and_removes_the_plist_but_keeps_the_agent_dir() {
let home = crate::agent::files::tests::tmp("launchd-uninstall-home");
let agent_dir = crate::agent::files::tests::tmp("launchd-uninstall-agent");
std::fs::create_dir_all(&home).unwrap();
crate::agent::files::load_or_init_agent_file(&agent_dir, 4720, false).unwrap();
let fake = FakeLaunchctl::new();
let zc_path = std::path::Path::new("/usr/local/bin/zc");
install(
&|a| fake.call(a),
"gui/501",
zc_path,
&home,
&[],
&agent_dir,
4720,
)
.unwrap();
assert!(plist_path(&home).exists());
uninstall(&|a| fake.call(a), "gui/501", &home).expect("uninstall succeeds");
assert!(!plist_path(&home).exists(), "plist removed");
assert!(
agent_dir.join(crate::agent::files::AGENT_FILE).exists(),
"agent dir kept"
);
let calls = fake.calls();
assert_eq!(
calls.last().unwrap(),
&vec!["bootout", "gui/501/ai.zakuro.agent"]
);
}
}