#[path = "daemon_run.rs"]
mod daemon_run;
pub(crate) use daemon_run::run_daemon;
use serde::Deserialize;
use crate::formatters::session::short_id;
use crate::types::SessionRow;
pub(crate) fn git_root_for(workdir: &str) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(workdir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()
.filter(|o| o.status.success());
match out {
Some(o) => String::from_utf8_lossy(&o.stdout).trim().to_string(),
None => workdir.to_string(),
}
}
pub(crate) fn group_by_git_root<'a>(
sessions: &'a [SessionRow],
cwd_root: &str,
) -> Vec<(String, Vec<&'a SessionRow>)> {
use std::collections::{BTreeMap, HashMap};
let mut cache: HashMap<&str, String> = HashMap::new();
let mut map: BTreeMap<String, Vec<&SessionRow>> = BTreeMap::new();
for s in sessions {
let key = cache
.entry(s.workdir.as_str())
.or_insert_with(|| git_root_for(&s.workdir))
.clone();
map.entry(key).or_default().push(s);
}
let mut groups: Vec<(String, Vec<&SessionRow>)> = map.into_iter().collect();
if let Some(pos) = groups.iter().position(|(k, _)| k == cwd_root) {
let cwd_group = groups.remove(pos);
groups.insert(0, cwd_group);
}
groups
}
pub(crate) async fn print_status(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
println!("daemon: ok");
print_sessions(&trusty_mpm::client::DaemonClient::with_client(
client.clone(),
url,
))
.await
}
pub(crate) async fn print_sessions(
daemon: &trusty_mpm::client::DaemonClient,
) -> anyhow::Result<()> {
#[derive(Deserialize)]
struct Body {
sessions: Vec<SessionRow>,
}
let body: Body = daemon
.get("/sessions")
.send()
.await?
.error_for_status()?
.json()
.await?;
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let cwd_root = git_root_for(&cwd);
let groups = group_by_git_root(&body.sessions, &cwd_root);
for (root, sessions) in &groups {
let marker = if root == &cwd_root { " *" } else { "" };
println!("---- {} ({} session(s)){marker} ----", root, sessions.len());
for s in sessions {
let status = s.status.as_str().unwrap_or("unknown");
println!(
" {} {} {} ({} delegations)",
short_id(&s.id),
status,
s.workdir,
s.active_delegations
);
}
}
if trusty_mpm::telegram::resolve_token("TELEGRAM_BOT_TOKEN").is_some() {
println!("Telegram bot active");
}
Ok(())
}
pub(crate) async fn daemon_healthy(client: &reqwest::Client, url: &str) -> bool {
let health_ok = match client.get(format!("{url}/health")).send().await {
Ok(r) => r.status().is_success(),
Err(_) => return false,
};
if !health_ok {
return false;
}
match client.get(format!("{url}/sessions")).send().await {
Ok(r) => r.status().is_success(),
Err(_) => false,
}
}
fn already_running_line(_check_url: &str) -> String {
daemon_run::ALREADY_RUNNING_NOTICE.to_string()
}
fn start_check_url(lock: Result<String, trusty_mpm::core::DaemonUrlError>, url: &str) -> String {
use trusty_mpm::core::DEFAULT_DAEMON_URL;
match lock {
Ok(lock_url) if lock_url != DEFAULT_DAEMON_URL || url == DEFAULT_DAEMON_URL => lock_url,
_ => url.to_string(),
}
}
pub(crate) async fn start(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
let check_url = start_check_url(trusty_mpm::core::try_resolve_daemon_url(None), url);
if daemon_healthy(client, &check_url).await {
tracing::debug!("daemon already running at {check_url}");
println!("{}", already_running_line(&check_url));
return print_status(client, &check_url).await;
}
if let Some(msg) = crate::commands::launchd_probe::cli_spawn_refusal() {
anyhow::bail!(msg);
}
trusty_mpm::core::refuse_daemon_spawn_when_isolated()?;
let root = trusty_mpm::core::paths::FrameworkPaths::default().root;
std::fs::create_dir_all(&root)?;
let log_path = root.join("daemon.log");
let stdout = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)?;
let stderr = stdout.try_clone()?;
let lock_path = trusty_mpm::core::lock_file_path();
let _ = std::fs::remove_file(&lock_path);
let exe = std::env::current_exe()?;
let stable_dir = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/"));
let mut cmd = std::process::Command::new(&exe);
cmd.arg("daemon")
.current_dir(&stable_dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(stdout))
.stderr(std::process::Stdio::from(stderr));
trusty_common::daemon_guard::start_in_new_session(&mut cmd).spawn()?;
print!("Starting daemon... ");
use std::io::Write as _;
std::io::stdout().flush().ok();
let mut healthy = false;
let mut actual_url = url.to_string();
for _ in 0..10 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
actual_url = trusty_mpm::core::resolve_daemon_url(None);
if daemon_healthy(client, &actual_url).await {
healthy = true;
break;
}
}
if healthy {
println!("done");
tracing::debug!("daemon listening on {actual_url}");
} else {
println!("failed");
println!(
"daemon did not become healthy within 5s; see {}",
log_path.display()
);
return Ok(());
}
print_status(client, &actual_url).await
}
pub(crate) async fn restart(client: &reqwest::Client, url: &str) -> anyhow::Result<()> {
if let Some(msg) = crate::commands::launchd_probe::cli_spawn_refusal() {
anyhow::bail!(msg);
}
trusty_mpm::core::refuse_daemon_spawn_when_isolated()?;
if daemon_healthy(client, url).await {
print!("Stopping daemon... ");
use std::io::Write as _;
std::io::stdout().flush().ok();
std::process::Command::new("pkill")
.args(["-f", "tm daemon"])
.status()
.ok();
std::process::Command::new("pkill")
.args(["-f", "trusty-mpm daemon"])
.status()
.ok();
for _ in 0..6 {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if !daemon_healthy(client, url).await {
break;
}
}
println!("done");
}
start(client, url).await
}
pub(crate) async fn stop_daemon() -> anyhow::Result<()> {
use std::time::{Duration, Instant};
let targets = find_daemon_pids();
if targets.is_empty() {
anyhow::bail!("No daemon running");
}
println!(
"Stopping trusty-mpm daemon ({} process(es): {:?})…",
targets.len(),
targets
);
match crate::commands::launchd_probe::daemon_launchd_label() {
crate::commands::launchd_probe::DaemonLabelProbe::Registered(label) => println!(
"note: launchd unit `{label}` owns this daemon and will NOT respawn it \
(KeepAlive.SuccessfulExit=false). Bring it back with `{}` — issue #4230.",
crate::commands::launchd_probe::daemon_restart_command_for(Some(&label))
),
crate::commands::launchd_probe::DaemonLabelProbe::Undeterminable => println!(
"note: `~/Library/LaunchAgents` could not be read, so whether a launchd unit \
owns this daemon is unknown. If one does, it will NOT respawn it \
(KeepAlive.SuccessfulExit=false) — issue #4230."
),
crate::commands::launchd_probe::DaemonLabelProbe::NotRegistered => {}
}
for pid in &targets {
let _ = send_signal(*pid, "TERM");
}
let deadline = Instant::now() + Duration::from_secs(5);
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
let any_alive = targets.iter().any(|p| pid_alive(*p));
if !any_alive {
println!("Daemon stopped");
cleanup_lock_file(&targets);
return Ok(());
}
if Instant::now() >= deadline {
break;
}
}
let stragglers: Vec<u32> = targets.iter().copied().filter(|p| pid_alive(*p)).collect();
if !stragglers.is_empty() {
println!(
"{} process(es) ignored SIGTERM — sending SIGKILL: {:?}",
stragglers.len(),
stragglers
);
for pid in &stragglers {
let _ = send_signal(*pid, "KILL");
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
if targets.iter().any(|p| pid_alive(*p)) {
println!("Daemon may still be shutting down");
} else {
println!("Daemon stopped");
cleanup_lock_file(&targets);
}
Ok(())
}
pub(crate) fn cleanup_lock_file(stopped: &[u32]) {
trusty_mpm::core::daemon_identity::remove_lock_owned_by(stopped);
let root = trusty_mpm::core::paths::FrameworkPaths::default().root;
super::guided_autostart::remove_autostart_pidfile(&root);
}
pub(crate) fn find_daemon_pids() -> Vec<u32> {
use super::daemon_pid_identity::{PidIdentity, classify_process, name_and_cmd};
let sys = super::daemon_pid_identity::refresh_with_cmd(sysinfo::ProcessesToUpdate::All);
let me = std::process::id();
sys.processes()
.iter()
.filter(|(pid, _)| pid.as_u32() != me)
.filter(|(_, p)| {
let (name, cmd) = name_and_cmd(p);
classify_process(&name, &cmd) == PidIdentity::Daemon
})
.map(|(pid, _)| pid.as_u32())
.collect()
}
#[cfg(unix)]
pub(crate) fn send_signal(pid: u32, sig: &str) -> std::io::Result<()> {
let status = std::process::Command::new("kill")
.arg(format!("-{sig}"))
.arg(pid.to_string())
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"kill -{sig} {pid} exited {status}"
)));
}
Ok(())
}
#[cfg(not(unix))]
pub(crate) fn send_signal(_pid: u32, _sig: &str) -> std::io::Result<()> {
Err(std::io::Error::other(
"signals unsupported on this platform",
))
}
pub(crate) fn pid_alive(pid: u32) -> bool {
trusty_mpm::core::daemon_identity::pid_alive(pid)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn start_checks_the_given_url_when_the_resolver_refuses() {
use trusty_mpm::core::{DEFAULT_DAEMON_URL, DaemonUrlError};
let sandbox = "http://127.0.0.1:17881";
assert_eq!(
start_check_url(Err(DaemonUrlError::NoSandboxDaemon), sandbox),
sandbox
);
assert_eq!(
start_check_url(Ok(sandbox.to_string()), DEFAULT_DAEMON_URL),
sandbox
);
assert_eq!(
start_check_url(Ok(DEFAULT_DAEMON_URL.to_string()), sandbox),
sandbox
);
}
#[test]
fn start_already_running_line_names_no_address() {
for url in [
trusty_mpm::core::DEFAULT_DAEMON_URL,
"http://127.0.0.1:9911",
] {
let line = already_running_line(url);
for needle in ["7880", "9911", "127.0.0.1", "http://", ":"] {
assert!(
!line.contains(needle),
"`tm start` must not print {needle:?} for {url}: {line}"
);
}
assert!(
line.contains("trusty-mpm daemon is already running"),
"the operator must still be told what happened: {line}"
);
assert_eq!(
line,
daemon_run::ALREADY_RUNNING_NOTICE,
"`tm start` and `tm daemon` must render the one shared constant"
);
}
}
fn make_session(workdir: &str) -> SessionRow {
SessionRow {
id: serde_json::json!({"0": "00000000-0000-0000-0000-000000000000"}),
workdir: workdir.to_string(),
status: serde_json::json!("active"),
active_delegations: 0,
}
}
#[test]
fn group_sessions_by_git_root_no_git() {
let sessions = vec![make_session("/tmp/no-git-dir")];
let groups = group_by_git_root(&sessions, "/tmp/no-git-dir");
assert_eq!(groups.len(), 1, "one group for the single session");
assert_eq!(groups[0].0, "/tmp/no-git-dir");
assert_eq!(groups[0].1.len(), 1);
}
#[test]
fn group_sessions_by_git_root_cwd_first() {
let sessions = vec![
make_session("/tmp/alpha-dir"),
make_session("/tmp/beta-dir"),
];
let groups = group_by_git_root(&sessions, "/tmp/beta-dir");
assert_eq!(groups[0].0, "/tmp/beta-dir", "cwd_root group must be first");
}
#[test]
fn group_sessions_same_root_together() {
let sessions = vec![make_session("/tmp/same-dir"), make_session("/tmp/same-dir")];
let groups = group_by_git_root(&sessions, "/tmp/same-dir");
assert_eq!(groups.len(), 1, "same root → one group");
assert_eq!(groups[0].1.len(), 2, "two sessions in the group");
}
#[test]
fn git_root_for_nonexistent_path_returns_workdir() {
let path = "/tmp/definitely-not-a-git-repo-xyzzy-12345";
let result = git_root_for(path);
assert_eq!(result, path, "non-git path must fall back to workdir");
}
}