use crate::config::ConfigStorage;
use anyhow::Result;
#[cfg(unix)]
use crate::daemon::lifecycle::LifecycleConfig;
#[cfg(unix)]
use crate::daemon::pidfile::{Pidfile, process_alive, process_name};
#[cfg(unix)]
use crate::daemon::state::DaemonState;
#[cfg(unix)]
use anyhow::Context;
pub enum DaemonAction {
Start {
foreground: bool,
log_level: Option<String>,
verbose: u8,
},
Stop,
Status {
json: bool,
},
Restart {
foreground: bool,
log_level: Option<String>,
verbose: u8,
},
}
pub fn handle_daemon_command(action: DaemonAction, storage: &ConfigStorage) -> Result<()> {
#[cfg(not(unix))]
{
let _ = (action, storage);
anyhow::bail!("cc daemon is Unix-only in v1 — run `ccs-proxy serve` directly");
}
#[cfg(unix)]
match action {
DaemonAction::Start {
foreground,
log_level,
verbose,
} => handle_start(foreground, log_level, verbose, storage),
DaemonAction::Stop => handle_stop(),
DaemonAction::Status { json } => handle_status(json, storage),
DaemonAction::Restart {
foreground,
log_level,
verbose,
} => {
let _ = handle_stop();
handle_start(foreground, log_level, verbose, storage)
}
}
}
#[cfg(unix)]
fn handle_start(
foreground: bool,
log_level: Option<String>,
verbose: u8,
storage: &ConfigStorage,
) -> Result<()> {
let cfg = LifecycleConfig::from_storage(storage, foreground)?;
let pidfile = Pidfile::new(cfg.pidfile_path.clone());
if let Some(pid) = pidfile.read()? {
if process_alive(pid)? {
let is_ours = match process_name(pid) {
Some(name) => name.contains("cc-switch") || name.contains("cc_switch"),
None => false,
};
if is_ours {
anyhow::bail!(
"daemon already running (PID {pid}). Use `cc-switch daemon stop` first."
);
}
eprintln!(
"warning: pidfile references PID {pid} which is alive but not cc-switch — removing stale pidfile"
);
pidfile.release()?;
} else {
eprintln!("warning: stale pidfile for dead PID {pid} — removing");
pidfile.release()?;
}
}
if !foreground {
let home = dirs::home_dir().context("could not find home directory")?;
let log_path = home.join(".cc-switch").join("daemon.log");
let is_daemon = crate::daemon::fork::double_fork_into_background(&log_path)?;
if !is_daemon {
wait_and_print_status(&cfg.state_path);
std::process::exit(0);
}
}
crate::daemon::lifecycle::run_daemon_blocking(cfg, log_level, verbose)
}
#[cfg(unix)]
fn wait_and_print_status(state_path: &std::path::Path) {
for _ in 0..30 {
std::thread::sleep(std::time::Duration::from_millis(100));
if let Ok(contents) = std::fs::read_to_string(state_path)
&& let Ok(state) = serde_json::from_str::<DaemonState>(&contents)
{
eprintln!("daemon started (PID {})", state.pid);
if let Some(port) = state.agg_port {
eprintln!("aggregate dashboard: http://localhost:{port}");
}
for proxy in &state.proxies {
eprintln!(" proxy :{} → {}", proxy.proxy_port, proxy.upstream);
}
return;
}
}
eprintln!("daemon starting in background (state file not yet available)");
}
#[cfg(unix)]
fn handle_stop() -> Result<()> {
let home = dirs::home_dir().context("could not find home directory")?;
let pidfile_path = home.join(".cc-switch").join("daemon.pid");
let pidfile = Pidfile::new(pidfile_path);
let pid = match pidfile.read()? {
Some(pid) => pid,
None => {
eprintln!("daemon not running (no pidfile)");
return Ok(());
}
};
if !process_alive(pid)? {
eprintln!("daemon not running (stale pidfile for PID {pid}) — cleaning up");
pidfile.release()?;
return Ok(());
}
let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
if ret != 0 {
let err = std::io::Error::last_os_error();
if err.raw_os_error() == Some(libc::ESRCH) {
eprintln!("daemon not running (PID {pid} gone) — cleaning up");
pidfile.release()?;
return Ok(());
}
return Err(err).with_context(|| format!("failed to send SIGTERM to PID {pid}"));
}
for _ in 0..50 {
std::thread::sleep(std::time::Duration::from_millis(100));
if !process_alive(pid)? {
eprintln!("daemon stopped (PID {pid})");
return Ok(());
}
}
eprintln!("warning: daemon PID {pid} did not exit after 5s — sending SIGKILL");
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
std::thread::sleep(std::time::Duration::from_millis(200));
pidfile.release()?;
eprintln!("daemon killed");
Ok(())
}
#[cfg(unix)]
fn handle_status(json: bool, storage: &ConfigStorage) -> Result<()> {
let home = dirs::home_dir().context("could not find home directory")?;
let cc_switch_dir = home.join(".cc-switch");
let pidfile_path = cc_switch_dir.join("daemon.pid");
let state_path = cc_switch_dir.join("daemon-state.json");
let pidfile = Pidfile::new(pidfile_path);
let pid = match pidfile.read()? {
Some(pid) => pid,
None => {
if json {
println!("{{\"status\":\"stopped\"}}");
} else {
println!("ccs-daemon: STOPPED (no pidfile)");
}
return Ok(());
}
};
if !process_alive(pid)? {
if json {
println!("{{\"status\":\"stopped\",\"stale_pid\":{pid}}}");
} else {
println!("ccs-daemon: STOPPED (stale pidfile, PID {pid} is dead)");
}
return Ok(());
}
let state = match DaemonState::load(&state_path)? {
Some(s) => s,
None => {
if json {
println!("{{\"status\":\"running\",\"pid\":{pid},\"proxies\":[]}}");
} else {
println!("ccs-daemon: RUNNING (pid {pid}) — no state file");
}
return Ok(());
}
};
let aliases_by_upstream = crate::daemon::status::build_aliases_by_upstream(storage);
let statuses = crate::daemon::status::collect_status(&state);
if json {
let output = crate::daemon::status::format_status_json(&state, &statuses);
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
let text =
crate::daemon::status::format_status_text(&state, &statuses, &aliases_by_upstream);
print!("{text}");
crate::daemon::print_version_mismatch_warning();
}
Ok(())
}