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    ipc::endpoint_dir()
29}
30
31pub fn daemon_pid_path() -> PathBuf {
32    data_dir().join("daemon.pid")
33}
34
35/// The daemon's process identity, next to `daemon.pid` (#2176 review #1).
36/// A separate file keeps `daemon.pid` a plain PID, which earlier releases
37/// (still running MCP servers during an update) read and parse.
38fn daemon_identity_path() -> PathBuf {
39    data_dir().join("daemon.identity.json")
40}
41
42#[derive(serde::Serialize, serde::Deserialize)]
43struct DaemonIdentityFile {
44    pid: u32,
45    identity: ipc::process::ProcessIdentity,
46}
47
48/// The recorded daemon: the PID from `daemon.pid` and, when the identity
49/// file names the same PID, the identity recorded at start. A PID file of an
50/// earlier release has no identity; it is gated by command line only.
51#[must_use]
52pub fn read_daemon_record() -> Option<ipc::process::RecordedPid> {
53    let pid = read_daemon_pid()?;
54    let identity = fs::read(daemon_identity_path())
55        .ok()
56        .filter(|bytes| bytes.len() <= 16 * 1024)
57        .and_then(|bytes| serde_json::from_slice::<DaemonIdentityFile>(&bytes).ok())
58        .filter(|file| file.pid == pid)
59        .map(|file| file.identity);
60    Some(ipc::process::RecordedPid { pid, identity })
61}
62
63fn remove_daemon_record() {
64    let _ = fs::remove_file(daemon_pid_path());
65    let _ = fs::remove_file(daemon_identity_path());
66}
67
68/// The PID of the recorded daemon when it really is this user's lean-ctx
69/// daemon: command line `serve --_foreground-daemon`, lean-ctx executable,
70/// same user, and the recorded identity where there is one. Slower than
71/// [`is_daemon_running`] (reads the command line); for `doctor` and `stop`.
72#[must_use]
73pub fn verified_daemon() -> Option<ipc::process::ServiceProcess> {
74    let record = read_daemon_record()?;
75    ipc::process::find_service_processes(
76        &[record],
77        ipc::process::ServiceSweep::Only(ipc::process::ServiceKind::Daemon),
78    )
79    .into_iter()
80    .next()
81}
82
83/// Releases up to 3.12.0 kept `daemon.pid` and `daemon.sock` in the OS
84/// data-local directory. A daemon such a release started is stopped before a
85/// new one starts and by `stop_daemon`, so the two never run side by side. Only
86/// a live `lean-ctx` process is signalled, never a reused PID. A process with a
87/// directory override (a test, an isolated instance) never touches it: that
88/// daemon belongs to the default install (GH #2007).
89fn stop_legacy_daemon() {
90    if cfg!(test) || crate::core::paths::dir_override_active() {
91        return;
92    }
93    let legacy = ipc::legacy_endpoint_dir();
94    if legacy == data_dir() {
95        return;
96    }
97    let pid_path = legacy.join("daemon.pid");
98    let Some(pid) = fs::read_to_string(&pid_path)
99        .ok()
100        .and_then(|contents| contents.trim().parse::<u32>().ok())
101    else {
102        return;
103    };
104    // Only when the PID still is a lean-ctx daemon of this user (command
105    // line, executable, identity): a name match alone also matched MCP
106    // servers on Windows, where PIDs are reused quickly (#2176 review #1).
107    let report = ipc::process::stop_services(
108        &[ipc::process::RecordedPid::bare(pid)],
109        ipc::process::ServiceSweep::Only(ipc::process::ServiceKind::Daemon),
110    );
111    if !report.stopped.is_empty() {
112        eprintln!("Stopped the daemon of an earlier release (PID {pid}).");
113    }
114    let _ = fs::remove_file(&pid_path);
115    #[cfg(unix)]
116    let _ = fs::remove_file(legacy.join("daemon.sock"));
117}
118
119pub fn daemon_addr() -> ipc::DaemonAddr {
120    ipc::DaemonAddr::default_for_current_os()
121}
122
123/// Fast check on the hot path (every MCP request): the recorded PID is
124/// alive and, when the daemon recorded its identity, still that process. A
125/// PID file of an earlier release has no identity; [`verified_daemon`] also
126/// checks the command line.
127pub fn is_daemon_running() -> bool {
128    let Some(record) = read_daemon_record() else {
129        return false;
130    };
131    if record.pid == std::process::id() {
132        return true;
133    }
134    let pid_alive = ipc::process::is_alive(record.pid);
135    match record.identity.as_ref() {
136        Some(identity) if pid_alive && ipc::process::is_same_process(record.pid, identity) => {
137            return true;
138        }
139        // A PID file without identity (an earlier release, or a PID reused
140        // after a reboot): only a verified lean-ctx daemon counts (#2176 VM).
141        // Rare, so the slower command-line gate is fine. A live process that
142        // cannot be verified keeps its files: `daemon start`/`stop` decide.
143        None if pid_alive => return legacy_record_verified(record.pid),
144        Some(_) | None => {}
145    }
146    remove_daemon_record();
147    ipc::cleanup(&daemon_addr());
148    false
149}
150
151/// [`verified_daemon`] for an identity-less PID file, remembered per PID for
152/// this process: the hot path must not read command lines on every request.
153fn legacy_record_verified(pid: u32) -> bool {
154    // Bit 33: cached; bit 32: verified; low 32 bits: the PID.
155    static LAST: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
156    let last = LAST.load(Ordering::Relaxed);
157    if last & (1 << 33) != 0 && last as u32 == pid {
158        return last & (1 << 32) != 0;
159    }
160    let verified = verified_daemon().is_some_and(|daemon| daemon.pid == pid);
161    LAST.store(
162        (1 << 33) | (u64::from(verified) << 32) | u64::from(pid),
163        Ordering::Relaxed,
164    );
165    verified
166}
167
168pub fn read_daemon_pid() -> Option<u32> {
169    let contents = fs::read_to_string(daemon_pid_path()).ok()?;
170    contents.trim().parse::<u32>().ok()
171}
172
173/// Exclusive, bounded-wait lock that serializes the daemon-start critical
174/// section (liveness check → spawn → PID write). Several MCP servers launching
175/// at once (Claude Code + OpenCode + Cursor) would otherwise all pass the
176/// `is_daemon_running()` check in the TOCTOU window and each spawn a daemon —
177/// the process proliferation seen in #453. The advisory flock is tied to the
178/// open fd, so it is released automatically if a holder crashes; the bounded
179/// wait keeps a wedged holder from blocking startup forever (the
180/// `is_daemon_running()` re-check remains the last line of defense).
181fn acquire_start_lock() -> Option<fs::File> {
182    use fs2::FileExt;
183    let lock_path = data_dir().join("daemon.start.lock");
184    if let Some(parent) = lock_path.parent() {
185        let _ = fs::create_dir_all(parent);
186    }
187    let file = fs::OpenOptions::new()
188        .create(true)
189        .write(true)
190        .truncate(false)
191        .open(&lock_path)
192        .ok()?;
193    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
194    loop {
195        match file.try_lock_exclusive() {
196            Ok(()) => return Some(file),
197            Err(_) if std::time::Instant::now() < deadline => {
198                std::thread::sleep(std::time::Duration::from_millis(50));
199            }
200            Err(_) => return None,
201        }
202    }
203}
204
205pub fn start_daemon(args: &[String]) -> Result<()> {
206    start_daemon_with_binary(args, None)
207}
208
209/// Source installs launch the verified installed copy, preserving the caller's CWD.
210pub fn start_daemon_with_binary(args: &[String], binary: Option<&std::path::Path>) -> Result<()> {
211    // Held for the whole critical section; released when `_start_lock` drops.
212    let _start_lock = acquire_start_lock();
213
214    // #2176 VM: a reused PID in daemon.pid (any live process) must not block
215    // the start; only a verified lean-ctx daemon of this user does.
216    if let Some(daemon) = verified_daemon() {
217        anyhow::bail!(
218            "Daemon already running (PID {}). Use --stop to stop it first.",
219            daemon.pid
220        );
221    }
222    if let Some(pid) = read_daemon_pid() {
223        eprintln!("{}", stale_pid_message(pid));
224        remove_daemon_record();
225    }
226
227    stop_legacy_daemon();
228    ipc::cleanup(&daemon_addr());
229
230    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
231        crate::config_io::cleanup_legacy_backups(&data_dir);
232    }
233
234    let exe = binary.map_or_else(
235        || std::path::PathBuf::from(crate::core::portable_binary::resolve_portable_binary()),
236        std::path::Path::to_path_buf,
237    );
238
239    let mut cmd_args = vec!["serve".to_string()];
240    for arg in args {
241        if arg == "--daemon" || arg == "-d" {
242            continue;
243        }
244        cmd_args.push(arg.clone());
245    }
246    cmd_args.push("--_foreground-daemon".to_string());
247
248    let log_dir = data_dir();
249    let _ = fs::create_dir_all(&log_dir);
250    let stderr_log = log_dir.join("daemon-stderr.log");
251    let stderr_file = fs::OpenOptions::new()
252        .create(true)
253        .write(true)
254        .truncate(true)
255        .open(&stderr_log);
256    let stderr_cfg = match stderr_file {
257        Ok(f) => ipc::process::DetachedStderr::File(f),
258        Err(error) => {
259            eprintln!(
260                "Cannot open {}: {error}; daemon stderr disabled.",
261                stderr_log.display()
262            );
263            ipc::process::DetachedStderr::Null
264        }
265    };
266
267    let mut cmd = Command::new(&exe);
268    cmd.args(&cmd_args);
269    // #356: if the *spawner* is a launchd-standalone process (e.g. the proxy
270    // auto-starting the daemon), the spawned daemon's ppid is the spawner — not
271    // 1 — so `getppid()`-based detection would miss it and the daemon's TCC path
272    // guards would stay off. Propagate the marker explicitly so the daemon
273    // treats itself as standalone regardless of where it sits in the tree.
274    if crate::core::pathutil::process_is_tcc_standalone() {
275        cmd.env("LEAN_CTX_TCC_STANDALONE", "1");
276    }
277    // Detached spawn: on Windows the daemon leaves the caller's job and
278    // inherits none of its handles (GL #545, #2176).
279    let child = ipc::process::spawn_detached(&mut cmd, stderr_cfg)
280        .with_context(|| format!("failed to spawn daemon: {}", exe.display()))?;
281
282    let pid = child.id();
283    write_pid_file(pid)?;
284
285    std::thread::sleep(std::time::Duration::from_millis(200));
286
287    if !ipc::process::is_alive(pid) {
288        remove_daemon_record();
289        let stderr_content = fs::read_to_string(&stderr_log).unwrap_or_default();
290        let stderr_trimmed = stderr_content.trim();
291        if stderr_trimmed.is_empty() {
292            anyhow::bail!("Daemon process exited immediately. Check logs for errors.");
293        }
294        anyhow::bail!("Daemon process exited immediately:\n{stderr_trimmed}");
295    }
296
297    let addr = daemon_addr();
298    if crate::core::protocol::meta_visible() {
299        eprintln!(
300            "lean-ctx daemon started (PID {pid})\n  Endpoint: {}\n  PID file: {}",
301            addr.display(),
302            daemon_pid_path().display()
303        );
304    }
305
306    Ok(())
307}
308
309/// What `stop_daemon` says when the recorded daemon had already exited. A
310/// stale PID file is normal after a reboot, a crash or an editor closing, so
311/// the line is neutral rather than alarming (#2176).
312fn stale_pid_message(pid: u32) -> String {
313    format!("No daemon was running (stale PID file for PID {pid} removed).")
314}
315
316pub fn stop_daemon() -> Result<()> {
317    stop_legacy_daemon();
318
319    let Some(record) = read_daemon_record() else {
320        eprintln!("No daemon PID file found. Nothing to stop.");
321        return Ok(());
322    };
323    let pid = record.pid;
324
325    // #2176 review #1: the PID file survives a reboot or crash, and Windows
326    // reuses PIDs quickly — the PID may now be an MCP server, an editor or
327    // any other process. It is signalled only while it is still this user's
328    // lean-ctx daemon (command line, executable, recorded identity).
329    let Some(daemon) = ipc::process::find_service_processes(
330        std::slice::from_ref(&record),
331        ipc::process::ServiceSweep::Only(ipc::process::ServiceKind::Daemon),
332    )
333    .into_iter()
334    .next() else {
335        // Not provably the daemon: never signalled. Should the check have
336        // failed for a real daemon (unreadable command line), the endpoint
337        // shutdown still ends it before its endpoint files are removed.
338        if ipc::process::is_alive(pid) {
339            let _ = try_http_shutdown();
340        }
341        eprintln!("{}", stale_pid_message(pid));
342        ipc::cleanup(&daemon_addr());
343        remove_daemon_record();
344        return Ok(());
345    };
346
347    // The HTTP shutdown goes to this instance's daemon endpoint, never to a PID.
348    if try_http_shutdown() {
349        for _ in 0..30 {
350            std::thread::sleep(std::time::Duration::from_millis(100));
351            if !daemon.is_running() {
352                break;
353            }
354        }
355    }
356
357    if daemon.is_running() {
358        daemon.signal(ipc::process::StopSignal::Terminate);
359        for _ in 0..20 {
360            std::thread::sleep(std::time::Duration::from_millis(100));
361            if !daemon.is_running() {
362                break;
363            }
364        }
365    }
366
367    if daemon.is_running() {
368        eprintln!("Daemon (PID {pid}) did not stop gracefully, force killing.");
369        daemon.signal(ipc::process::StopSignal::Kill);
370        std::thread::sleep(std::time::Duration::from_millis(200));
371    }
372
373    remove_daemon_record();
374    ipc::cleanup(&daemon_addr());
375    eprintln!("lean-ctx daemon stopped (PID {pid}).");
376
377    // #1292: stopping one daemon ends that daemon only. A machine-wide
378    // name-based sweep here reached every other lean-ctx process — the real
379    // user's daemon and proxy under a sandbox HOME, other worktrees' daemons,
380    // other agents' `lean-ctx -c` commands — from `setup`, `daemon stop` and
381    // `serve`. The lifecycle commands (`stop`, `restart`, `config apply`,
382    // `uninstall`, `dev-install`) sweep the background services explicitly
383    // with `ipc::process::stop_services`, never MCP servers (#2176).
384    Ok(())
385}
386
387fn try_http_shutdown() -> bool {
388    let Ok(rt) = tokio::runtime::Runtime::new() else {
389        return false;
390    };
391
392    rt.block_on(async {
393        crate::daemon_client::daemon_request("POST", "/v1/shutdown", "")
394            .await
395            .is_ok()
396    })
397}
398
399pub fn daemon_status() -> String {
400    let addr = daemon_addr();
401    if let Some(pid) = read_daemon_pid() {
402        if verified_daemon().is_some() {
403            let listening = addr.is_listening();
404            return format!(
405                "Daemon running (PID {pid})\n  Endpoint: {} ({})\n  PID file: {}",
406                addr.display(),
407                if listening { "ready" } else { "missing" },
408                daemon_pid_path().display()
409            );
410        }
411        return format!("Daemon not running (stale PID file for PID {pid})");
412    }
413    "Daemon not running".to_string()
414}
415
416fn write_pid_file(pid: u32) -> Result<()> {
417    let pid_path = daemon_pid_path();
418    if let Some(parent) = pid_path.parent() {
419        fs::create_dir_all(parent)
420            .with_context(|| format!("cannot create dir: {}", parent.display()))?;
421    }
422    let mut f = fs::File::create(&pid_path)
423        .with_context(|| format!("cannot write PID file: {}", pid_path.display()))?;
424    write!(f, "{pid}")?;
425    // The identity defends the PID against reuse after a reboot or crash;
426    // without one (unsupported platform) the PID is gated by command line.
427    let identity_path = daemon_identity_path();
428    match ipc::process::identity(pid) {
429        Some(identity) => {
430            let file = DaemonIdentityFile { pid, identity };
431            if let Ok(bytes) = serde_json::to_vec(&file) {
432                let _ = crate::core::updater::atomic_write_bytes(&identity_path, &bytes);
433            }
434        }
435        None => {
436            let _ = fs::remove_file(&identity_path);
437        }
438    }
439    Ok(())
440}
441
442/// Initialize the foreground-daemon process. Commits the XDG layout pin (and
443/// drains a residual `~/.lean-ctx`) *before* the daemon writes anything, so this
444/// long-running, possibly launchd/systemd-autostarted writer can never
445/// re-collapse config/data/state/cache onto a stray legacy dir (GL #623). The
446/// MCP server pins on its own start; the daemon is the other independent entry
447/// point (e.g. `serve --_foreground-daemon`), so it must heal too. `heal()` is
448/// idempotent and cheap — a no-op once pinned and when no residual dir exists.
449pub fn init_foreground_daemon() -> Result<()> {
450    mark_foreground_daemon();
451    crate::core::layout_pin::heal();
452    // The daemon is the single long-lived owner for local agent-presence
453    // maintenance. Starting this in every short-lived MCP process creates a
454    // reaper swarm precisely when many coding agents connect at once.
455    crate::core::agents::reaper::spawn_reaper();
456    // The daemon starts at login: an Engine without its Cockpit (an update
457    // from a release without one) gets it here, without a command.
458    crate::core::intelligence_runtime::cockpit::install_if_missing_in_background();
459    let pid = std::process::id();
460    write_pid_file(pid)?;
461    Ok(())
462}
463
464/// PIDs of the background services this instance (data dir) recorded: the
465/// daemon PID file, the proxy that answers `/health` on the configured port,
466/// and the Cockpit launch record. Nothing else is ever a stop candidate —
467/// MCP stdio servers, `lean-ctx -c` runners, hooks and other instances'
468/// services have no record here (#2176).
469#[must_use]
470pub fn owned_service_pids() -> Vec<ipc::process::RecordedPid> {
471    let mut pids = Vec::new();
472    pids.extend(read_daemon_record());
473    pids.extend(crate::proxy_autostart::recorded_proxy_pid().map(ipc::process::RecordedPid::bare));
474    // #2176 review #6: the Cockpit record carries the identity it started
475    // with; a record whose PID was reused is not a candidate.
476    pids.extend(crate::core::cockpit_launch_record::read_record().map(|r| {
477        ipc::process::RecordedPid {
478            pid: r.pid,
479            identity: r.identity,
480        }
481    }));
482    pids
483}
484
485/// Stop this instance's recorded background services that `sweep` includes
486/// (see [`owned_service_pids`]); every PID is gated again by command line,
487/// user and process identity in `ipc::process::stop_services`.
488pub fn stop_owned_services(sweep: ipc::process::ServiceSweep) -> ipc::process::ServiceStopReport {
489    ipc::process::stop_services(&owned_service_pids(), sweep)
490}
491
492/// lean-ctx background services of this user that this instance has no
493/// record of. Listed by `stop`, `uninstall` and `doctor`, never stopped
494/// (owner decision 02.11.).
495#[must_use]
496pub fn unrecorded_services() -> Vec<ipc::process::ServiceProcess> {
497    let recorded: Vec<u32> = owned_service_pids().iter().map(|r| r.pid).collect();
498    ipc::process::find_unrecorded_services(&recorded)
499}
500
501/// The two lines `stop`/`uninstall`/`doctor` print for
502/// [`unrecorded_services`] — what still runs, and how to stop it — or `None`.
503#[must_use]
504pub fn unrecorded_services_warning() -> Option<(String, String)> {
505    ipc::process::unrecorded_services_lines(&unrecorded_services(), cfg!(windows))
506}
507
508/// Start a proxy that `restart`/`config apply` stopped again when no
509/// autostart service brought it back (#2176 review #4): detached, with the
510/// arguments it ran with, by the installed binary. `None` when there was
511/// nothing to restore; otherwise the PID or the spawn error.
512pub fn restore_stopped_proxy(
513    report: &ipc::process::ServiceStopReport,
514    autostart_restarted: bool,
515) -> Option<std::io::Result<u32>> {
516    let args = ipc::process::proxy_respawn_args(&report.stopped, autostart_restarted)?;
517    let binary = crate::core::portable_binary::resolve_portable_binary();
518    let mut cmd = Command::new(binary);
519    cmd.args(&args);
520    Some(
521        ipc::process::spawn_detached(&mut cmd, ipc::process::DetachedStderr::Null)
522            .map(|child| child.id()),
523    )
524}
525
526/// Cleanup PID file and IPC endpoint on shutdown.
527pub fn cleanup_daemon_files() {
528    remove_daemon_record();
529    ipc::cleanup(&daemon_addr());
530}
531
532#[cfg(test)]
533mod tests {
534    /// #2176: a stale PID file on `restart` read as an alarm ("is not
535    /// running. Cleaning up stale files."); it is a neutral note now.
536    #[test]
537    fn stale_pid_message_is_neutral() {
538        let line = super::stale_pid_message(36492);
539        assert_eq!(
540            line,
541            "No daemon was running (stale PID file for PID 36492 removed)."
542        );
543        assert!(!line.contains("Cleaning up"));
544    }
545}