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
11static IS_FOREGROUND_DAEMON: AtomicBool = AtomicBool::new(false);
15
16pub fn mark_foreground_daemon() {
18 IS_FOREGROUND_DAEMON.store(true, Ordering::Relaxed);
19}
20
21#[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
35fn 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#[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#[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
83fn 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 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
123pub 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 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
151fn legacy_record_verified(pid: u32) -> bool {
154 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
173fn 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
209pub fn start_daemon_with_binary(args: &[String], binary: Option<&std::path::Path>) -> Result<()> {
211 let _start_lock = acquire_start_lock();
213
214 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 if crate::core::pathutil::process_is_tcc_standalone() {
275 cmd.env("LEAN_CTX_TCC_STANDALONE", "1");
276 }
277 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
309fn 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 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 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 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 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 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
442pub fn init_foreground_daemon() -> Result<()> {
450 mark_foreground_daemon();
451 crate::core::layout_pin::heal();
452 crate::core::agents::reaper::spawn_reaper();
456 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#[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 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
485pub fn stop_owned_services(sweep: ipc::process::ServiceSweep) -> ipc::process::ServiceStopReport {
489 ipc::process::stop_services(&owned_service_pids(), sweep)
490}
491
492#[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#[must_use]
504pub fn unrecorded_services_warning() -> Option<(String, String)> {
505 ipc::process::unrecorded_services_lines(&unrecorded_services(), cfg!(windows))
506}
507
508pub 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
526pub fn cleanup_daemon_files() {
528 remove_daemon_record();
529 ipc::cleanup(&daemon_addr());
530}
531
532#[cfg(test)]
533mod tests {
534 #[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}