Skip to main content

lean_ctx/
daemon.rs

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