schwab-api-cli 0.1.5

schwab-api-cli — agent-first CLI for Charles Schwab Trader API (experimental — use at your own risk)
Documentation
use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};

use anyhow::{Context, Result};

use super::paths::{log_path, pid_path};

pub fn spawn_background(rules_path: &Path, extra_args: &[String]) -> Result<u32> {
    let pid_file = pid_path(rules_path);
    if let Ok(existing) = read_pid(&pid_file) {
        if process_alive(existing) {
            anyhow::bail!(
                "agent already running with pid {existing} (pid file: {})",
                pid_file.display()
            );
        }
    }

    let exe = std::env::current_exe().context("current exe")?;
    let log = log_path(rules_path);
    let log_file = fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(&log)
        .with_context(|| format!("open log {}", log.display()))?;

    let err_file = log_file
        .try_clone()
        .with_context(|| format!("clone log handle {}", log.display()))?;

    let mut cmd = Command::new(&exe);
    cmd.arg("agent")
        .arg("run")
        .arg(rules_path)
        .args(extra_args)
        .stdout(Stdio::from(log_file))
        .stderr(Stdio::from(err_file));

    #[cfg(unix)]
    {
        use std::os::unix::process::CommandExt;
        unsafe {
            cmd.pre_exec(|| {
                libc::setsid();
                Ok(())
            });
        }
    }

    let child = cmd.spawn().context("spawn background agent")?;
    let pid = child.id();
    fs::write(&pid_file, pid.to_string())?;
    Ok(pid)
}

pub fn stop_daemon(rules_path: &Path) -> Result<()> {
    let pid_file = pid_path(rules_path);
    let pid = read_pid(&pid_file).with_context(|| {
        format!(
            "no running agent (missing pid file at {})",
            pid_file.display()
        )
    })?;

    if !process_alive(pid) {
        fs::remove_file(&pid_file).ok();
        anyhow::bail!("agent pid {pid} is not running; removed stale pid file");
    }

    #[cfg(unix)]
    {
        let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
        if rc != 0 {
            anyhow::bail!("failed to send SIGTERM to pid {pid}");
        }
    }

    #[cfg(not(unix))]
    {
        anyhow::bail!("stop is only supported on Unix");
    }

    for _ in 0..20 {
        if !process_alive(pid) {
            fs::remove_file(&pid_file).ok();
            return Ok(());
        }
        std::thread::sleep(std::time::Duration::from_millis(250));
    }

    #[cfg(unix)]
    {
        unsafe {
            libc::kill(pid as i32, libc::SIGKILL);
        }
        fs::remove_file(&pid_file).ok();
    }

    Ok(())
}

fn read_pid(path: &Path) -> Result<u32> {
    let content = fs::read_to_string(path)?;
    content
        .trim()
        .parse::<u32>()
        .with_context(|| format!("invalid pid in {}", path.display()))
}

fn process_alive(pid: u32) -> bool {
    #[cfg(unix)]
    {
        unsafe { libc::kill(pid as i32, 0) == 0 }
    }
    #[cfg(not(unix))]
    {
        let _ = pid;
        false
    }
}

/// Background agent process metadata for dashboard / status UIs.
#[derive(Debug, Clone)]
pub struct DaemonStatus {
    pub running: bool,
    pub pid: Option<u32>,
    pub pid_file: std::path::PathBuf,
    pub log_file: std::path::PathBuf,
}

pub fn daemon_status(rules_path: &Path) -> DaemonStatus {
    let pid_file = pid_path(rules_path);
    let log_file = log_path(rules_path);
    match read_pid(&pid_file) {
        Ok(pid) if process_alive(pid) => DaemonStatus {
            running: true,
            pid: Some(pid),
            pid_file,
            log_file,
        },
        Ok(pid) => DaemonStatus {
            running: false,
            pid: Some(pid),
            pid_file,
            log_file,
        },
        Err(_) => DaemonStatus {
            running: false,
            pid: None,
            pid_file,
            log_file,
        },
    }
}