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        crate::daemon::fork::double_fork_into_background(&log_path)?;
97    }
98
99    crate::daemon::lifecycle::run_daemon_blocking(cfg, log_level, verbose)
100}
101
102#[cfg(unix)]
103fn handle_stop() -> Result<()> {
104    let home = dirs::home_dir().context("could not find home directory")?;
105    let pidfile_path = home.join(".cc-switch").join("daemon.pid");
106    let pidfile = Pidfile::new(pidfile_path);
107
108    let pid = match pidfile.read()? {
109        Some(pid) => pid,
110        None => {
111            eprintln!("daemon not running (no pidfile)");
112            return Ok(());
113        }
114    };
115
116    if !process_alive(pid)? {
117        eprintln!("daemon not running (stale pidfile for PID {pid}) — cleaning up");
118        pidfile.release()?;
119        return Ok(());
120    }
121
122    // Send SIGTERM.
123    let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
124    if ret != 0 {
125        let err = std::io::Error::last_os_error();
126        if err.raw_os_error() == Some(libc::ESRCH) {
127            eprintln!("daemon not running (PID {pid} gone) — cleaning up");
128            pidfile.release()?;
129            return Ok(());
130        }
131        return Err(err).with_context(|| format!("failed to send SIGTERM to PID {pid}"));
132    }
133
134    // Poll for exit (up to 5 seconds).
135    for _ in 0..50 {
136        std::thread::sleep(std::time::Duration::from_millis(100));
137        if !process_alive(pid)? {
138            eprintln!("daemon stopped (PID {pid})");
139            return Ok(());
140        }
141    }
142
143    // Force kill after timeout.
144    eprintln!("warning: daemon PID {pid} did not exit after 5s — sending SIGKILL");
145    unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
146    std::thread::sleep(std::time::Duration::from_millis(200));
147    pidfile.release()?;
148    eprintln!("daemon killed");
149    Ok(())
150}
151
152#[cfg(unix)]
153fn handle_status(json: bool, storage: &ConfigStorage) -> Result<()> {
154    let home = dirs::home_dir().context("could not find home directory")?;
155    let cc_switch_dir = home.join(".cc-switch");
156    let pidfile_path = cc_switch_dir.join("daemon.pid");
157    let state_path = cc_switch_dir.join("daemon-state.json");
158
159    let pidfile = Pidfile::new(pidfile_path);
160    let pid = match pidfile.read()? {
161        Some(pid) => pid,
162        None => {
163            if json {
164                println!("{{\"status\":\"stopped\"}}");
165            } else {
166                println!("ccs-daemon: STOPPED (no pidfile)");
167            }
168            return Ok(());
169        }
170    };
171
172    if !process_alive(pid)? {
173        if json {
174            println!("{{\"status\":\"stopped\",\"stale_pid\":{pid}}}");
175        } else {
176            println!("ccs-daemon: STOPPED (stale pidfile, PID {pid} is dead)");
177        }
178        return Ok(());
179    }
180
181    let state = match DaemonState::load(&state_path)? {
182        Some(s) => s,
183        None => {
184            if json {
185                println!("{{\"status\":\"running\",\"pid\":{pid},\"proxies\":[]}}");
186            } else {
187                println!("ccs-daemon: RUNNING (pid {pid}) — no state file");
188            }
189            return Ok(());
190        }
191    };
192
193    let aliases_by_upstream = crate::daemon::status::build_aliases_by_upstream(storage);
194    let statuses = crate::daemon::status::collect_status(&state);
195
196    if json {
197        let output = crate::daemon::status::format_status_json(&state, &statuses);
198        println!("{}", serde_json::to_string_pretty(&output)?);
199    } else {
200        let text =
201            crate::daemon::status::format_status_text(&state, &statuses, &aliases_by_upstream);
202        print!("{text}");
203    }
204
205    Ok(())
206}