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
45/// Exclusive, bounded-wait lock that serializes the daemon-start critical
46/// section (liveness check → spawn → PID write). Several MCP servers launching
47/// at once (Claude Code + OpenCode + Cursor) would otherwise all pass the
48/// `is_daemon_running()` check in the TOCTOU window and each spawn a daemon —
49/// the process proliferation seen in #453. The advisory flock is tied to the
50/// open fd, so it is released automatically if a holder crashes; the bounded
51/// wait keeps a wedged holder from blocking startup forever (the
52/// `is_daemon_running()` re-check remains the last line of defense).
53fn acquire_start_lock() -> Option<fs::File> {
54    use fs2::FileExt;
55    let lock_path = data_dir().join("daemon.start.lock");
56    if let Some(parent) = lock_path.parent() {
57        let _ = fs::create_dir_all(parent);
58    }
59    let file = fs::OpenOptions::new()
60        .create(true)
61        .write(true)
62        .truncate(false)
63        .open(&lock_path)
64        .ok()?;
65    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
66    loop {
67        match file.try_lock_exclusive() {
68            Ok(()) => return Some(file),
69            Err(_) if std::time::Instant::now() < deadline => {
70                std::thread::sleep(std::time::Duration::from_millis(50));
71            }
72            Err(_) => return None,
73        }
74    }
75}
76
77pub fn start_daemon(args: &[String]) -> Result<()> {
78    // Held for the whole critical section; released when `_start_lock` drops.
79    let _start_lock = acquire_start_lock();
80
81    if is_daemon_running() {
82        let pid = read_daemon_pid().unwrap_or(0);
83        anyhow::bail!("Daemon already running (PID {pid}). Use --stop to stop it first.");
84    }
85
86    ipc::cleanup(&daemon_addr());
87
88    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
89        crate::config_io::cleanup_legacy_backups(&data_dir);
90    }
91
92    let exe_str = crate::core::portable_binary::resolve_portable_binary();
93    let exe = std::path::PathBuf::from(&exe_str);
94
95    let mut cmd_args = vec!["serve".to_string()];
96    for arg in args {
97        if arg == "--daemon" || arg == "-d" {
98            continue;
99        }
100        cmd_args.push(arg.clone());
101    }
102    cmd_args.push("--_foreground-daemon".to_string());
103
104    let log_dir = data_dir();
105    let _ = fs::create_dir_all(&log_dir);
106    let stderr_log = log_dir.join("daemon-stderr.log");
107    let stderr_file = fs::OpenOptions::new()
108        .create(true)
109        .write(true)
110        .truncate(true)
111        .open(&stderr_log);
112    let stderr_cfg = match stderr_file {
113        Ok(f) => std::process::Stdio::from(f),
114        Err(_) => std::process::Stdio::inherit(),
115    };
116
117    let mut cmd = Command::new(&exe);
118    cmd.args(&cmd_args)
119        .stdin(std::process::Stdio::null())
120        .stdout(std::process::Stdio::null())
121        .stderr(stderr_cfg);
122    // #356: if the *spawner* is a launchd-standalone process (e.g. the proxy
123    // auto-starting the daemon), the spawned daemon's ppid is the spawner — not
124    // 1 — so `getppid()`-based detection would miss it and the daemon's TCC path
125    // guards would stay off. Propagate the marker explicitly so the daemon
126    // treats itself as standalone regardless of where it sits in the tree.
127    if crate::core::pathutil::process_is_tcc_standalone() {
128        cmd.env("LEAN_CTX_TCC_STANDALONE", "1");
129    }
130    // Detached spawn: on Windows the daemon must escape the parent's
131    // console/Job so it survives AI-client MCP process recycling (GL #545).
132    let child = ipc::process::spawn_detached(&mut cmd)
133        .with_context(|| format!("failed to spawn daemon: {}", exe.display()))?;
134
135    let pid = child.id();
136    write_pid_file(pid)?;
137
138    std::thread::sleep(std::time::Duration::from_millis(200));
139
140    if !ipc::process::is_alive(pid) {
141        let _ = fs::remove_file(daemon_pid_path());
142        let stderr_content = fs::read_to_string(&stderr_log).unwrap_or_default();
143        let stderr_trimmed = stderr_content.trim();
144        if stderr_trimmed.is_empty() {
145            anyhow::bail!("Daemon process exited immediately. Check logs for errors.");
146        }
147        anyhow::bail!("Daemon process exited immediately:\n{stderr_trimmed}");
148    }
149
150    let addr = daemon_addr();
151    if crate::core::protocol::meta_visible() {
152        eprintln!(
153            "lean-ctx daemon started (PID {pid})\n  Endpoint: {}\n  PID file: {}",
154            addr.display(),
155            daemon_pid_path().display()
156        );
157    }
158
159    Ok(())
160}
161
162pub fn stop_daemon() -> Result<()> {
163    let pid_path = daemon_pid_path();
164
165    let Some(pid) = read_daemon_pid() else {
166        eprintln!("No daemon PID file found. Nothing to stop.");
167        return Ok(());
168    };
169
170    if !ipc::process::is_alive(pid) {
171        eprintln!("Daemon (PID {pid}) is not running. Cleaning up stale files.");
172        ipc::cleanup(&daemon_addr());
173        let _ = fs::remove_file(&pid_path);
174        return Ok(());
175    }
176
177    let http_shutdown_ok = try_http_shutdown();
178
179    if http_shutdown_ok {
180        for _ in 0..30 {
181            std::thread::sleep(std::time::Duration::from_millis(100));
182            if !ipc::process::is_alive(pid) {
183                break;
184            }
185        }
186    }
187
188    if ipc::process::is_alive(pid) {
189        let _ = ipc::process::terminate_gracefully(pid);
190        for _ in 0..20 {
191            std::thread::sleep(std::time::Duration::from_millis(100));
192            if !ipc::process::is_alive(pid) {
193                break;
194            }
195        }
196    }
197
198    if ipc::process::is_alive(pid) {
199        eprintln!("Daemon (PID {pid}) did not stop gracefully, force killing.");
200        let _ = ipc::process::force_kill(pid);
201        std::thread::sleep(std::time::Duration::from_millis(200));
202    }
203
204    let _ = fs::remove_file(&pid_path);
205    ipc::cleanup(&daemon_addr());
206    eprintln!("lean-ctx daemon stopped (PID {pid}).");
207
208    let orphans = ipc::process::find_pids_by_name("lean-ctx");
209    if !orphans.is_empty() {
210        eprintln!("  Cleaning up {} orphan process(es)…", orphans.len());
211        ipc::process::kill_all_by_name("lean-ctx");
212    }
213
214    Ok(())
215}
216
217fn try_http_shutdown() -> bool {
218    let Ok(rt) = tokio::runtime::Runtime::new() else {
219        return false;
220    };
221
222    rt.block_on(async {
223        crate::daemon_client::daemon_request("POST", "/v1/shutdown", "")
224            .await
225            .is_ok()
226    })
227}
228
229pub fn daemon_status() -> String {
230    let addr = daemon_addr();
231    if let Some(pid) = read_daemon_pid() {
232        if ipc::process::is_alive(pid) {
233            let listening = addr.is_listening();
234            return format!(
235                "Daemon running (PID {pid})\n  Endpoint: {} ({})\n  PID file: {}",
236                addr.display(),
237                if listening { "ready" } else { "missing" },
238                daemon_pid_path().display()
239            );
240        }
241        return format!("Daemon not running (stale PID file for PID {pid})");
242    }
243    "Daemon not running".to_string()
244}
245
246fn write_pid_file(pid: u32) -> Result<()> {
247    let pid_path = daemon_pid_path();
248    if let Some(parent) = pid_path.parent() {
249        fs::create_dir_all(parent)
250            .with_context(|| format!("cannot create dir: {}", parent.display()))?;
251    }
252    let mut f = fs::File::create(&pid_path)
253        .with_context(|| format!("cannot write PID file: {}", pid_path.display()))?;
254    write!(f, "{pid}")?;
255    Ok(())
256}
257
258/// Initialize the foreground-daemon process. Commits the XDG layout pin (and
259/// drains a residual `~/.lean-ctx`) *before* the daemon writes anything, so this
260/// long-running, possibly launchd/systemd-autostarted writer can never
261/// re-collapse config/data/state/cache onto a stray legacy dir (GL #623). The
262/// MCP server pins on its own start; the daemon is the other independent entry
263/// point (e.g. `serve --_foreground-daemon`), so it must heal too. `heal()` is
264/// idempotent and cheap — a no-op once pinned and when no residual dir exists.
265pub fn init_foreground_daemon() -> Result<()> {
266    crate::core::layout_pin::heal();
267    let pid = std::process::id();
268    write_pid_file(pid)?;
269    Ok(())
270}
271
272/// Cleanup PID file and IPC endpoint on shutdown.
273pub fn cleanup_daemon_files() {
274    let _ = fs::remove_file(daemon_pid_path());
275    ipc::cleanup(&daemon_addr());
276}