Skip to main content

cc_switch/daemon/
commands.rs

1use crate::config::ConfigStorage;
2use anyhow::Result;
3
4#[cfg(unix)]
5use crate::daemon::lifecycle::LifecycleConfig;
6#[cfg(unix)]
7use crate::daemon::pidfile::{Pidfile, process_alive, process_name};
8#[cfg(unix)]
9use crate::daemon::state::DaemonState;
10#[cfg(unix)]
11use anyhow::Context;
12
13pub enum DaemonAction {
14    Start {
15        foreground: bool,
16        log_level: Option<String>,
17        verbose: u8,
18    },
19    Stop,
20    Status {
21        json: bool,
22    },
23    Restart {
24        foreground: bool,
25        log_level: Option<String>,
26        verbose: u8,
27    },
28}
29
30pub fn handle_daemon_command(action: DaemonAction, storage: &ConfigStorage) -> Result<()> {
31    #[cfg(not(unix))]
32    {
33        let _ = (action, storage);
34        anyhow::bail!("cc daemon is Unix-only in v1 — run `ccs-proxy serve` directly");
35    }
36
37    #[cfg(unix)]
38    match action {
39        DaemonAction::Start {
40            foreground,
41            log_level,
42            verbose,
43        } => handle_start(foreground, log_level, verbose, storage),
44        DaemonAction::Stop => handle_stop(),
45        DaemonAction::Status { json } => handle_status(json, storage),
46        DaemonAction::Restart {
47            foreground,
48            log_level,
49            verbose,
50        } => {
51            let _ = handle_stop();
52            handle_start(foreground, log_level, verbose, storage)
53        }
54    }
55}
56
57#[cfg(unix)]
58fn handle_start(
59    foreground: bool,
60    log_level: Option<String>,
61    verbose: u8,
62    storage: &ConfigStorage,
63) -> Result<()> {
64    let cfg = LifecycleConfig::from_storage(storage, foreground)?;
65
66    // Preflight: check for existing pidfile (spec §8 invariant table).
67    let pidfile = Pidfile::new(cfg.pidfile_path.clone());
68    if let Some(pid) = pidfile.read()? {
69        if process_alive(pid)? {
70            // PID is alive — check if it's our daemon or a recycled PID.
71            let is_ours = match process_name(pid) {
72                Some(name) => name.contains("cc-switch") || name.contains("cc_switch"),
73                // Can't determine (e.g. permission denied) — treat as stale to
74                // avoid blocking the user on PID reuse after reinstalls.
75                None => false,
76            };
77            if is_ours {
78                anyhow::bail!(
79                    "daemon already running (PID {pid}). Use `cc-switch daemon stop` first."
80                );
81            }
82            // PID alive but belongs to a different process.
83            eprintln!(
84                "warning: pidfile references PID {pid} which is alive but not cc-switch — removing stale pidfile"
85            );
86            pidfile.release()?;
87        } else {
88            eprintln!("warning: stale pidfile for dead PID {pid} — removing");
89            pidfile.release()?;
90        }
91    }
92
93    if !foreground {
94        let home = dirs::home_dir().context("could not find home directory")?;
95        let log_path = home.join(".cc-switch").join("daemon.log");
96        let is_daemon = crate::daemon::fork::double_fork_into_background(&log_path)?;
97        if !is_daemon {
98            // Parent: wait for the daemon to write its state file, then print info.
99            wait_and_print_status(&cfg.state_path);
100            std::process::exit(0);
101        }
102    }
103
104    crate::daemon::lifecycle::run_daemon_blocking(cfg, log_level, verbose)
105}
106
107#[cfg(unix)]
108fn wait_and_print_status(state_path: &std::path::Path) {
109    for _ in 0..30 {
110        std::thread::sleep(std::time::Duration::from_millis(100));
111        if let Ok(contents) = std::fs::read_to_string(state_path)
112            && let Ok(state) = serde_json::from_str::<DaemonState>(&contents)
113        {
114            eprintln!("daemon started (PID {})", state.pid);
115            if let Some(port) = state.agg_port {
116                eprintln!("aggregate dashboard: http://localhost:{port}");
117            }
118            for proxy in &state.proxies {
119                eprintln!("  proxy :{} → {}", proxy.proxy_port, proxy.upstream);
120            }
121            return;
122        }
123    }
124    eprintln!("daemon starting in background (state file not yet available)");
125}
126
127#[cfg(unix)]
128fn handle_stop() -> Result<()> {
129    let home = dirs::home_dir().context("could not find home directory")?;
130    let pidfile_path = home.join(".cc-switch").join("daemon.pid");
131    let pidfile = Pidfile::new(pidfile_path);
132
133    let pid = match pidfile.read()? {
134        Some(pid) => pid,
135        None => {
136            eprintln!("daemon not running (no pidfile)");
137            return Ok(());
138        }
139    };
140
141    if !process_alive(pid)? {
142        eprintln!("daemon not running (stale pidfile for PID {pid}) — cleaning up");
143        pidfile.release()?;
144        return Ok(());
145    }
146
147    // Send SIGTERM.
148    let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
149    if ret != 0 {
150        let err = std::io::Error::last_os_error();
151        if err.raw_os_error() == Some(libc::ESRCH) {
152            eprintln!("daemon not running (PID {pid} gone) — cleaning up");
153            pidfile.release()?;
154            return Ok(());
155        }
156        return Err(err).with_context(|| format!("failed to send SIGTERM to PID {pid}"));
157    }
158
159    // Poll for exit (up to 5 seconds).
160    for _ in 0..50 {
161        std::thread::sleep(std::time::Duration::from_millis(100));
162        if !process_alive(pid)? {
163            eprintln!("daemon stopped (PID {pid})");
164            return Ok(());
165        }
166    }
167
168    // Force kill after timeout.
169    eprintln!("warning: daemon PID {pid} did not exit after 5s — sending SIGKILL");
170    unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
171    std::thread::sleep(std::time::Duration::from_millis(200));
172    pidfile.release()?;
173    eprintln!("daemon killed");
174    Ok(())
175}
176
177#[cfg(unix)]
178fn handle_status(json: bool, storage: &ConfigStorage) -> Result<()> {
179    let home = dirs::home_dir().context("could not find home directory")?;
180    let cc_switch_dir = home.join(".cc-switch");
181    let pidfile_path = cc_switch_dir.join("daemon.pid");
182    let state_path = cc_switch_dir.join("daemon-state.json");
183
184    let pidfile = Pidfile::new(pidfile_path);
185    let pid = match pidfile.read()? {
186        Some(pid) => pid,
187        None => {
188            if json {
189                println!("{{\"status\":\"stopped\"}}");
190            } else {
191                println!("ccs-daemon: STOPPED (no pidfile)");
192            }
193            return Ok(());
194        }
195    };
196
197    if !process_alive(pid)? {
198        if json {
199            println!("{{\"status\":\"stopped\",\"stale_pid\":{pid}}}");
200        } else {
201            println!("ccs-daemon: STOPPED (stale pidfile, PID {pid} is dead)");
202        }
203        return Ok(());
204    }
205
206    let state = match DaemonState::load(&state_path)? {
207        Some(s) => s,
208        None => {
209            if json {
210                println!("{{\"status\":\"running\",\"pid\":{pid},\"proxies\":[]}}");
211            } else {
212                println!("ccs-daemon: RUNNING (pid {pid}) — no state file");
213            }
214            return Ok(());
215        }
216    };
217
218    let aliases_by_upstream = crate::daemon::status::build_aliases_by_upstream(storage);
219    let statuses = crate::daemon::status::collect_status(&state);
220
221    if json {
222        let output = crate::daemon::status::format_status_json(&state, &statuses);
223        println!("{}", serde_json::to_string_pretty(&output)?);
224    } else {
225        let text =
226            crate::daemon::status::format_status_text(&state, &statuses, &aliases_by_upstream);
227        print!("{text}");
228    }
229
230    Ok(())
231}