From be7aff53bdf27bf424e91bf8680402e28c261580 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:05:06 -0300 Subject: [PATCH 1/5] feat(platform): start with the system on Linux and macOS - Linux: a systemd user service. Enabled when Botloft starts with the system; Restart=on-failure brings the daemon back after a crash, and KillMode=control-group ends its bots and browsers with it. - macOS: a launch agent. RunAtLoad follows the owner's choice, and KeepAlive watches a keep-alive file that starting creates and stopping (or a clean exit) deletes, since a plain KeepAlive would also start it at every sign-in. - Task arguments are a list; Windows quotes them for its command line. - Messages name what starts the daemon on each system. Spec 14.1 describes both. --- crates/botloftd/src/autostart/choice.rs | 12 +- crates/botloftd/src/autostart/mod.rs | 93 ++++-- crates/botloftd/src/main.rs | 3 + crates/botloftd/src/platform/mod.rs | 22 +- crates/botloftd/src/platform/unix.rs | 56 ++-- crates/botloftd/src/platform/unix/launchd.rs | 290 ++++++++++++++++++ crates/botloftd/src/platform/unix/systemd.rs | 223 ++++++++++++++ crates/botloftd/src/platform/windows.rs | 3 + .../botloftd/src/platform/windows/task_xml.rs | 42 ++- docs/spec.md | 9 +- 10 files changed, 673 insertions(+), 80 deletions(-) create mode 100644 crates/botloftd/src/platform/unix/launchd.rs create mode 100644 crates/botloftd/src/platform/unix/systemd.rs diff --git a/crates/botloftd/src/autostart/choice.rs b/crates/botloftd/src/autostart/choice.rs index bba95525..15f2ed1a 100644 --- a/crates/botloftd/src/autostart/choice.rs +++ b/crates/botloftd/src/autostart/choice.rs @@ -18,6 +18,7 @@ use anyhow::{Context, bail}; use tracing::{info, warn}; use super::{config, register, stop_task, task_name}; +use crate::platform::TASK_KIND; use crate::platform::{self, Triggers}; const SIGN_IN_FILE: &str = "signin"; @@ -63,11 +64,12 @@ fn is_new_sign_in(home: &Path) -> bool { pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool { if !start_with_windows && is_new_sign_in(home) { if let Err(err) = register(home, stopped(false)) { - warn!("cannot take the triggers off the scheduled task: {err:#}"); + warn!("cannot take the triggers off the {TASK_KIND}: {err:#}"); } return false; } mark_sign_in(home); + super::keep_running(home); // Started at sign-in after the owner stopped it: the minute trigger // comes back. let wanted = running(start_with_windows); @@ -75,7 +77,7 @@ pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool { if task.is_ok_and(|task| task.is_some_and(|task| task.triggers != wanted)) && let Err(err) = register(home, wanted) { - warn!("cannot update the scheduled task: {err:#}"); + warn!("cannot update the {TASK_KIND}: {err:#}"); } true } @@ -85,12 +87,12 @@ pub fn scheduled_start(home: &Path, start_with_windows: bool) -> bool { pub fn stop(home: &Path) -> anyhow::Result<()> { let name = task_name(home); if platform::find_task(&name)?.is_none() { - bail!("the scheduled task {name} is not installed; run `botloftd service install`"); + bail!("the {TASK_KIND} {name} is not installed; run `botloftd service install`"); } let config = config(home)?; register(home, stopped(config.start_with_windows))?; stop_task(&name, config.port)?; - println!("Stopped the daemon of the scheduled task {name}."); + println!("Stopped the daemon of the {TASK_KIND} {name}."); Ok(()) } @@ -99,7 +101,7 @@ pub fn stop(home: &Path) -> anyhow::Result<()> { pub fn set_start_with_windows(home: &Path, start: bool) -> anyhow::Result<()> { let name = task_name(home); let task = platform::find_task(&name) - .with_context(|| format!("cannot read the scheduled task {name}"))?; + .with_context(|| format!("cannot read the {TASK_KIND} {name}"))?; if let Some(task) = task { register( home, diff --git a/crates/botloftd/src/autostart/mod.rs b/crates/botloftd/src/autostart/mod.rs index 21075e4b..b967d547 100644 --- a/crates/botloftd/src/autostart/mod.rs +++ b/crates/botloftd/src/autostart/mod.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; use crate::config::Config; use crate::paths; -use crate::platform::{self, TaskDefinition, TaskState, Triggers}; +use crate::platform::{self, TASK_KIND, TaskDefinition, TaskState, Triggers}; /// How long a daemon may take to stop or to answer after starting. const STOP_TIMEOUT: Duration = Duration::from_secs(10); @@ -38,13 +38,6 @@ pub fn task_name(home: &Path) -> String { } } -/// Quotes one argument for the Windows command line. Backslashes before -/// the closing quote are doubled, so `C:\` does not escape it. -fn quote(arg: &str) -> String { - let trailing = arg.len() - arg.trim_end_matches('\\').len(); - format!("\"{arg}{}\"", "\\".repeat(trailing)) -} - /// `--scheduled` tells the daemon the task started it (`choice`). fn definition(home: &Path, program: &Path, triggers: Triggers) -> TaskDefinition { let home_text = home.to_string_lossy(); @@ -53,18 +46,51 @@ fn definition(home: &Path, program: &Path, triggers: Triggers) -> TaskDefinition "Starts the Botloft daemon at logon and keeps it running. Data folder: {home_text}" ), program: program.to_owned(), - arguments: format!("serve --home {} --scheduled", quote(&home_text)), + arguments: vec![ + "serve".to_owned(), + "--home".to_owned(), + home_text.into_owned(), + "--scheduled".to_owned(), + ], working_dir: home.to_owned(), + keep_alive: keep_alive_file(home), triggers, } } +/// While it exists, launchd brings the daemon back on macOS (spec 14.1). +pub fn keep_alive_file(home: &Path) -> PathBuf { + home.join("run").join("keep-alive") +} + +/// The daemon runs from its task: launchd should bring it back if it dies, +/// also when it started at sign-in (spec 14.1). +fn keep_running(home: &Path) { + let file = keep_alive_file(home); + let made = file + .parent() + .map_or(Ok(()), std::fs::create_dir_all) + .and_then(|()| std::fs::write(&file, b"")); + if let Err(err) = made { + tracing::warn!("cannot write the keep-alive file: {err}"); + } +} + +/// The daemon stopped cleanly: nothing should bring it back (spec 14.1). +pub fn stopped_cleanly(home: &Path) { + match std::fs::remove_file(keep_alive_file(home)) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => tracing::warn!("cannot delete the keep-alive file: {err}"), + } +} + /// Registers the task with `triggers`, running the installed daemon. fn register(home: &Path, triggers: Triggers) -> anyhow::Result<()> { let name = task_name(home); let program = bin_dir(home).join(binary::EXE_NAME); platform::register_task(&name, &definition(home, &program, triggers)) - .with_context(|| format!("cannot register the scheduled task {name}")) + .with_context(|| format!("cannot register the {TASK_KIND} {name}")) } fn config(home: &Path) -> anyhow::Result { @@ -94,7 +120,7 @@ pub fn install(home: &Path) -> anyhow::Result<()> { // The owner opened Botloft: it runs in this sign-in. choice::mark_sign_in(home); println!( - "Scheduled task {name} starts {} at logon.", + "The {TASK_KIND} {name} starts {} at logon.", installed.path.display() ); @@ -111,7 +137,7 @@ pub fn install(home: &Path) -> anyhow::Result<()> { pub fn restart(home: &Path) -> anyhow::Result<()> { let name = task_name(home); if platform::find_task(&name)?.is_none() { - bail!("the scheduled task {name} is not installed; run `botloftd service install`"); + bail!("the {TASK_KIND} {name} is not installed; run `botloftd service install`"); } restart_task(home, port(home)?) } @@ -120,7 +146,7 @@ fn restart_task(home: &Path, port: u16) -> anyhow::Result<()> { let name = &task_name(home); stop_task(name, port)?; choice::mark_sign_in(home); - platform::run_task(name).with_context(|| format!("cannot start the scheduled task {name}"))?; + platform::run_task(name).with_context(|| format!("cannot start the {TASK_KIND} {name}"))?; let up = health::wait(port, START_TIMEOUT, |health| { health.is_some_and(|health| health.version == VERSION) }); @@ -144,14 +170,14 @@ fn stop_task(name: &str, port: u16) -> anyhow::Result<()> { if !running { return Ok(()); } - platform::stop_task(name).with_context(|| format!("cannot stop the scheduled task {name}"))?; + platform::stop_task(name).with_context(|| format!("cannot stop the {TASK_KIND} {name}"))?; let stopped = health::wait(port, STOP_TIMEOUT, |health| health.is_none()) && wait_until(STOP_TIMEOUT, || { platform::find_task(name) .is_ok_and(|task| task.is_none_or(|task| task.state != TaskState::Running)) }); if !stopped { - bail!("the daemon of the scheduled task {name} did not stop"); + bail!("the daemon of the {TASK_KIND} {name} did not stop"); } Ok(()) } @@ -163,9 +189,9 @@ pub fn uninstall(home: &Path) -> anyhow::Result<()> { let port = port(home).unwrap_or(Config::default().port); stop_task(&name, port)?; if platform::delete_task(&name)? { - println!("Deleted the scheduled task {name}."); + println!("Deleted the {TASK_KIND} {name}."); } else { - println!("The scheduled task {name} was not installed."); + println!("The {TASK_KIND} {name} was not installed."); } binary::remove(&bin_dir(home)); println!("Your bots and data stay in {}.", home.display()); @@ -183,12 +209,12 @@ pub fn status(home: &Path) -> anyhow::Result<()> { TaskState::Disabled => "disabled", TaskState::Unknown => "in an unknown state", }; - println!("Scheduled task {name}: installed, {state}."); + println!("The {TASK_KIND} {name} is installed, {state}."); if let Some(command) = task.command { println!(" {command}"); } } - None => println!("Scheduled task {name}: not installed."), + None => println!("The {TASK_KIND} {name} is not installed."), } let port = port(home)?; match health::probe(port) { @@ -218,19 +244,26 @@ mod tests { use super::*; #[test] - fn arguments_are_quoted_for_the_command_line() { + fn the_task_serves_its_data_folder() { + let home = Path::new("/data/Ana Lima"); + let task = definition(home, &home.join("bin/botloftd"), choice::running(true)); assert_eq!( - quote(r"C:\Users\Ana Lima\Botloft"), - r#""C:\Users\Ana Lima\Botloft""# - ); - assert_eq!(quote(r"D:\"), r#""D:\\""#); - let task = definition( - Path::new(r"C:\data"), - Path::new(r"C:\data\bin\botloftd.exe"), - choice::running(true), + task.arguments, + ["serve", "--home", "/data/Ana Lima", "--scheduled"] ); - assert_eq!(task.arguments, r#"serve --home "C:\data" --scheduled"#); - assert!(task.description.ends_with(r"Data folder: C:\data")); + assert_eq!(task.keep_alive, home.join("run").join("keep-alive")); + assert!(task.description.ends_with("Data folder: /data/Ana Lima")); + } + + #[test] + fn a_clean_stop_deletes_the_keep_alive_file() { + let home = tempfile::tempdir().expect("home"); + let file = keep_alive_file(home.path()); + std::fs::create_dir_all(file.parent().expect("run")).expect("run"); + std::fs::write(&file, b"").expect("write"); + stopped_cleanly(home.path()); + assert!(!file.exists()); + stopped_cleanly(home.path()); } #[test] diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index b840d96f..25912881 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -158,6 +158,9 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a )); server::serve(Arc::clone(&daemon), listener, platform::shutdown_signal()).await?; daemon.supervisor.shutdown(); + // A stop asked for (logout, `service stop`): launchd must not + // bring the daemon back (spec 14.1). + autostart::stopped_cleanly(&daemon.paths.home); info!("stopped"); anyhow::Ok(()) }) diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index ebc2f2c9..6270cc5b 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -12,13 +12,15 @@ use std::path::{Path, PathBuf}; #[cfg(unix)] pub use unix::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle, - register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, + KeepAwake, ProcessJob, TASK_KIND, delete_task, find_task, leave_own_console, owner_name, + recycle, register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, + user_environment, }; #[cfg(windows)] pub use windows::{ - KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle, - register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment, + KeepAwake, ProcessJob, TASK_KIND, delete_task, find_task, leave_own_console, owner_name, + recycle, register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, + user_environment, }; /// A program the system starts for the owner (spec 14). @@ -26,18 +28,22 @@ pub use windows::{ pub struct TaskDefinition { pub description: String, pub program: PathBuf, - /// Already quoted for the command line. - pub arguments: String, + pub arguments: Vec, pub working_dir: PathBuf, + /// On macOS, launchd keeps the program running while this file exists + /// (spec 14.1). The program deletes it when it stops cleanly. + pub keep_alive: PathBuf, pub triggers: Triggers, } /// When the system starts the task (spec 14). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Triggers { - /// When the owner signs in to Windows. + /// When the owner signs in. pub logon: bool, - /// Every minute, which brings the program back soon after it stops. + /// Brings the program back soon after it stops: a trigger every minute + /// on Windows. On Linux and macOS the service manager restarts it while + /// it runs, so this is always on there (14.1). pub watchdog: bool, } diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index a058f83b..25691344 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -1,9 +1,14 @@ -//! Linux and macOS (spec 14.1). Starting with the system comes later; the -//! rest gives bots what they get on Windows. +//! Linux and macOS (spec 14.1): what bots get on Windows, and the daemon +//! started for the owner by systemd or launchd. mod account; mod env; mod job; +// Both build everywhere, so each one's tests run on either system. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod launchd; +#[cfg_attr(target_os = "macos", allow(dead_code))] +mod systemd; use std::io; use std::os::unix::fs::PermissionsExt; @@ -12,7 +17,21 @@ use std::path::Path; pub use self::account::owner_name; pub use self::env::user_environment; pub use self::job::ProcessJob; -use super::{TaskDefinition, TaskInfo}; +#[cfg(target_os = "macos")] +pub use self::launchd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task}; +#[cfg(not(target_os = "macos"))] +pub use self::systemd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task}; + +/// Writes `path` whole or not at all, creating its folder. +fn write_atomically(path: &Path, contents: &[u8]) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut staged = path.as_os_str().to_owned(); + staged.push(".new"); + std::fs::write(&staged, contents)?; + std::fs::rename(&staged, path) +} /// `0700` for folders, `0600` for files. pub fn restrict_to_current_user(path: &Path) -> io::Result<()> { @@ -54,34 +73,6 @@ impl KeepAwake { /// Nothing to leave: Unix never opens a console for the daemon. pub fn leave_own_console() {} -fn no_tasks() -> io::Error { - io::Error::new( - io::ErrorKind::Unsupported, - "starting at logon is only supported on Windows", - ) -} - -pub fn register_task(_name: &str, _task: &TaskDefinition) -> io::Result<()> { - Err(no_tasks()) -} - -/// Nothing can be installed here yet, so there is never a task to find. -pub fn find_task(_name: &str) -> io::Result> { - Ok(None) -} - -pub fn run_task(_name: &str) -> io::Result<()> { - Err(no_tasks()) -} - -pub fn stop_task(_name: &str) -> io::Result<()> { - Err(no_tasks()) -} - -pub fn delete_task(_name: &str) -> io::Result { - Ok(false) -} - /// There is no Recycle Bin off Windows: the folder stays. pub fn recycle(_path: &Path) -> io::Result<()> { Err(io::Error::new( @@ -90,7 +81,8 @@ pub fn recycle(_path: &Path) -> io::Result<()> { )) } -/// Unix has no Windows sign-in to tell apart. +/// No sign-in to tell apart: systemd and launchd start the daemon at +/// sign-in only when the owner wants it, so a new sign-in needs no check. pub fn sign_in_id() -> Option { None } diff --git a/crates/botloftd/src/platform/unix/launchd.rs b/crates/botloftd/src/platform/unix/launchd.rs new file mode 100644 index 00000000..dcde593f --- /dev/null +++ b/crates/botloftd/src/platform/unix/launchd.rs @@ -0,0 +1,290 @@ +//! The daemon as a launch agent on macOS (spec 14.1), in +//! `~/Library/LaunchAgents`. `RunAtLoad` starts it when the owner signs +//! in. `KeepAlive` keeps it running while its keep-alive file exists: +//! starting creates the file and stopping deletes it, so launchd brings the +//! daemon back after a crash but not after a stop asked for. A plain +//! `KeepAlive` would also start it at every sign-in. + +use std::io; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::time::Duration; + +use super::write_atomically; +use crate::platform::{TaskDefinition, TaskInfo, TaskState, Triggers}; + +pub const TASK_KIND: &str = "launch agent"; + +/// `Botloft` is the installed daemon's agent; `Botloft-` a dev one. +fn label(name: &str) -> String { + let rest = name.strip_prefix("Botloft").unwrap_or(name); + format!("io.github.httpsphl.botloft.daemon{}", rest.to_lowercase()) +} + +fn plist_path(name: &str) -> io::Result { + let home = dirs::home_dir().ok_or_else(|| io::Error::other("cannot find the home folder"))?; + Ok(home + .join("Library") + .join("LaunchAgents") + .join(format!("{}.plist", label(name)))) +} + +/// The owner's GUI session, where agents run with their sign-in. +fn domain() -> String { + // SAFETY: no arguments, never fails. + format!("gui/{}", unsafe { libc::getuid() }) +} + +fn service(name: &str) -> String { + format!("{}/{}", domain(), label(name)) +} + +fn launchctl(args: &[&str]) -> io::Result { + Command::new("launchctl") + .args(args) + .stdin(Stdio::null()) + .output() +} + +fn run(args: &[&str]) -> io::Result<()> { + let output = launchctl(args)?; + if output.status.success() { + return Ok(()); + } + let said = [output.stderr, output.stdout] + .map(|bytes| String::from_utf8_lossy(&bytes).trim().to_owned()) + .join(" "); + Err(io::Error::other(format!( + "`launchctl {}` failed: {}", + args.join(" "), + said.trim() + ))) +} + +pub fn register_task(name: &str, task: &TaskDefinition) -> io::Result<()> { + write_atomically( + &plist_path(name)?, + plist_text(&label(name), task).as_bytes(), + ) +} + +pub fn find_task(name: &str) -> io::Result> { + let text = match std::fs::read_to_string(plist_path(name)?) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + let state = match launchctl(&["print", &service(name)]) { + Ok(output) if output.status.success() => { + if String::from_utf8_lossy(&output.stdout).contains("state = running") { + TaskState::Running + } else { + TaskState::Ready + } + } + // Not loaded: it waits for the next sign-in or for `run_task`. + Ok(_) => TaskState::Ready, + Err(_) => TaskState::Unknown, + }; + let command = strings(&text, "ProgramArguments").map(|args| args.join(" ")); + Ok(Some(TaskInfo { + state, + command, + triggers: Triggers { + logon: text.contains("RunAtLoad"), + watchdog: true, + }, + })) +} + +/// Loads the agent anew from its file and starts it. +pub fn run_task(name: &str) -> io::Result<()> { + let path = plist_path(name)?; + let text = std::fs::read_to_string(&path)?; + if let Some(file) = keep_alive(&text) { + write_atomically(&file, b"")?; + } + // Errors only mean it was not loaded. + let _ = launchctl(&["bootout", &service(name)]); + let path = path.to_string_lossy(); + // Right after a bootout, launchd may still refuse the same label. + let mut tries = 0; + loop { + match run(&["bootstrap", &domain(), &path]) { + Ok(()) => break, + Err(_) if tries < 10 => { + tries += 1; + std::thread::sleep(Duration::from_millis(300)); + } + Err(err) => return Err(err), + } + } + // With the keep-alive file there, launchd may have started it already; + // whether it answers is what the caller waits for. + let _ = launchctl(&["kickstart", &service(name)]); + Ok(()) +} + +/// Deletes the keep-alive file, then asks the daemon to stop. +pub fn stop_task(name: &str) -> io::Result<()> { + let text = std::fs::read_to_string(plist_path(name)?)?; + if let Some(file) = keep_alive(&text) { + remove(&file)?; + } + run(&["kill", "SIGTERM", &service(name)]) +} + +pub fn delete_task(name: &str) -> io::Result { + let path = plist_path(name)?; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(err) => return Err(err), + }; + if let Some(file) = keep_alive(&text) { + remove(&file)?; + } + let _ = launchctl(&["bootout", &service(name)]); + std::fs::remove_file(&path)?; + Ok(true) +} + +fn remove(path: &Path) -> io::Result<()> { + match std::fs::remove_file(path) { + Err(err) if err.kind() != io::ErrorKind::NotFound => Err(err), + _ => Ok(()), + } +} + +fn plist_text(label: &str, task: &TaskDefinition) -> String { + let arguments: String = std::iter::once(task.program.to_string_lossy().into_owned()) + .chain(task.arguments.iter().cloned()) + .map(|arg| format!(" {}\n", escape(&arg))) + .collect(); + let run_at_load = if task.triggers.logon { + "" + } else { + "" + }; + format!( + r#" + + + + + Label{label} + ProgramArguments + +{arguments} + WorkingDirectory{dir} + RunAtLoad{run_at_load} + KeepAlive + + PathState + + {keep_alive} + + + ProcessTypeStandard + + +"#, + label = escape(label), + dir = escape(&task.working_dir.to_string_lossy()), + keep_alive = escape(&task.keep_alive.to_string_lossy()), + ) +} + +/// The keep-alive file named in the agent this module wrote. +fn keep_alive(plist: &str) -> Option { + let rest = plist.split_once("PathState")?.1; + let key = rest.split_once("")?.1.split_once("")?.0; + Some(PathBuf::from(unescape(key))) +} + +/// The `` values of the array under `key`. +fn strings(plist: &str, key: &str) -> Option> { + let rest = plist.split_once(&format!("{key}"))?.1; + let array = rest.split_once("")?.1.split_once("")?.0; + Some( + array + .split("") + .skip(1) + .filter_map(|part| part.split_once("")) + .map(|(value, _)| unescape(value)) + .collect(), + ) +} + +fn escape(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn unescape(text: &str) -> String { + text.replace("<", "<") + .replace(">", ">") + .replace("&", "&") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn task(logon: bool) -> TaskDefinition { + TaskDefinition { + description: "Starts Botloft".into(), + program: PathBuf::from("/Users/ana/Library/Application Support/Botloft/bin/botloftd"), + arguments: ["serve", "--home", "/Users/ana/A & B", "--scheduled"] + .map(String::from) + .to_vec(), + working_dir: PathBuf::from("/Users/ana/A & B"), + keep_alive: PathBuf::from("/Users/ana/A & B/run/keep-alive"), + triggers: Triggers { + logon, + watchdog: true, + }, + } + } + + #[test] + fn the_agent_runs_the_daemon_while_its_keep_alive_file_exists() { + let text = plist_text("io.github.httpsphl.botloft.daemon", &task(true)); + assert!( + text.contains("Labelio.github.httpsphl.botloft.daemon") + ); + assert!(text.contains("RunAtLoad")); + assert!(text.contains("WorkingDirectory/Users/ana/A & B")); + assert_eq!( + keep_alive(&text), + Some(PathBuf::from("/Users/ana/A & B/run/keep-alive")) + ); + assert_eq!( + strings(&text, "ProgramArguments").expect("arguments"), + [ + "/Users/ana/Library/Application Support/Botloft/bin/botloftd", + "serve", + "--home", + "/Users/ana/A & B", + "--scheduled", + ] + ); + } + + #[test] + fn without_starting_at_sign_in_it_does_not_run_at_load() { + let text = plist_text("x", &task(false)); + assert!(text.contains("RunAtLoad")); + assert!(!text.contains("RunAtLoad")); + } + + #[test] + fn each_data_folder_has_its_own_label() { + assert_eq!(label("Botloft"), "io.github.httpsphl.botloft.daemon"); + assert_eq!( + label("Botloft-1A2B3C4D"), + "io.github.httpsphl.botloft.daemon-1a2b3c4d" + ); + } +} diff --git a/crates/botloftd/src/platform/unix/systemd.rs b/crates/botloftd/src/platform/unix/systemd.rs new file mode 100644 index 00000000..d279b667 --- /dev/null +++ b/crates/botloftd/src/platform/unix/systemd.rs @@ -0,0 +1,223 @@ +//! The daemon as a `systemd --user` service on Linux (spec 14.1). Enabled, +//! the owner's user manager starts it when they sign in; while it runs, +//! systemd brings it back after a crash. Stopping the service ends every +//! process in its control group, the bots and their browsers too. + +use std::io; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +use super::write_atomically; +use crate::platform::{TaskDefinition, TaskInfo, TaskState, Triggers}; + +pub const TASK_KIND: &str = "systemd user service"; + +fn unit(name: &str) -> String { + format!("{}.service", name.to_lowercase()) +} + +fn unit_path(name: &str) -> io::Result { + let config = dirs::config_dir() + .ok_or_else(|| io::Error::other("cannot find the configuration folder"))?; + Ok(config.join("systemd").join("user").join(unit(name))) +} + +fn systemctl(args: &[&str]) -> io::Result { + Command::new("systemctl") + .arg("--user") + .args(args) + .stdin(Stdio::null()) + .output() +} + +/// Runs `systemctl --user` and fails with what it said. +fn run(args: &[&str]) -> io::Result<()> { + let output = systemctl(args)?; + if output.status.success() { + return Ok(()); + } + Err(io::Error::other(format!( + "`systemctl --user {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + ))) +} + +/// The first word `systemctl --user ` prints, whatever its +/// exit code (`inactive` and `disabled` come with a failure code). +fn query(query: &str, unit: &str) -> Option { + let output = systemctl(&[query, unit]).ok()?; + let text = String::from_utf8_lossy(&output.stdout); + text.split_whitespace().next().map(str::to_owned) +} + +pub fn register_task(name: &str, task: &TaskDefinition) -> io::Result<()> { + let path = unit_path(name)?; + write_atomically(&path, unit_text(task).as_bytes())?; + run(&["daemon-reload"])?; + let verb = if task.triggers.logon { + "enable" + } else { + "disable" + }; + run(&[verb, &unit(name)]) +} + +pub fn find_task(name: &str) -> io::Result> { + let path = unit_path(name)?; + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + let unit = unit(name); + let state = match query("is-active", &unit).as_deref() { + Some("active" | "activating" | "reloading" | "deactivating") => TaskState::Running, + Some("inactive" | "failed") => TaskState::Ready, + _ => TaskState::Unknown, + }; + let logon = query("is-enabled", &unit).as_deref() == Some("enabled"); + Ok(Some(TaskInfo { + state, + command: exec_line(&text), + triggers: Triggers { + logon, + watchdog: true, + }, + })) +} + +pub fn run_task(name: &str) -> io::Result<()> { + run(&["start", &unit(name)]) +} + +pub fn stop_task(name: &str) -> io::Result<()> { + run(&["stop", &unit(name)]) +} + +pub fn delete_task(name: &str) -> io::Result { + let path = unit_path(name)?; + if !path.exists() { + return Ok(false); + } + let unit = unit(name); + // Errors here only mean there was nothing to stop or disable. + let _ = systemctl(&["stop", &unit]); + let _ = systemctl(&["disable", &unit]); + std::fs::remove_file(&path)?; + let _ = systemctl(&["daemon-reload"]); + let _ = systemctl(&["reset-failed", &unit]); + Ok(true) +} + +/// The unit. `Restart=on-failure` brings the daemon back after a crash but +/// not after a stop asked for (logout, `service stop`), which ends with a +/// clean exit. The default `KillMode=control-group` is spelled out: it is +/// what ends the bots with the daemon. +fn unit_text(task: &TaskDefinition) -> String { + let exec = std::iter::once(task.program.to_string_lossy().into_owned()) + .chain(task.arguments.iter().cloned()) + .map(|arg| quote(&arg)) + .collect::>() + .join(" "); + format!( + "# Written by Botloft (spec 14.1). `botloftd service uninstall` removes it.\n\ + [Unit]\n\ + Description={description}\n\ + \n\ + [Service]\n\ + Type=exec\n\ + ExecStart={exec}\n\ + WorkingDirectory={dir}\n\ + Restart=on-failure\n\ + RestartSec=5\n\ + KillMode=control-group\n\ + TimeoutStopSec=20\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + description = specifiers(&task.description), + dir = specifiers(&task.working_dir.to_string_lossy()), + ) +} + +/// `%` starts a specifier in a unit file. +fn specifiers(text: &str) -> String { + text.replace('%', "%%") +} + +/// One word of `ExecStart`, in double quotes: `\` and `"` escaped, `%` and +/// `$` doubled so systemd takes them as they are. +fn quote(arg: &str) -> String { + let mut out = String::from("\""); + for c in arg.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '%' => out.push_str("%%"), + '$' => out.push_str("$$"), + _ => out.push(c), + } + } + out.push('"'); + out +} + +fn exec_line(unit: &str) -> Option { + unit.lines() + .find_map(|line| line.strip_prefix("ExecStart=")) + .map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + fn task() -> TaskDefinition { + TaskDefinition { + description: "Starts Botloft. Data folder: /home/ana/100% bots".into(), + program: PathBuf::from("/home/ana/100% bots/bin/botloftd"), + arguments: ["serve", "--home", "/home/ana/100% bots", "--scheduled"] + .map(String::from) + .to_vec(), + working_dir: PathBuf::from("/home/ana/100% bots"), + keep_alive: PathBuf::from("/home/ana/100% bots/run/keep-alive"), + triggers: Triggers { + logon: true, + watchdog: true, + }, + } + } + + #[test] + fn the_unit_runs_the_daemon_and_ends_its_processes_with_it() { + let text = unit_text(&task()); + assert!(text.contains( + r#"ExecStart="/home/ana/100%% bots/bin/botloftd" "serve" "--home" "/home/ana/100%% bots" "--scheduled""# + )); + assert!(text.contains("WorkingDirectory=/home/ana/100%% bots\n")); + assert!(text.contains("Description=Starts Botloft. Data folder: /home/ana/100%% bots\n")); + assert!(text.contains("Restart=on-failure\n")); + assert!(text.contains("KillMode=control-group\n")); + assert!(text.contains("WantedBy=default.target\n")); + assert_eq!( + exec_line(&text).as_deref(), + Some( + r#""/home/ana/100%% bots/bin/botloftd" "serve" "--home" "/home/ana/100%% bots" "--scheduled""# + ) + ); + } + + #[test] + fn quotes_and_dollars_stay_as_they_are() { + assert_eq!(quote(r#"a "b" \c $HOME"#), r#""a \"b\" \\c $$HOME""#); + } + + #[test] + fn each_data_folder_has_its_own_unit() { + assert_eq!(unit("Botloft"), "botloft.service"); + assert_eq!(unit("Botloft-1a2b3c4d"), "botloft-1a2b3c4d.service"); + } +} diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index dddad3fc..9bdbeb31 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -20,6 +20,9 @@ pub use recycle::recycle; pub use sign_in::sign_in_id; pub use task::{delete_task, find_task, register_task, run_task, stop_task}; +/// What starts the daemon for the owner here, for messages (spec 14). +pub const TASK_KIND: &str = "scheduled task"; + use std::io; use std::os::windows::ffi::OsStrExt; use std::path::Path; diff --git a/crates/botloftd/src/platform/windows/task_xml.rs b/crates/botloftd/src/platform/windows/task_xml.rs index 868acf66..ae9199b3 100644 --- a/crates/botloftd/src/platform/windows/task_xml.rs +++ b/crates/botloftd/src/platform/windows/task_xml.rs @@ -20,11 +20,34 @@ use crate::platform::{TaskDefinition, Triggers}; const LOGON_TRIGGER: &str = ""; const WATCHDOG_TRIGGER: &str = ""; +/// The arguments as one command line. Words of letters, digits and dashes +/// go as they are; anything else is quoted. +fn join_arguments(arguments: &[String]) -> String { + let plain = |arg: &String| { + !arg.is_empty() + && arg + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + }; + arguments + .iter() + .map(|arg| if plain(arg) { arg.clone() } else { quote(arg) }) + .collect::>() + .join(" ") +} + +/// Quotes one argument for the Windows command line. Backslashes before +/// the closing quote are doubled, so `C:\` does not escape it. +fn quote(arg: &str) -> String { + let trailing = arg.len() - arg.trim_end_matches('\\').len(); + format!("\"{arg}{}\"", "\\".repeat(trailing)) +} + pub(super) fn render(task: &TaskDefinition, user_sid: &str) -> String { let sid = escape(user_sid); let description = escape(&task.description); let command = escape(&task.program.to_string_lossy()); - let arguments = escape(&task.arguments); + let arguments = escape(&join_arguments(&task.arguments)); let working_dir = escape(&task.working_dir.to_string_lossy()); let triggers = triggers_xml(task.triggers, &sid); format!( @@ -163,8 +186,13 @@ mod tests { TaskDefinition { description: "Botloft & friends".into(), program: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft\bin\botloftd.exe"), - arguments: r#"serve --home "C:\Users\Ana Lima\AppData\Local\Botloft""#.into(), + arguments: vec![ + "serve".into(), + "--home".into(), + r"C:\Users\Ana Lima\AppData\Local\Botloft".into(), + ], working_dir: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft"), + keep_alive: PathBuf::from(r"C:\Users\Ana Lima\AppData\Local\Botloft\run\keep-alive"), triggers: Triggers { logon: true, watchdog: true, @@ -219,4 +247,14 @@ mod tests { ); assert_eq!(command_line(""), None); } + + #[test] + fn arguments_are_quoted_for_the_command_line() { + assert_eq!(quote(r"D:\"), r#""D:\\""#); + let args = ["serve", "--home", r"C:\data", "--scheduled"].map(String::from); + assert_eq!( + join_arguments(&args), + r#"serve --home "C:\data" --scheduled"# + ); + } } diff --git a/docs/spec.md b/docs/spec.md index 92480239..c6dc8bed 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -703,12 +703,15 @@ Em fatias (18, item 8). O que já vale fora do Windows: | Tema | Solução | |---|---| -| Processos dos bots | Cada processo de bot e de navegador lidera um **grupo de processos** próprio (`process_group(0)`), e tudo o que ele inicia entra no grupo. Parar o bot manda `SIGKILL` ao grupo inteiro (`kill(-pgid)`), como `TerminateJobObject`. Um grupo que já sumiu (`ESRCH`) é esquecido, para um número reaproveitado não atingir outro grupo. Um processo que cria sessão própria (`setsid`) sai do grupo. **Falta:** quando o daemon morre, o grupo não morre junto como com `KILL_ON_JOB_CLOSE`. O `claude` sai sozinho quando o stdin fecha (7.4), mas um navegador continua; a fatia 3 resolve isso (o `systemd --user` mata o cgroup da unit; no macOS, uma varredura ao subir) | +| Processos dos bots | Cada processo de bot e de navegador lidera um **grupo de processos** próprio (`process_group(0)`), e tudo o que ele inicia entra no grupo. Parar o bot manda `SIGKILL` ao grupo inteiro (`kill(-pgid)`), como `TerminateJobObject`. Um grupo que já sumiu (`ESRCH`) é esquecido, para um número reaproveitado não atingir outro grupo. Um processo que cria sessão própria (`setsid`) sai do grupo. Quando o daemon morre, o grupo não morre junto como com `KILL_ON_JOB_CLOSE`: o `claude` sai sozinho quando o stdin fecha (7.4), mas um navegador continua. No Linux, o `systemd --user` mata todo o cgroup da unit quando o daemon para ou morre (`KillMode=control-group`). **Falta** no macOS, onde o launchd só mata o grupo do próprio daemon: uma varredura ao subir | | Ambiente do bot | O que o **shell de login** do dono monta: `$SHELL` (senão o shell da conta, `getpwuid_r`, senão `/bin/sh`) com `-l -c`, sem terminal, imprime uma marca e `env -0`. Assim o bot acha o que o terminal do dono acha (`~/.local/bin`, Homebrew), mesmo com o daemon iniciado pelo sistema com um `PATH` mínimo. A marca separa o que o perfil imprime; `_`, `SHLVL`, `PWD` e `OLDPWD` ficam de fora. Mais de 10 s, saída sem `PATH` ou erro: vale o ambiente do daemon. A leitura vale por 20 s, menos que os 30 s em que o Botloft percebe o Claude Code instalado (15.1). As variáveis de sessão do Claude Code saem nos dois casos | | Claude Code | O `claude` no `PATH` desse ambiente, senão em `~/.local/bin` (instalador nativo), `~/.claude/local`, `/opt/homebrew/bin` e `/usr/local/bin` | | Nome do dono | O nome completo da conta (primeiro campo do GECOS), senão o login; `USER`, `LOGNAME` e o nome da pasta pessoal cobrem um uid sem entrada (contêineres) | | Navegador | 21.2 | -| Iniciar com o sistema, não suspender, Lixeira | Ainda não: procurar a tarefa responde "não instalada", e o resto avisa que só existe no Windows | +| Iniciar com o sistema (Linux) | `botloftd service install` grava a unit `~/.config/systemd/user/.service` (`botloft.service` para a pasta padrão, `botloft-<8 hex>.service` para outra, como a tarefa do Windows), com `Type=exec`, `ExecStart` com cada palavra entre aspas (`%` e `$` dobrados), `Restart=on-failure` com `RestartSec=5`, `KillMode=control-group`, `TimeoutStopSec=20` e `WantedBy=default.target`. `start_with_windows` vira `systemctl --user enable` (o gerenciador do usuário a sobe no login) ou `disable`. Depois de uma queda o systemd a traz de volta; um stop pedido (logout, `service stop`) termina com saída limpa e não volta. Por isso não há gatilho de minuto nem conferência de logon novo (`sign_in_id` é sempre vazio): sem `enable`, nada a sobe num login novo. Estado por `is-active` e `is-enabled`, cujas palavras não são traduzidas | +| Iniciar com o sistema (macOS) | Um launch agent em `~/Library/LaunchAgents/io.github.httpsphl.botloft.daemon[-<8 hex>].plist`, no domínio `gui/`. `RunAtLoad` segue `start_with_windows`. O `KeepAlive` é `PathState` com o arquivo `/run/keep-alive`: o launchd traz o daemon de volta enquanto o arquivo existir. Um `KeepAlive` simples, ou com `SuccessfulExit`, implica rodar no login. O arquivo é criado ao iniciar (`service install`, `restart` e o daemon iniciado pelo agente) e apagado no stop pedido e na saída limpa do daemon (logout incluído). Iniciar recarrega o agente do arquivo (`bootout`, `bootstrap`, com novas tentativas logo depois do `bootout`) e dá `kickstart`; parar apaga o arquivo e manda `SIGTERM` (`launchctl kill`); gravar a escolha só reescreve o arquivo, que o launchd lê no próximo login. Estado por `launchctl print` (`state = running`) | +| Os dois | `service install`, `status`, `restart`, `stop` e `uninstall` iguais aos do Windows; as mensagens dizem "systemd user service" ou "launch agent" no lugar de "scheduled task". Um teste no CI (`tests/service.rs`, só com `BOTLOFT_SERVICE_TEST=1`) instala com o systemd e o launchd de verdade, mata o daemon com `SIGKILL` e o vê voltar, para e confere que não volta, reinicia e desinstala. O runner Ubuntu só tem gerenciador de usuário com `loginctl enable-linger` | +| Não suspender, Lixeira | Ainda não: avisam que só existem no Windows | ## 15. App desktop @@ -963,7 +966,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e 5. Histórico de versões das instruções do bot. 6. Acesso remoto com token por dispositivo (Tailscale). 7. Sinais entre bots disparando rotinas. -8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1 e 2 (14.1). +8. Suporte Linux/macOS. Em fatias: (1) CI com Ubuntu e macOS para o daemon; (2) runtime do bot (grupo de processos, ambiente, `claude`, navegador); (3) iniciar com o sistema (`systemd --user`, LaunchAgent); (4) app; (5) empacotamento e release. Feitas: 1, 2 e 3 (14.1), menos a varredura de órfãos no macOS. 9. Navegador dos bots, com o dono assistindo ao vivo. Desenho na seção 21. ## 19. Pontos a verificar na versão alvo do Claude Code From 3a6a7ed150fd02f7796f6e14167fad3765f4ccf8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:05:06 -0300 Subject: [PATCH 2/5] test(service): install, crash, stop and uninstall against the real service manager Runs only with BOTLOFT_SERVICE_TEST=1, which CI sets on its Ubuntu runner (with a lingering user manager) and its macOS runner. --- .github/workflows/ci.yml | 16 ++++ crates/botloftd/tests/service.rs | 124 +++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 crates/botloftd/tests/service.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4635735..79dca68e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,22 @@ jobs: fi done - run: cargo test + # `botloftd service` against the real systemd or launchd (spec 14.1). + # The Linux runner has no user manager until the user lingers. + - name: The daemon as a service of the system + env: + BOTLOFT_SERVICE_TEST: "1" + run: | + if [ "$RUNNER_OS" = Linux ]; then + sudo loginctl enable-linger "$USER" + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus" + for i in $(seq 1 30); do + systemctl --user show-environment > /dev/null 2>&1 && break + sleep 1 + done + fi + cargo test -p botloftd --test service -- --nocapture app: name: App (Windows) diff --git a/crates/botloftd/tests/service.rs b/crates/botloftd/tests/service.rs new file mode 100644 index 00000000..45746748 --- /dev/null +++ b/crates/botloftd/tests/service.rs @@ -0,0 +1,124 @@ +//! `botloftd service` against the real systemd or launchd (spec 14.1): +//! install, come back after a crash, stay stopped after a stop, uninstall. +//! It changes the owner's service manager, so it runs only when +//! `BOTLOFT_SERVICE_TEST=1` (CI sets it on its Linux and macOS runners). +#![cfg(unix)] + +use std::net::TcpListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::Duration; + +use botloftd::autostart::health; + +const BACK_WITHIN: Duration = Duration::from_secs(60); +const STOP_WITHIN: Duration = Duration::from_secs(20); + +struct Installed { + home: PathBuf, +} + +impl Installed { + fn service(&self, command: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_botloftd")) + .arg("--home") + .arg(&self.home) + .args(["service", command]) + .output() + .expect("run botloftd") + } + + fn ok(&self, command: &str) -> String { + let output = self.service(command); + let said = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.status.success(), "service {command}: {said}"); + said + } +} + +/// Uninstalls even when the test fails, so the runner keeps no agent. +impl Drop for Installed { + fn drop(&mut self) { + let _ = self.service("uninstall"); + } +} + +fn free_port() -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + listener.local_addr().expect("address").port() +} + +fn daemon_pid(home: &Path) -> Option { + let output = Command::new("pgrep") + .args(["-f", &format!("serve --home {}", home.display())]) + .output() + .ok()?; + String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .map(str::to_owned) +} + +#[test] +fn the_service_brings_the_daemon_back_until_it_is_stopped() { + if std::env::var_os("BOTLOFT_SERVICE_TEST").is_none() { + eprintln!("BOTLOFT_SERVICE_TEST is not set; skipping"); + return; + } + let dir = tempfile::tempdir().expect("tempdir"); + let home = dir.path().join("home"); + std::fs::create_dir_all(&home).expect("home"); + let port = free_port(); + std::fs::write( + home.join("config.toml"), + format!( + "port = {port}\nworkspaces_root = '{}'\n", + dir.path().join("bots").display() + ), + ) + .expect("config"); + let installed = Installed { home: home.clone() }; + + installed.ok("install"); + assert!( + health::probe(port).is_some(), + "install waits for the daemon" + ); + let status = installed.ok("status"); + assert!(status.contains("is installed, running"), "{status}"); + + // A crash: the service manager starts it again. + let pid = daemon_pid(&home).expect("the daemon's pid"); + Command::new("kill") + .args(["-9", &pid]) + .status() + .expect("kill"); + assert!( + health::wait(port, STOP_WITHIN, |health| health.is_none()), + "the daemon died" + ); + assert!( + health::wait(port, BACK_WITHIN, |health| health.is_some()), + "the daemon came back after the crash" + ); + + // A stop asked for: it stays stopped. + installed.ok("stop"); + assert!(health::probe(port).is_none()); + std::thread::sleep(Duration::from_secs(15)); + assert!(health::probe(port).is_none(), "nothing brought it back"); + + // Starting again works from the stopped state. + installed.ok("restart"); + assert!(health::probe(port).is_some()); + + let said = installed.ok("uninstall"); + assert!(said.contains("Deleted the"), "{said}"); + assert!(health::probe(port).is_none()); + let status = installed.ok("status"); + assert!(status.contains("is not installed"), "{status}"); +} From 03a942f67e4f2e13cbfa6b2cdc219be4fb03f6d3 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:22:46 -0300 Subject: [PATCH 3/5] test(service): show the daemon's log and the service manager's view when it fails The step also stops after 10 minutes instead of hanging the runner. --- .github/workflows/ci.yml | 11 +++++- crates/botloftd/tests/service.rs | 60 ++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79dca68e..5810aea2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,6 +68,7 @@ jobs: # `botloftd service` against the real systemd or launchd (spec 14.1). # The Linux runner has no user manager until the user lingers. - name: The daemon as a service of the system + timeout-minutes: 10 env: BOTLOFT_SERVICE_TEST: "1" run: | @@ -80,7 +81,15 @@ jobs: sleep 1 done fi - cargo test -p botloftd --test service -- --nocapture + cargo test -p botloftd --test service -- --nocapture && exit 0 + # What the service manager saw, when the test fails. + if [ "$RUNNER_OS" = Linux ]; then + journalctl --user --no-pager -n 60 || true + else + launchctl list | grep -i botloft || true + log show --last 5m --predicate 'subsystem == "com.apple.xpc.launchd"' --style compact 2>/dev/null | grep -i botloft | tail -40 || true + fi + exit 1 app: name: App (Windows) diff --git a/crates/botloftd/tests/service.rs b/crates/botloftd/tests/service.rs index 45746748..68f62198 100644 --- a/crates/botloftd/tests/service.rs +++ b/crates/botloftd/tests/service.rs @@ -20,6 +20,7 @@ struct Installed { impl Installed { fn service(&self, command: &str) -> Output { + eprintln!("service {command}"); Command::new(env!("CARGO_BIN_EXE_botloftd")) .arg("--home") .arg(&self.home) @@ -35,9 +36,29 @@ impl Installed { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); - assert!(output.status.success(), "service {command}: {said}"); + self.check( + output.status.success(), + &format!("service {command}: {said}"), + ); said } + + /// Fails with the daemon's own log, which says why it did not run. + fn check(&self, holds: bool, what: &str) { + if holds { + return; + } + let mut logs = String::new(); + for entry in std::fs::read_dir(self.home.join("logs")) + .into_iter() + .flatten() + .flatten() + { + logs += &format!("--- {}\n", entry.path().display()); + logs += &std::fs::read_to_string(entry.path()).unwrap_or_default(); + } + panic!("{what}\n{logs}"); + } } /// Uninstalls even when the test fails, so the runner keeps no agent. @@ -83,42 +104,43 @@ fn the_service_brings_the_daemon_back_until_it_is_stopped() { .expect("config"); let installed = Installed { home: home.clone() }; + let up = |installed: &Installed, what: &str| { + installed.check(health::probe(port).is_some(), what); + }; installed.ok("install"); - assert!( - health::probe(port).is_some(), - "install waits for the daemon" - ); + up(&installed, "install waits for the daemon"); let status = installed.ok("status"); - assert!(status.contains("is installed, running"), "{status}"); + installed.check(status.contains("is installed, running"), &status); - // A crash: the service manager starts it again. - let pid = daemon_pid(&home).expect("the daemon's pid"); + eprintln!("a crash"); + let pid = daemon_pid(&home); + installed.check(pid.is_some(), "the daemon's pid"); Command::new("kill") - .args(["-9", &pid]) + .args(["-9", &pid.unwrap_or_default()]) .status() .expect("kill"); - assert!( + installed.check( health::wait(port, STOP_WITHIN, |health| health.is_none()), - "the daemon died" + "the daemon died", ); - assert!( + installed.check( health::wait(port, BACK_WITHIN, |health| health.is_some()), - "the daemon came back after the crash" + "the daemon came back after the crash", ); // A stop asked for: it stays stopped. installed.ok("stop"); - assert!(health::probe(port).is_none()); + installed.check(health::probe(port).is_none(), "stopped"); std::thread::sleep(Duration::from_secs(15)); - assert!(health::probe(port).is_none(), "nothing brought it back"); + installed.check(health::probe(port).is_none(), "nothing brought it back"); // Starting again works from the stopped state. installed.ok("restart"); - assert!(health::probe(port).is_some()); + up(&installed, "restarted"); let said = installed.ok("uninstall"); - assert!(said.contains("Deleted the"), "{said}"); - assert!(health::probe(port).is_none()); + installed.check(said.contains("Deleted the"), &said); + installed.check(health::probe(port).is_none(), "gone after uninstall"); let status = installed.ok("status"); - assert!(status.contains("is not installed"), "{status}"); + installed.check(status.contains("is not installed"), &status); } From 1cf4df704dfb84380f82c372616db116a6c8fa79 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:38:53 -0300 Subject: [PATCH 4/5] fix(platform): a stuck systemctl or launchctl fails after 30 s On the macOS runner a launchctl call hung the install for 10 minutes. Each call now gives up after 30 s and says which one it was. The service test keeps its data folder under the home folder: the Linux runner's /tmp is its own, and systemd answered 203/EXEC for a binary there. --- crates/botloftd/src/platform/unix.rs | 33 ++++++++++++++++++++ crates/botloftd/src/platform/unix/launchd.rs | 7 ++--- crates/botloftd/src/platform/unix/systemd.rs | 10 +++--- crates/botloftd/tests/service.rs | 4 ++- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 25691344..802e8d8d 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -13,6 +13,8 @@ mod systemd; use std::io; use std::os::unix::fs::PermissionsExt; use std::path::Path; +use std::process::{Command, Output, Stdio}; +use std::time::{Duration, Instant}; pub use self::account::owner_name; pub use self::env::user_environment; @@ -22,6 +24,37 @@ pub use self::launchd::{TASK_KIND, delete_task, find_task, register_task, run_ta #[cfg(not(target_os = "macos"))] pub use self::systemd::{TASK_KIND, delete_task, find_task, register_task, run_task, stop_task}; +/// Longest a `systemctl` or `launchctl` call may take. +const MANAGER_TIMEOUT: Duration = Duration::from_secs(30); + +/// Runs a service manager command and collects what it said, giving up +/// after `MANAGER_TIMEOUT`: a stuck `launchctl` must not hang the app. +fn manager(program: &str, args: &[&str]) -> io::Result { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let deadline = Instant::now() + MANAGER_TIMEOUT; + while child.try_wait()?.is_none() { + if Instant::now() > deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "`{program} {}` did not finish in {} s", + args.join(" "), + MANAGER_TIMEOUT.as_secs() + ), + )); + } + std::thread::sleep(Duration::from_millis(50)); + } + child.wait_with_output() +} + /// Writes `path` whole or not at all, creating its folder. fn write_atomically(path: &Path, contents: &[u8]) -> io::Result<()> { if let Some(parent) = path.parent() { diff --git a/crates/botloftd/src/platform/unix/launchd.rs b/crates/botloftd/src/platform/unix/launchd.rs index dcde593f..39762327 100644 --- a/crates/botloftd/src/platform/unix/launchd.rs +++ b/crates/botloftd/src/platform/unix/launchd.rs @@ -7,7 +7,7 @@ use std::io; use std::path::{Path, PathBuf}; -use std::process::{Command, Output, Stdio}; +use std::process::Output; use std::time::Duration; use super::write_atomically; @@ -40,10 +40,7 @@ fn service(name: &str) -> String { } fn launchctl(args: &[&str]) -> io::Result { - Command::new("launchctl") - .args(args) - .stdin(Stdio::null()) - .output() + super::manager("launchctl", args) } fn run(args: &[&str]) -> io::Result<()> { diff --git a/crates/botloftd/src/platform/unix/systemd.rs b/crates/botloftd/src/platform/unix/systemd.rs index d279b667..24797500 100644 --- a/crates/botloftd/src/platform/unix/systemd.rs +++ b/crates/botloftd/src/platform/unix/systemd.rs @@ -5,7 +5,7 @@ use std::io; use std::path::PathBuf; -use std::process::{Command, Output, Stdio}; +use std::process::Output; use super::write_atomically; use crate::platform::{TaskDefinition, TaskInfo, TaskState, Triggers}; @@ -23,11 +23,9 @@ fn unit_path(name: &str) -> io::Result { } fn systemctl(args: &[&str]) -> io::Result { - Command::new("systemctl") - .arg("--user") - .args(args) - .stdin(Stdio::null()) - .output() + let mut all = vec!["--user"]; + all.extend_from_slice(args); + super::manager("systemctl", &all) } /// Runs `systemctl --user` and fails with what it said. diff --git a/crates/botloftd/tests/service.rs b/crates/botloftd/tests/service.rs index 68f62198..acd16e18 100644 --- a/crates/botloftd/tests/service.rs +++ b/crates/botloftd/tests/service.rs @@ -90,7 +90,9 @@ fn the_service_brings_the_daemon_back_until_it_is_stopped() { eprintln!("BOTLOFT_SERVICE_TEST is not set; skipping"); return; } - let dir = tempfile::tempdir().expect("tempdir"); + // Under the home folder, like a real data folder: the CI runner keeps a + // `/tmp` of its own that the user's service manager does not see. + let dir = tempfile::tempdir_in(dirs::home_dir().expect("home folder")).expect("tempdir"); let home = dir.path().join("home"); std::fs::create_dir_all(&home).expect("home"); let port = free_port(); From 451bcbf7c8bfc26f73e7b9fee1df23530e37e20a Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:45:59 -0300 Subject: [PATCH 5/5] fix(service): the daemon's copy in the data folder can run off Windows It was written with fs::write, which leaves no execute bit: systemd answered 203/EXEC and launchd never started it. --- crates/botloftd/src/autostart/binary.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/botloftd/src/autostart/binary.rs b/crates/botloftd/src/autostart/binary.rs index bb3e899c..de2e53e8 100644 --- a/crates/botloftd/src/autostart/binary.rs +++ b/crates/botloftd/src/autostart/binary.rs @@ -44,6 +44,12 @@ pub fn install(source: &Path, bin_dir: &Path) -> io::Result { } let staged = bin_dir.join(STAGED); fs::write(&staged, &bytes)?; + // A written file is not a program off Windows until it may run. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?; + } if target.exists() { fs::rename(&target, aside(bin_dir))?; } @@ -126,6 +132,15 @@ mod tests { let first = install(&source, &bin).expect("install"); assert!(first.changed); assert_eq!(fs::read(&first.path).expect("read"), b"v1"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::metadata(&first.path) + .expect("metadata") + .permissions() + .mode(); + assert_eq!(mode & 0o111, 0o111, "the copy can run"); + } assert!(!install(&source, &bin).expect("again").changed); assert!( !install(&first.path, &bin).expect("itself").changed,