1use std::fs;
2use std::path::PathBuf;
3use std::process::Command;
4use std::sync::atomic::{AtomicBool, Ordering};
5
6use anyhow::{Context, Result};
7
8use crate::ipc;
9
10static IS_FOREGROUND_DAEMON: AtomicBool = AtomicBool::new(false);
14
15pub fn mark_foreground_daemon() {
17 IS_FOREGROUND_DAEMON.store(true, Ordering::Relaxed);
18}
19
20#[must_use]
22pub fn is_foreground_daemon() -> bool {
23 IS_FOREGROUND_DAEMON.load(Ordering::Relaxed)
24}
25
26fn data_dir() -> PathBuf {
27 ipc::endpoint_dir()
28}
29
30pub fn daemon_pid_path() -> PathBuf {
31 data_dir().join("daemon.pid")
32}
33
34fn daemon_identity_path() -> PathBuf {
38 data_dir().join("daemon.identity.json")
39}
40
41#[derive(serde::Serialize, serde::Deserialize)]
42struct DaemonIdentityFile {
43 pid: u32,
44 identity: ipc::process::ProcessIdentity,
45}
46
47#[must_use]
51pub fn read_daemon_record() -> Option<ipc::process::RecordedPid> {
52 let pid = read_daemon_pid()?;
53 let identity = fs::read(daemon_identity_path())
54 .ok()
55 .filter(|bytes| bytes.len() <= 16 * 1024)
56 .and_then(|bytes| serde_json::from_slice::<DaemonIdentityFile>(&bytes).ok())
57 .filter(|file| file.pid == pid)
58 .map(|file| file.identity);
59 Some(ipc::process::RecordedPid { pid, identity })
60}
61
62fn remove_daemon_record() {
63 let _ = fs::remove_file(daemon_pid_path());
64 let _ = fs::remove_file(daemon_identity_path());
65}
66
67#[must_use]
72pub fn verified_daemon() -> Option<ipc::process::ServiceProcess> {
73 let record = read_daemon_record()?;
74 ipc::process::find_service_processes(
75 &[record],
76 ipc::process::ServiceSweep::Only(ipc::process::ServiceKind::Daemon),
77 )
78 .into_iter()
79 .next()
80}
81
82#[must_use]
88pub fn running_daemon_pid() -> Option<u32> {
89 let own = std::process::id();
90 if is_recorded_self(is_foreground_daemon(), read_daemon_pid(), own) {
91 return Some(own);
92 }
93 verified_daemon().map(|daemon| daemon.pid)
94}
95
96fn is_recorded_self(foreground: bool, recorded: Option<u32>, own: u32) -> bool {
101 foreground && recorded == Some(own)
102}
103
104fn stop_legacy_daemon() {
111 if cfg!(test) || crate::core::paths::dir_override_active() {
112 return;
113 }
114 let legacy = ipc::legacy_endpoint_dir();
115 if legacy == data_dir() {
116 return;
117 }
118 let pid_path = legacy.join("daemon.pid");
119 let Some(pid) = ipc::process::read_pid_file(&pid_path) else {
120 return;
121 };
122 let report = ipc::process::stop_services(
126 &[ipc::process::RecordedPid::bare(pid)],
127 ipc::process::ServiceSweep::Only(ipc::process::ServiceKind::Daemon),
128 );
129 if !report.stopped.is_empty() {
130 eprintln!("Stopped the daemon of an earlier release (PID {pid}).");
131 }
132 let _ = fs::remove_file(&pid_path);
133 #[cfg(unix)]
134 let _ = fs::remove_file(legacy.join("daemon.sock"));
135}
136
137pub fn daemon_addr() -> ipc::DaemonAddr {
138 ipc::DaemonAddr::default_for_current_os()
139}
140
141pub fn is_daemon_running() -> bool {
146 let Some(record) = read_daemon_record() else {
147 return false;
148 };
149 if record.pid == std::process::id() {
150 return true;
151 }
152 let pid_alive = ipc::process::is_alive(record.pid);
153 match record.identity.as_ref() {
154 Some(identity) if pid_alive && ipc::process::is_same_process(record.pid, identity) => {
155 return true;
156 }
157 None if pid_alive => return legacy_record_verified(record.pid),
162 Some(_) | None => {}
163 }
164 remove_daemon_record();
165 ipc::cleanup(&daemon_addr());
166 false
167}
168
169fn legacy_record_verified(pid: u32) -> bool {
172 static LAST: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
174 let last = LAST.load(Ordering::Relaxed);
175 if last & (1 << 33) != 0 && last as u32 == pid {
176 return last & (1 << 32) != 0;
177 }
178 let verified = verified_daemon().is_some_and(|daemon| daemon.pid == pid);
179 LAST.store(
180 (1 << 33) | (u64::from(verified) << 32) | u64::from(pid),
181 Ordering::Relaxed,
182 );
183 verified
184}
185
186pub fn read_daemon_pid() -> Option<u32> {
190 ipc::process::read_pid_file(&daemon_pid_path())
191}
192
193fn acquire_start_lock() -> Option<fs::File> {
202 use fs2::FileExt;
203 let lock_path = data_dir().join("daemon.start.lock");
204 if let Some(parent) = lock_path.parent() {
205 let _ = fs::create_dir_all(parent);
206 }
207 let file = fs::OpenOptions::new()
208 .create(true)
209 .write(true)
210 .truncate(false)
211 .open(&lock_path)
212 .ok()?;
213 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
214 loop {
215 match file.try_lock_exclusive() {
216 Ok(()) => return Some(file),
217 Err(_) if std::time::Instant::now() < deadline => {
218 std::thread::sleep(std::time::Duration::from_millis(50));
219 }
220 Err(_) => return None,
221 }
222 }
223}
224
225pub fn start_daemon(args: &[String]) -> Result<()> {
226 start_daemon_with_binary(args, None)
227}
228
229pub fn start_daemon_with_binary(args: &[String], binary: Option<&std::path::Path>) -> Result<()> {
231 let _start_lock = acquire_start_lock();
233
234 if let Some(daemon) = verified_daemon() {
237 anyhow::bail!(
238 "Daemon already running (PID {}). Use --stop to stop it first.",
239 daemon.pid
240 );
241 }
242 if let Some(pid) = read_daemon_pid() {
243 eprintln!("{}", stale_pid_message(pid));
244 remove_daemon_record();
245 }
246
247 stop_legacy_daemon();
248 ipc::cleanup(&daemon_addr());
249
250 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
251 crate::config_io::cleanup_legacy_backups(&data_dir);
252 }
253
254 let exe = binary.map_or_else(
255 || std::path::PathBuf::from(crate::core::portable_binary::resolve_portable_binary()),
256 std::path::Path::to_path_buf,
257 );
258
259 let mut cmd_args = vec!["serve".to_string()];
260 for arg in args {
261 if arg == "--daemon" || arg == "-d" {
262 continue;
263 }
264 cmd_args.push(arg.clone());
265 }
266 cmd_args.push("--_foreground-daemon".to_string());
267
268 let log_dir = data_dir();
269 let _ = fs::create_dir_all(&log_dir);
270 let stderr_log = log_dir.join("daemon-stderr.log");
271 let stderr_file = fs::OpenOptions::new()
272 .create(true)
273 .write(true)
274 .truncate(true)
275 .open(&stderr_log);
276 let stderr_cfg = match stderr_file {
277 Ok(f) => ipc::process::DetachedStderr::File(f),
278 Err(error) => {
279 eprintln!(
280 "Cannot open {}: {error}; daemon stderr disabled.",
281 stderr_log.display()
282 );
283 ipc::process::DetachedStderr::Null
284 }
285 };
286
287 let mut cmd = Command::new(&exe);
288 cmd.args(&cmd_args);
289 if crate::core::pathutil::process_is_tcc_standalone() {
295 cmd.env("LEAN_CTX_TCC_STANDALONE", "1");
296 }
297 let child = ipc::process::spawn_detached(&mut cmd, stderr_cfg)
300 .with_context(|| format!("failed to spawn daemon: {}", exe.display()))?;
301
302 let pid = child.id();
303 write_pid_file(pid)?;
304
305 std::thread::sleep(std::time::Duration::from_millis(200));
306
307 if !ipc::process::is_alive(pid) {
308 remove_daemon_record();
309 let stderr_content = fs::read_to_string(&stderr_log).unwrap_or_default();
310 let stderr_trimmed = stderr_content.trim();
311 if stderr_trimmed.is_empty() {
312 anyhow::bail!("Daemon process exited immediately. Check logs for errors.");
313 }
314 anyhow::bail!("Daemon process exited immediately:\n{stderr_trimmed}");
315 }
316
317 let addr = daemon_addr();
318 if crate::core::protocol::meta_visible() {
319 eprintln!(
320 "lean-ctx daemon started (PID {pid})\n Endpoint: {}\n PID file: {}",
321 addr.display(),
322 daemon_pid_path().display()
323 );
324 }
325
326 Ok(())
327}
328
329fn stale_pid_message(pid: u32) -> String {
333 format!("No daemon was running (stale PID file for PID {pid} removed).")
334}
335
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
338pub enum DaemonStop {
339 Stopped(u32),
341 NotRunning,
343}
344
345#[must_use]
349pub fn not_running_message() -> String {
350 format!(
351 "No lean-ctx daemon is running for data dir {}. Nothing was stopped.",
352 data_dir().display()
353 )
354}
355
356pub fn stop_daemon() -> Result<DaemonStop> {
363 stop_legacy_daemon();
364
365 let Some(record) = read_daemon_record() else {
367 return Ok(DaemonStop::NotRunning);
368 };
369 let pid = record.pid;
370
371 let Some(daemon) = ipc::process::find_service_processes(
376 std::slice::from_ref(&record),
377 ipc::process::ServiceSweep::Only(ipc::process::ServiceKind::Daemon),
378 )
379 .into_iter()
380 .next() else {
381 let stopped_via_endpoint =
388 ipc::process::is_alive(pid) && try_http_shutdown() && wait_for_exit(pid);
389 ipc::cleanup(&daemon_addr());
390 remove_daemon_record();
391 if stopped_via_endpoint {
392 eprintln!("lean-ctx daemon stopped (PID {pid}).");
393 return Ok(DaemonStop::Stopped(pid));
394 }
395 eprintln!("{}", stale_pid_message(pid));
396 return Ok(DaemonStop::NotRunning);
397 };
398
399 if crate::daemon_autostart::supervised_pid() == Some(daemon.pid) {
403 crate::daemon_autostart::stop();
404 for _ in 0..30 {
405 if !daemon.is_running() {
406 break;
407 }
408 std::thread::sleep(std::time::Duration::from_millis(100));
409 }
410 }
411
412 if try_http_shutdown() {
414 for _ in 0..30 {
415 std::thread::sleep(std::time::Duration::from_millis(100));
416 if !daemon.is_running() {
417 break;
418 }
419 }
420 }
421
422 if daemon.is_running() {
423 daemon.signal(ipc::process::StopSignal::Terminate);
424 for _ in 0..20 {
425 std::thread::sleep(std::time::Duration::from_millis(100));
426 if !daemon.is_running() {
427 break;
428 }
429 }
430 }
431
432 if daemon.is_running() {
433 eprintln!("Daemon (PID {pid}) did not stop gracefully, force killing.");
434 daemon.signal(ipc::process::StopSignal::Kill);
435 std::thread::sleep(std::time::Duration::from_millis(200));
436 }
437
438 remove_daemon_record();
439 ipc::cleanup(&daemon_addr());
440 eprintln!("lean-ctx daemon stopped (PID {pid}).");
441
442 Ok(DaemonStop::Stopped(pid))
450}
451
452fn wait_for_exit(pid: u32) -> bool {
454 for _ in 0..30 {
455 if !ipc::process::is_alive(pid) {
456 return true;
457 }
458 std::thread::sleep(std::time::Duration::from_millis(100));
459 }
460 !ipc::process::is_alive(pid)
461}
462
463fn try_http_shutdown() -> bool {
464 let Ok(rt) = tokio::runtime::Runtime::new() else {
465 return false;
466 };
467
468 rt.block_on(async {
469 crate::daemon_client::daemon_request("POST", "/v1/shutdown", "")
470 .await
471 .is_ok()
472 })
473}
474
475pub fn daemon_status() -> String {
476 let addr = daemon_addr();
477 if let Some(pid) = read_daemon_pid() {
478 if verified_daemon().is_some() {
479 let listening = addr.is_listening();
480 return format!(
481 "Daemon running (PID {pid})\n Endpoint: {} ({})\n PID file: {}",
482 addr.display(),
483 if listening { "ready" } else { "missing" },
484 daemon_pid_path().display()
485 );
486 }
487 return format!(
488 "Daemon not running for data dir {} (stale PID file for PID {pid})",
489 data_dir().display()
490 );
491 }
492 format!("Daemon not running for data dir {}", data_dir().display())
493}
494
495fn write_pid_file(pid: u32) -> Result<()> {
496 let pid_path = daemon_pid_path();
497 if let Some(parent) = pid_path.parent() {
498 fs::create_dir_all(parent)
499 .with_context(|| format!("cannot create dir: {}", parent.display()))?;
500 }
501 crate::core::atomic_fs::try_atomic_write(&pid_path, pid.to_string().as_bytes(), None)
504 .with_context(|| format!("cannot write PID file: {}", pid_path.display()))?;
505 let identity_path = daemon_identity_path();
508 match ipc::process::identity(pid) {
509 Some(identity) => {
510 let file = DaemonIdentityFile { pid, identity };
511 if let Ok(bytes) = serde_json::to_vec(&file) {
512 let _ = crate::core::updater::atomic_write_bytes(&identity_path, &bytes);
513 }
514 }
515 None => {
516 let _ = fs::remove_file(&identity_path);
517 }
518 }
519 Ok(())
520}
521
522pub fn init_foreground_daemon() -> Result<()> {
530 mark_foreground_daemon();
531 crate::core::layout_pin::heal();
532 crate::core::agents::reaper::spawn_reaper();
536 crate::core::intelligence_runtime::cockpit::install_if_missing_in_background();
539 let pid = std::process::id();
540 write_pid_file(pid)?;
541 Ok(())
542}
543
544#[must_use]
550pub fn owned_service_pids() -> Vec<ipc::process::RecordedPid> {
551 let mut pids = Vec::new();
552 pids.extend(read_daemon_record());
553 pids.extend(crate::proxy_autostart::recorded_proxy_pid().map(ipc::process::RecordedPid::bare));
554 pids.extend(crate::core::cockpit_launch_record::read_record().map(|r| {
557 ipc::process::RecordedPid {
558 pid: r.pid,
559 identity: r.identity,
560 }
561 }));
562 pids
563}
564
565pub fn stop_owned_services(sweep: ipc::process::ServiceSweep) -> ipc::process::ServiceStopReport {
569 ipc::process::stop_services(&owned_service_pids(), sweep)
570}
571
572#[must_use]
576pub fn unrecorded_services() -> Vec<ipc::process::ServiceProcess> {
577 let recorded: Vec<u32> = owned_service_pids().iter().map(|r| r.pid).collect();
578 ipc::process::find_unrecorded_services(&recorded)
579}
580
581#[must_use]
584pub fn unrecorded_services_warning() -> Option<(String, String)> {
585 ipc::process::unrecorded_services_lines(&unrecorded_services(), cfg!(windows))
586}
587
588pub fn restore_stopped_proxy(
593 report: &ipc::process::ServiceStopReport,
594 autostart_restarted: bool,
595) -> Option<std::io::Result<u32>> {
596 let args = ipc::process::proxy_respawn_args(&report.stopped, autostart_restarted)?;
597 let binary = crate::core::portable_binary::resolve_portable_binary();
598 let mut cmd = Command::new(binary);
599 cmd.args(&args);
600 Some(
601 ipc::process::spawn_detached(&mut cmd, ipc::process::DetachedStderr::Null)
602 .map(|child| child.id()),
603 )
604}
605
606pub fn cleanup_daemon_files() {
608 remove_daemon_record();
609 ipc::cleanup(&daemon_addr());
610}
611
612#[cfg(test)]
613mod tests {
614 #[test]
617 fn stale_pid_message_is_neutral() {
618 let line = super::stale_pid_message(36492);
619 assert_eq!(
620 line,
621 "No daemon was running (stale PID file for PID 36492 removed)."
622 );
623 assert!(!line.contains("Cleaning up"));
624 }
625
626 #[test]
629 fn foreground_daemon_named_by_the_record_is_running() {
630 assert!(super::is_recorded_self(true, Some(4242), 4242));
631 assert!(!super::is_recorded_self(false, Some(4242), 4242));
634 assert!(!super::is_recorded_self(true, Some(4243), 4242));
635 assert!(!super::is_recorded_self(true, None, 4242));
636 }
637}