unifier-cli 0.4.0

Filesystem postbox for inter-process communication via a Unix tree
Documentation
//! Hot in-memory daemon lifecycle and client dispatch.

#[cfg(unix)]
mod client;
#[cfg(unix)]
mod idle;
#[cfg(unix)]
mod notify;
#[cfg(unix)]
mod paths;
#[cfg(unix)]
mod protocol;
#[cfg(unix)]
mod server;
#[cfg(unix)]
pub mod www;

#[cfg(unix)]
pub use client::{
    flush as client_flush, ping, read_pid, response_found, response_messages, response_ok,
    response_uuid, response_value, shutdown, Client,
};
#[cfg(unix)]
pub use notify::{subscribe, watch, EventHub, Notice};
#[cfg(unix)]
pub use paths::{daemon_dir, events_socket_path, http_port_path, pid_path, socket_path, www_dir};
#[cfg(unix)]
pub use protocol::{ok_empty, Request, Response};
#[cfg(unix)]
pub use server::run as run_server;

use std::path::Path;
use std::process::{Command, Stdio};

use crate::error::{Error, Result};
use crate::home::UnifierHome;

/// Start the daemon if it is not already running (Gradle-style invocation).
pub fn ensure_running(home: &UnifierHome) -> Result<()> {
    if is_running(home) {
        return Ok(());
    }
    start(home, false)
}

/// Whether a hot daemon is running for this store root.
pub fn is_running(home: &UnifierHome) -> bool {
    #[cfg(unix)]
    {
        Client::is_running(home)
    }
    #[cfg(not(unix))]
    {
        let _ = home;
        false
    }
}

/// Start the hot daemon. With `foreground`, blocks in the current process (for tests).
pub fn start(home: &UnifierHome, foreground: bool) -> Result<()> {
    #[cfg(not(unix))]
    {
        let _ = (home, foreground);
        return Err(Error::msg("hot daemon requires a Unix platform"));
    }

    #[cfg(unix)]
    {
        if Client::is_running(home) {
            return Err(Error::msg("daemon is already running"));
        }

        home.ensure()?;
        std::fs::create_dir_all(crate::daemon::paths::daemon_dir(home))?;

        if foreground {
            return run_server(home.clone());
        }

        let exe = std::env::current_exe()?;
        let mut cmd = Command::new(exe);
        cmd.arg("daemon").arg("run");
        if let Some(p) = home.global_path().to_str() {
            cmd.args(["--home", p]);
        }
        if let Some(name) = home.chroot_name() {
            cmd.args(["--chroot", name]);
        }
        cmd.stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(Stdio::null());

        let child = cmd.spawn()?;
        wait_for_socket(home, child.id())?;
        Ok(())
    }
}

/// Stop the daemon, flushing dirty state first.
pub fn stop(home: &UnifierHome) -> Result<()> {
    #[cfg(not(unix))]
    {
        let _ = home;
        return Err(Error::msg("hot daemon requires a Unix platform"));
    }

    #[cfg(unix)]
    {
        shutdown(home)
    }
}

/// Print daemon status to stdout.
pub fn status(home: &UnifierHome) -> Result<()> {
    #[cfg(not(unix))]
    {
        let _ = home;
        println!("daemon: unavailable (requires Unix)");
        return Ok(());
    }

    #[cfg(unix)]
    {
        if Client::is_running(home) {
            let pid = read_pid(home)?.unwrap_or(0);
            println!("daemon: running (pid {pid})");
            println!("socket: {}", socket_path(home).display());
            println!("events: {}", events_socket_path(home).display());
            if let Some(url) = crate::daemon::www::base_url(home) {
                println!("www: {url}");
            }
        } else {
            println!("daemon: stopped");
        }
        Ok(())
    }
}

