Skip to main content

lean_ctx/
daemon.rs

1use std::fs;
2use std::io::Write;
3use std::path::PathBuf;
4use std::process::Command;
5
6use anyhow::{Context, Result};
7
8use crate::ipc;
9
10fn data_dir() -> PathBuf {
11    dirs::data_local_dir()
12        .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
13        .join("lean-ctx")
14}
15
16pub fn daemon_pid_path() -> PathBuf {
17    data_dir().join("daemon.pid")
18}
19
20pub fn daemon_addr() -> ipc::DaemonAddr {
21    ipc::DaemonAddr::default_for_current_os()
22}
23
24pub fn is_daemon_running() -> bool {
25    let pid_path = daemon_pid_path();
26    let Ok(contents) = fs::read_to_string(&pid_path) else {
27        return false;
28    };
29    let Ok(pid) = contents.trim().parse::<u32>() else {
30        return false;
31    };
32    if ipc::process::is_alive(pid) {
33        return true;
34    }
35    let _ = fs::remove_file(&pid_path);
36    ipc::cleanup(&daemon_addr());
37    false
38}
39
40pub fn read_daemon_pid() -> Option<u32> {
41    let contents = fs::read_to_string(daemon_pid_path()).ok()?;
42    contents.trim().parse::<u32>().ok()
43}
44
45pub fn start_daemon(args: &[String]) -> Result<()> {
46    if is_daemon_running() {
47        let pid = read_daemon_pid().unwrap_or(0);
48        anyhow::bail!("Daemon already running (PID {pid}). Use --stop to stop it first.");
49    }
50
51    ipc::cleanup(&daemon_addr());
52
53    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
54        crate::config_io::cleanup_legacy_backups(&data_dir);
55    }
56
57    let exe_str = crate::core::portable_binary::resolve_portable_binary();
58    let exe = std::path::PathBuf::from(&exe_str);
59
60    let mut cmd_args = vec!["serve".to_string()];
61    for arg in args {
62        if arg == "--daemon" || arg == "-d" {
63            continue;
64        }
65        cmd_args.push(arg.clone());
66    }
67    cmd_args.push("--_foreground-daemon".to_string());
68
69    let log_dir = data_dir();
70    let _ = fs::create_dir_all(&log_dir);
71    let stderr_log = log_dir.join("daemon-stderr.log");
72    let stderr_file = fs::OpenOptions::new()
73        .create(true)
74        .write(true)
75        .truncate(true)
76        .open(&stderr_log);
77    let stderr_cfg = match stderr_file {
78        Ok(f) => std::process::Stdio::from(f),
79        Err(_) => std::process::Stdio::inherit(),
80    };
81
82    let mut cmd = Command::new(&exe);
83    cmd.args(&cmd_args)
84        .stdin(std::process::Stdio::null())
85        .stdout(std::process::Stdio::null())
86        .stderr(stderr_cfg);
87    // Detached spawn: on Windows the daemon must escape the parent's
88    // console/Job so it survives AI-client MCP process recycling (GL #545).
89    let child = ipc::process::spawn_detached(&mut cmd)
90        .with_context(|| format!("failed to spawn daemon: {}", exe.display()))?;
91
92    let pid = child.id();
93    write_pid_file(pid)?;
94
95    std::thread::sleep(std::time::Duration::from_millis(200));
96
97    if !ipc::process::is_alive(pid) {
98        let _ = fs::remove_file(daemon_pid_path());
99        let stderr_content = fs::read_to_string(&stderr_log).unwrap_or_default();
100        let stderr_trimmed = stderr_content.trim();
101        if stderr_trimmed.is_empty() {
102            anyhow::bail!("Daemon process exited immediately. Check logs for errors.");
103        }
104        anyhow::bail!("Daemon process exited immediately:\n{stderr_trimmed}");
105    }
106
107    let addr = daemon_addr();
108    if crate::core::protocol::meta_visible() {
109        eprintln!(
110            "lean-ctx daemon started (PID {pid})\n  Endpoint: {}\n  PID file: {}",
111            addr.display(),
112            daemon_pid_path().display()
113        );
114    }
115
116    Ok(())
117}
118
119pub fn stop_daemon() -> Result<()> {
120    let pid_path = daemon_pid_path();
121
122    let Some(pid) = read_daemon_pid() else {
123        eprintln!("No daemon PID file found. Nothing to stop.");
124        return Ok(());
125    };
126
127    if !ipc::process::is_alive(pid) {
128        eprintln!("Daemon (PID {pid}) is not running. Cleaning up stale files.");
129        ipc::cleanup(&daemon_addr());
130        let _ = fs::remove_file(&pid_path);
131        return Ok(());
132    }
133
134    let http_shutdown_ok = try_http_shutdown();
135
136    if http_shutdown_ok {
137        for _ in 0..30 {
138            std::thread::sleep(std::time::Duration::from_millis(100));
139            if !ipc::process::is_alive(pid) {
140                break;
141            }
142        }
143    }
144
145    if ipc::process::is_alive(pid) {
146        let _ = ipc::process::terminate_gracefully(pid);
147        for _ in 0..20 {
148            std::thread::sleep(std::time::Duration::from_millis(100));
149            if !ipc::process::is_alive(pid) {
150                break;
151            }
152        }
153    }
154
155    if ipc::process::is_alive(pid) {
156        eprintln!("Daemon (PID {pid}) did not stop gracefully, force killing.");
157        let _ = ipc::process::force_kill(pid);
158        std::thread::sleep(std::time::Duration::from_millis(200));
159    }
160
161    let _ = fs::remove_file(&pid_path);
162    ipc::cleanup(&daemon_addr());
163    eprintln!("lean-ctx daemon stopped (PID {pid}).");
164
165    let orphans = ipc::process::find_pids_by_name("lean-ctx");
166    if !orphans.is_empty() {
167        eprintln!("  Cleaning up {} orphan process(es)…", orphans.len());
168        ipc::process::kill_all_by_name("lean-ctx");
169    }
170
171    Ok(())
172}
173
174fn try_http_shutdown() -> bool {
175    let Ok(rt) = tokio::runtime::Runtime::new() else {
176        return false;
177    };
178
179    rt.block_on(async {
180        crate::daemon_client::daemon_request("POST", "/v1/shutdown", "")
181            .await
182            .is_ok()
183    })
184}
185
186pub fn daemon_status() -> String {
187    let addr = daemon_addr();
188    if let Some(pid) = read_daemon_pid() {
189        if ipc::process::is_alive(pid) {
190            let listening = addr.is_listening();
191            return format!(
192                "Daemon running (PID {pid})\n  Endpoint: {} ({})\n  PID file: {}",
193                addr.display(),
194                if listening { "ready" } else { "missing" },
195                daemon_pid_path().display()
196            );
197        }
198        return format!("Daemon not running (stale PID file for PID {pid})");
199    }
200    "Daemon not running".to_string()
201}
202
203fn write_pid_file(pid: u32) -> Result<()> {
204    let pid_path = daemon_pid_path();
205    if let Some(parent) = pid_path.parent() {
206        fs::create_dir_all(parent)
207            .with_context(|| format!("cannot create dir: {}", parent.display()))?;
208    }
209    let mut f = fs::File::create(&pid_path)
210        .with_context(|| format!("cannot write PID file: {}", pid_path.display()))?;
211    write!(f, "{pid}")?;
212    Ok(())
213}
214
215/// Write the current process's PID. Called from the foreground-daemon process.
216pub fn init_foreground_daemon() -> Result<()> {
217    let pid = std::process::id();
218    write_pid_file(pid)?;
219    Ok(())
220}
221
222/// Cleanup PID file and IPC endpoint on shutdown.
223pub fn cleanup_daemon_files() {
224    let _ = fs::remove_file(daemon_pid_path());
225    ipc::cleanup(&daemon_addr());
226}