/// Flush dirty in-memory state to disk via the running daemon.
pub fn flush(home: &UnifierHome) -> Result<()> {
    #[cfg(not(unix))]
    {
        let _ = home;
        return Err(Error::msg("hot daemon requires a Unix platform"));
    }

    #[cfg(unix)]
    {
        let dirty = client_flush(home)?;
        if dirty {
            println!("flushed dirty state to disk");
        } else {
            println!("nothing to flush");
        }
        Ok(())
    }
}

/// Kill orphan `unifier daemon run` processes whose `--home` directory is gone.
pub fn gc(dry_run: bool) -> Result<()> {
    #[cfg(not(unix))]
    {
        let _ = dry_run;
        return Err(Error::msg("hot daemon requires a Unix platform"));
    }

    #[cfg(unix)]
    {
        let self_pid = std::process::id();
        let mut killed = 0usize;
        let mut skipped = 0usize;
        let proc = std::fs::read_dir("/proc").map_err(|e| Error::msg(e.to_string()))?;
        for entry in proc.flatten() {
            let name = entry.file_name();
            let name = name.to_string_lossy();
            if !name.chars().all(|c| c.is_ascii_digit()) {
                continue;
            }
            let pid: u32 = match name.parse() {
                Ok(p) if p != self_pid => p,
                _ => continue,
            };
            let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
            if cmdline.is_empty() {
                continue;
            }
            let args: Vec<&str> = cmdline
                .split(|&b| b == 0)
                .filter(|a| !a.is_empty())
                .filter_map(|a| std::str::from_utf8(a).ok())
                .collect();
            if !is_unifier_daemon_run(&args) {
                continue;
            }
            let Some(home) = home_from_args(&args) else {
                skipped += 1;
                continue;
            };
            if Path::new(home).is_dir() {
                skipped += 1;
                continue;
            }
            if dry_run {
                println!("would kill pid={pid} home={home} (missing)");
                killed += 1;
                continue;
            }
            match send_sigterm(pid) {
                Ok(()) => {
                    println!("killed pid={pid} home={home} (missing)");
                    killed += 1;
                }
                Err(e) => eprintln!("failed to kill pid={pid}: {e}"),
            }
        }
        if dry_run {
            println!("daemon gc dry-run: {killed} orphan(s), {skipped} kept");
        } else {
            println!("daemon gc: killed {killed} orphan(s), kept {skipped}");
        }
        Ok(())
    }
}

#[cfg(unix)]
fn is_unifier_daemon_run(args: &[&str]) -> bool {
    let has_unifier = args.iter().any(|a| a.ends_with("unifier") || *a == "unifier");
    let mut saw_daemon = false;
    let mut saw_run = false;
    for a in args {
        if *a == "daemon" {
            saw_daemon = true;
        } else if saw_daemon && *a == "run" {
            saw_run = true;
        }
    }
    has_unifier && saw_daemon && saw_run
}

#[cfg(unix)]
fn home_from_args<'a>(args: &[&'a str]) -> Option<&'a str> {
    let mut i = 0usize;
    while i < args.len() {
        if args[i] == "--home" {
            return args.get(i + 1).copied();
        }
        if let Some(rest) = args[i].strip_prefix("--home=") {
            return Some(rest);
        }
        i += 1;
    }
    None
}

#[cfg(unix)]
fn send_sigterm(pid: u32) -> Result<()> {
    let status = Command::new("kill")
        .args(["-TERM", &pid.to_string()])
        .status()
        .map_err(|e| Error::msg(format!("spawn kill: {e}")))?;
    if status.success() {
        Ok(())
    } else {
        Err(Error::msg(format!("kill -TERM {pid} failed ({status})")))
    }
}

#[cfg(unix)]
fn wait_for_socket(home: &UnifierHome, _pid: u32) -> Result<()> {
    let path = socket_path(home);
    for _ in 0..100 {
        if path.exists() && Client::is_running(home) {
            return ping(home);
        }
        std::thread::sleep(std::time::Duration::from_millis(50));
    }
    Err(Error::msg("daemon failed to start"))
}