1pub mod close;
16pub mod conn;
17pub mod extension_reload;
18pub mod lifecycle;
19pub mod listener;
20pub mod pin;
21pub mod pins;
22pub mod registry;
23pub mod reload;
24pub mod rename;
25#[cfg(windows)]
26pub(crate) mod win;
27
28use std::collections::HashMap;
29use std::future::Future;
30use std::path::PathBuf;
31use std::pin::Pin;
32use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33use std::sync::{Arc, Mutex};
34use std::time::{Duration, Instant};
35
36use tokio_util::sync::CancellationToken;
37
38use crate::session::{SessionConfig, SessionHandle, SessionId, SessionMeta};
39use crate::EngineHost;
40use registry::{DaemonInfo, DaemonLock, DaemonPaths};
41
42pub const EXIT_REFUSED: i32 = 3;
44pub const EXIT_VERSION: i32 = 2;
46pub const EXIT_DAEMON_LOST: i32 = 4;
49pub const READY_FD_ENV: &str = "SYNAPS_DAEMON_READY_FD";
51pub const READY_EVENT_ENV: &str = "SYNAPS_DAEMON_READY_EVENT";
54pub const EXTENSION_DISCOVERY_WAIT: Duration = Duration::from_secs(10);
58const SPAWN_READY_MARGIN: Duration = Duration::from_secs(5);
61pub const SPAWN_READY_TIMEOUT: Duration =
66 Duration::from_secs(EXTENSION_DISCOVERY_WAIT.as_secs() + SPAWN_READY_MARGIN.as_secs());
67const _: () = assert!(
70 SPAWN_READY_TIMEOUT.as_secs() > EXTENSION_DISCOVERY_WAIT.as_secs(),
71 "SPAWN_READY_TIMEOUT must exceed EXTENSION_DISCOVERY_WAIT"
72);
73
74pub fn enabled() -> bool {
77 enabled_from(std::env::var("SYNAPS_DAEMON").ok().as_deref())
78}
79
80fn env_is_off(v: Option<&str>) -> bool {
81 matches!(v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("0" | "false" | "off" | "no"))
82}
83
84fn enabled_from(v: Option<&str>) -> bool {
85 !env_is_off(v)
86}
87
88pub fn autospawn_enabled() -> bool {
92 !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
93}
94
95pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
97
98pub fn profile_mismatch(daemon: Option<&str>, client: Option<&str>) -> Option<String> {
105 let norm = |p: Option<&str>| p.filter(|s| !s.is_empty()).map(str::to_owned);
106 let (daemon, client) = (norm(daemon), norm(client));
107 if daemon == client {
108 return None;
109 }
110 let name = |p: &Option<String>| match p {
111 Some(p) => format!("profile `{p}`"),
112 None => "the default profile".to_string(),
113 };
114 let stop = match &daemon {
115 Some(p) => format!("synaps --profile {p} daemon stop"),
116 None => "synaps daemon stop".to_string(),
117 };
118 Some(format!(
119 "refusing to attach: this daemon serves {}, but this client runs {} — \
120 stop it ({stop}) or use the matching --profile",
121 name(&daemon),
122 name(&client),
123 ))
124}
125
126pub type SessionFactory = Arc<
129 dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
130 + Send
131 + Sync,
132>;
133
134#[cfg(any(test, feature = "testing"))]
136pub fn echo_factory() -> SessionFactory {
137 Arc::new(|cfg: SessionConfig| {
138 Box::pin(async move {
139 let id = SessionId(format!(
140 "echo-{}-{}",
141 chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"),
142 uuid::Uuid::new_v4().simple()
143 ));
144 let (handle, _task) = SessionHandle::echo_for_test(id);
145 let _ = cfg;
146 Ok(handle)
147 })
148 })
149}
150
151pub fn host_factory(host: &Arc<EngineHost>) -> SessionFactory {
154 let host = Arc::clone(host);
155 Arc::new(move |cfg: SessionConfig| {
156 let host = Arc::clone(&host);
157 Box::pin(async move { host.create_session(cfg).await.map_err(|e| e.to_string()) })
158 })
159}
160
161#[derive(Clone, Default)]
162pub struct DaemonOpts {
163 pub socket: Option<PathBuf>,
165 pub profile: Option<String>,
166 pub idle_exit: Option<Duration>,
171 pub allow_legacy_mcp: bool,
175 pub runtime_dir: Option<PathBuf>,
177 pub factory: Option<SessionFactory>,
179}
180
181impl DaemonOpts {
182 pub fn paths(&self) -> DaemonPaths {
183 let mut p = match &self.runtime_dir {
184 Some(d) => registry::daemon_paths_in(d, self.profile.as_deref()),
185 None => registry::daemon_paths(self.profile.as_deref()),
186 };
187 if let Some(s) = &self.socket {
188 p.sock = s.clone();
189 }
190 p
191 }
192}
193
194pub struct DaemonState {
196 pub host: Arc<EngineHost>,
197 pub paths: DaemonPaths,
198 pub profile: Option<String>,
199 pub started: Instant,
200 pub factory: SessionFactory,
201 pub connections: AtomicUsize,
202 pub shutdown: CancellationToken,
203 pub force_shutdown: AtomicBool,
204 pub generation: u64,
207 pub reloading: AtomicBool,
209 pub reload_generation: std::sync::atomic::AtomicU64,
211 pub reload_announce: Mutex<CancellationToken>,
215 pub lock_fd: Mutex<Option<DaemonLock>>,
218 pub reload_aliases: Mutex<HashMap<String, String>>,
221 pub meta: registry::Metadata,
225 pub pins: Arc<pins::PinBook>,
227 pub restoring: AtomicBool,
229}
230
231impl DaemonState {
232 pub fn uptime_s(&self) -> u64 {
233 self.started.elapsed().as_secs()
234 }
235
236 pub fn announce(&self) -> CancellationToken {
238 self.reload_announce
239 .lock()
240 .unwrap_or_else(|e| e.into_inner())
241 .clone()
242 }
243
244 pub fn fire_announce(&self) {
246 self.announce().cancel();
247 }
248
249 pub fn reset_announce(&self) {
251 *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
252 }
253
254 pub fn daemon_info(&self) -> DaemonInfo {
256 self.meta.info()
257 }
258
259 pub fn heal_daemon_json(&self) {
270 let lock = self.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
271 let Some(lock) = lock.as_ref() else {
272 return;
273 };
274 if self.shutdown.is_cancelled() {
275 return;
276 }
277 match self.meta.heal(lock) {
278 Ok(registry::Healed::Yes { info, log: Some(heals) }) => tracing::info!(
279 path = %self.paths.json.display(),
280 pid = info.pid,
281 generation = info.generation,
282 heals,
283 "daemon: daemon.json rewritten (it was missing or named another pid)"
284 ),
285 Ok(_) => {}
286 Err(e) => tracing::debug!(error = %e, "daemon: daemon.json heal failed"),
287 }
288 }
289
290 pub fn live_sessions(&self) -> Vec<SessionHandle> {
292 self.host.session_handles()
293 }
294
295 pub fn session_metas(&self) -> Vec<SessionMeta> {
298 self.host.sessions()
299 }
300
301 pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
306 self.host.attach(id).filter(|h| h.is_alive()).or_else(|| {
307 let alias = self
308 .reload_aliases
309 .lock()
310 .unwrap_or_else(|e| e.into_inner())
311 .get(id.as_str())
312 .cloned()?;
313 self.host
314 .attach(&SessionId::from(alias.as_str()))
315 .filter(|h| h.is_alive())
316 })
317 }
318
319 pub fn insert(&self, handle: SessionHandle) {
320 self.host.adopt_session(handle);
321 }
322
323 pub fn remove(&self, id: &SessionId) {
324 self.host.remove_session(id);
325 }
326
327 pub async fn create(&self, cfg: SessionConfig) -> Result<SessionHandle, String> {
328 let handle = (self.factory)(cfg).await?;
329 self.insert(handle.clone());
330 Ok(handle)
331 }
332
333 pub async fn retry_locked_placeholder(&self, placeholder: SessionHandle) -> SessionHandle {
340 let cfg = SessionConfig {
341 continue_session: Some(Some(placeholder.id.as_str().to_string())),
342 ..placeholder.placeholder_config().cloned().unwrap_or_default()
343 };
344 self.remove(&placeholder.id);
345 match (self.factory)(cfg).await {
346 Ok(real) if real.id == placeholder.id || real.journal_id() == placeholder.id.as_str() => {
347 tracing::info!(session = %placeholder.id, "daemon: locked journal released — placeholder replaced by the real session");
348 self.insert(real.clone());
349 real
350 }
351 Ok(other) => {
352 tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
354 self.insert(other.clone());
355 other
356 }
357 Err(e) => {
358 tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
359 self.insert(placeholder.clone());
360 placeholder
361 }
362 }
363 }
364
365 pub fn request_shutdown(&self, force: bool) {
366 if force {
367 self.force_shutdown.store(true, Ordering::SeqCst);
368 }
369 self.shutdown.cancel();
370 }
371}
372
373pub struct Daemon {
375 pub paths: DaemonPaths,
376 pub state: Arc<DaemonState>,
377 accept: tokio::task::JoinHandle<()>,
378}
379
380impl Daemon {
381 pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
384 let paths = opts.paths();
385 std::fs::create_dir_all(&paths.dir)?;
386 #[cfg(unix)]
387 {
388 use std::os::unix::fs::PermissionsExt;
389 std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
390 }
391 let adopted = reload::adopt_from_env(&paths)?;
394 let (lock, reload_state) = match adopted {
395 Some((lock, rs, rs_path)) => {
396 let _ = std::fs::remove_file(&rs_path);
397 (lock, Some(rs))
398 }
399 None => {
400 registry::reap_stale(&paths);
401 let lock = match DaemonLock::try_acquire(&paths)? {
402 Some(l) => l,
403 None => {
404 let who = registry::read_daemon_json(&paths).map(|i| i.pid);
405 anyhow::bail!(
406 "another daemon holds {} (pid {})",
407 paths.lock.display(),
408 who.map_or("unknown".to_string(), |p| p.to_string())
409 );
410 }
411 };
412 (lock, None)
413 }
414 };
415 let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
416 let listener = listener::bind(&paths.sock)?;
417
418 let info = DaemonInfo {
419 pid: std::process::id(),
420 protocol_version: crate::session::wire::PROTOCOL_VERSION,
421 daemon_version: crate::session::wire::binary_version(),
422 profile: opts.profile.clone(),
423 started_at: chrono::Utc::now(),
424 socket: paths.sock.to_string_lossy().into_owned(),
425 exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
426 generation,
427 };
428 let meta = registry::Metadata::start(paths.clone(), info.clone())?;
429
430 let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
431 let state = Arc::new(DaemonState {
432 host,
433 paths: paths.clone(),
434 profile: opts.profile.clone(),
435 started: Instant::now(),
436 factory,
437 connections: AtomicUsize::new(0),
438 shutdown: CancellationToken::new(),
439 force_shutdown: AtomicBool::new(false),
440 generation,
441 reloading: AtomicBool::new(false),
442 reload_generation: std::sync::atomic::AtomicU64::new(generation),
443 reload_announce: Mutex::new(CancellationToken::new()),
444 lock_fd: Mutex::new(Some(lock)),
445 reload_aliases: Mutex::new(HashMap::new()),
446 meta,
447 pins: Arc::new(pins::PinBook::for_active_profile()),
448 restoring: AtomicBool::new(false),
449 });
450 state.host.set_pin_sink(Arc::clone(&state.pins) as Arc<dyn crate::session::PinSink>);
451
452 let own_pid = reload_state.as_ref().map(|_| info.pid);
460 let swept = crate::events::registry::sweep_stale_registrations(own_pid);
461 if swept > 0 {
462 tracing::info!(swept, "daemon: removed stale session registrations");
463 }
464
465 if let Some(rs) = &reload_state {
467 reload::rehydrate(&state, rs).await;
468 }
469
470 let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
471 if let Some(idle) = opts.idle_exit {
472 tokio::spawn(idle_monitor(Arc::clone(&state), idle));
473 }
474 signal_ready();
475 if reload_state.is_none() && pins::restore_enabled() {
477 tokio::spawn(pins::restore_all(Arc::clone(&state)));
478 }
479 tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
480 Ok(Self { paths, state, accept })
481 }
482
483 pub fn shutdown_token(&self) -> CancellationToken {
484 self.state.shutdown.clone()
485 }
486
487 pub async fn wait(self) {
490 self.state.shutdown.cancelled().await;
491 let force = self.state.force_shutdown.load(Ordering::SeqCst);
492 lifecycle::shutdown_all(&self.state, force).await;
493 self.accept.abort();
494 let _ = self.accept.await;
495 crate::events::socket::cleanup_socket(&self.paths.sock.to_string_lossy());
496 {
497 let lock = self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
498 if let Some(lock) = lock.as_ref() {
499 self.state.meta.cleanup(lock);
500 }
501 }
502 tracing::info!("daemon: stopped");
503 drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
505 }
506}
507
508const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
510
511pub async fn session_is_idle(handle: &SessionHandle) -> bool {
517 use crate::session::wire::IDLE_PROBE_QUERY_ID;
518 use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
519 if handle.lifecycle() == SessionLifecycle::Parked {
521 return true;
522 }
523 let mut rx = handle.subscribe();
524 if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
525 return false;
526 }
527 let probe = async {
528 loop {
529 match rx.recv().await {
530 Ok(env) => {
531 if let SessionEventWire::QueryResult { id, value } = env.event {
532 if id == IDLE_PROBE_QUERY_ID {
533 let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
536 let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
537 return !streaming && prompts == 0;
538 }
539 }
540 }
541 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
542 Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
543 }
544 }
545 };
546 tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
547}
548
549pub async fn daemon_is_idle(state: &DaemonState) -> bool {
554 if state.connections.load(Ordering::SeqCst) > 0 {
555 return false;
556 }
557 if state.restoring.load(Ordering::SeqCst) {
558 return false;
559 }
560 let sessions = state.live_sessions();
561 if sessions.iter().any(|h| h.pinned() && h.is_alive()) {
562 return false;
563 }
564 for h in sessions {
565 if !session_is_idle(&h).await {
566 return false;
567 }
568 }
569 true
570}
571
572async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
573 let mut idle_since: Option<Instant> = None;
574 let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
575 loop {
576 tokio::select! {
577 _ = state.shutdown.cancelled() => return,
578 _ = tokio::time::sleep(tick) => {}
579 }
580 if !daemon_is_idle(&state).await {
581 idle_since = None;
582 continue;
583 }
584 let since = *idle_since.get_or_insert_with(Instant::now);
585 if since.elapsed() >= idle {
586 tracing::info!("daemon: idle for {:?}, exiting", idle);
587 state.request_shutdown(false);
588 return;
589 }
590 }
591}
592
593pub fn signal_ready() {
596 #[cfg(unix)]
597 {
598 let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
599 return;
600 };
601 std::env::remove_var(READY_FD_ENV);
602 unsafe {
604 let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
605 libc::close(fd);
606 }
607 }
608 #[cfg(windows)]
609 {
610 let Ok(name) = std::env::var(READY_EVENT_ENV) else {
611 return;
612 };
613 std::env::remove_var(READY_EVENT_ENV);
614 if let Err(e) = win::signal_event(&name) {
615 tracing::warn!(error = %e, "daemon: could not signal the spawner's ready event");
616 }
617 win::detach_stdio();
619 }
620}
621
622#[cfg(unix)]
626fn daemon_launch(
627 effective_env: &[(std::ffi::OsString, std::ffi::OsString)],
628 exe: PathBuf,
629 cmd_args: Vec<std::ffi::OsString>,
630) -> (std::ffi::OsString, Vec<std::ffi::OsString>) {
631 let kind = crate::process_scope::ScopeKind::Daemon;
632 match crate::process_scope::launcher_for_effective_env(effective_env, None, kind) {
633 Some(launcher) => launcher.wrap("synaps daemon", exe, cmd_args),
634 None => (exe.into_os_string(), cmd_args),
635 }
636}
637
638#[cfg(unix)]
643pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
644 use std::io::Read;
645 use std::os::unix::process::CommandExt;
646
647 let exe = std::env::current_exe()?;
648
649
650 let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
651 if let Some(p) = &opts.profile {
652 cmd_args.extend(["--profile".into(), p.into()]);
653 }
654 if let Some(s) = &opts.socket {
655 cmd_args.extend(["--socket".into(), s.into()]);
656 }
657 if let Some(i) = opts.idle_exit {
658 cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
659 }
660 if opts.allow_legacy_mcp {
661 cmd_args.push("--allow-legacy-mcp".into());
662 }
663 let mut effective_env = crate::process_scope::inherited_env();
666 crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
667 effective_env.retain(|(k, _)| k != READY_FD_ENV);
668 let (program, cmd_args) = daemon_launch(&effective_env, exe, cmd_args);
669 let mut fds = [0i32; 2];
670 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
672 return Err(std::io::Error::last_os_error().into());
673 }
674 let (read_fd, write_fd) = (fds[0], fds[1]);
675 for fd in [read_fd, write_fd] {
677 if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
678 let error = std::io::Error::last_os_error();
679 unsafe { libc::close(read_fd); libc::close(write_fd); }
680 return Err(error.into());
681 }
682 }
683 crate::process_scope::set_env(&mut effective_env, READY_FD_ENV, write_fd.to_string());
684 let mut cmd = std::process::Command::new(program);
685 cmd.args(cmd_args);
686 cmd.env_clear().envs(effective_env)
687 .stdin(std::process::Stdio::null())
688 .stdout(std::process::Stdio::null())
689 .stderr(std::process::Stdio::piped());
690 unsafe {
692 cmd.pre_exec(move || {
693 libc::close(read_fd);
694 let flags = libc::fcntl(write_fd, libc::F_GETFD);
696 if flags >= 0 {
697 libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
698 }
699 libc::setsid();
700 Ok(())
701 });
702 }
703 let mut child = match cmd.spawn() {
704 Ok(child) => child,
705 Err(error) => {
706 unsafe { libc::close(read_fd); libc::close(write_fd); }
707 return Err(error.into());
708 }
709 };
710 unsafe { libc::close(write_fd) };
712 let pid = child.id();
713
714 let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
716 let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
717 let ready = loop {
718 let left = deadline.saturating_duration_since(Instant::now());
719 if left.is_zero() {
720 break false;
721 }
722 let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
724 if n < 0 {
725 let e = std::io::Error::last_os_error();
726 if e.kind() == std::io::ErrorKind::Interrupted {
727 continue;
728 }
729 break false;
730 }
731 if n == 0 {
732 break false;
733 }
734 let mut byte = [0u8; 1];
735 let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
737 break r == 1 && byte[0] == b'R';
738 };
739 unsafe { libc::close(read_fd) };
741
742 if ready {
743 if let Some(mut err) = child.stderr.take() {
745 std::thread::spawn(move || {
746 let mut sink = Vec::new();
747 let _ = err.read_to_end(&mut sink);
748 });
749 }
750 drop(child);
751 return Ok(pid);
752 }
753 let status = child.try_wait().ok().flatten().or_else(|| {
755 let _ = child.kill();
756 child.wait().ok()
757 });
758 let mut tail = String::new();
759 if let Some(mut err) = child.stderr.take() {
760 use std::os::fd::AsRawFd;
761 let fd = err.as_raw_fd();
762 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
763 if flags >= 0 && unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } >= 0 {
764 let mut bytes = Vec::new();
767 let _ = err.by_ref().take(64 * 1024).read_to_end(&mut bytes);
768 tail = String::from_utf8_lossy(&bytes).into_owned();
769 }
770 }
771 let reason = tail
772 .lines()
773 .map(str::trim)
774 .rfind(|l| !l.is_empty())
775 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
776 match (status, reason) {
777 (Some(st), Some(r)) if st.code().is_some() => {
778 anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
779 }
780 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
781 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
782 }
783}
784
785#[cfg(windows)]
791pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
792 let exe = std::env::current_exe()?;
793 let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
794 if let Some(p) = &opts.profile {
795 cmd_args.extend(["--profile".into(), p.into()]);
796 }
797 if let Some(s) = &opts.socket {
798 cmd_args.extend(["--socket".into(), s.into()]);
799 }
800 if let Some(i) = opts.idle_exit {
801 cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
802 }
803 if opts.allow_legacy_mcp {
804 cmd_args.push("--allow-legacy-mcp".into());
805 }
806 let mut effective_env = crate::process_scope::inherited_env();
807 crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
808 effective_env.retain(|(k, _)| k != READY_FD_ENV && k != READY_EVENT_ENV);
809 let ready = win::Event::create_unique("ready")?;
810 crate::process_scope::set_env(&mut effective_env, READY_EVENT_ENV, ready.name());
811 let mut child = win::spawn_detached(&exe, &cmd_args, &effective_env)?;
812 let pid = child.id();
813
814 if win::wait_event_or_exit(&ready, &child, SPAWN_READY_TIMEOUT) == win::Wait::Signalled {
815 if let Some(mut err) = child.take_stderr() {
817 std::thread::spawn(move || {
818 let mut sink = Vec::new();
819 let _ = std::io::Read::read_to_end(&mut err, &mut sink);
820 });
821 }
822 return Ok(pid);
823 }
824 let exited = child.wait(Duration::ZERO);
827 if exited.is_none() {
828 child.kill();
829 let _ = child.wait(Duration::from_secs(5));
830 }
831 let tail = win::stderr_tail(child.take_stderr(), 64 * 1024, Duration::from_millis(500));
832 let reason = tail
833 .lines()
834 .map(str::trim)
835 .rfind(|l| !l.is_empty())
836 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
837 match (exited, reason) {
838 (Some(code), Some(r)) => anyhow::bail!("daemon exited (code {code}): {r}"),
839 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
840 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
841 }
842}
843
844#[derive(Debug, Clone, PartialEq, Eq)]
846pub enum Ensured {
847 Running,
849 Spawned(u32),
851}
852
853#[derive(Debug, Clone, PartialEq, Eq)]
855pub enum EnsureError {
856 AutospawnDisabled,
858 Spawn(String),
860}
861
862impl std::fmt::Display for EnsureError {
863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864 match self {
865 Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
866 Self::Spawn(r) => write!(f, "{r}"),
867 }
868 }
869}
870
871const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
874
875pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
885 ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
886}
887
888fn ensure_running_with(
891 opts: &DaemonOpts,
892 auto: bool,
893 spawn: impl FnOnce(&DaemonOpts) -> anyhow::Result<u32>,
894 before_spawn_lock: impl FnOnce(),
895) -> Result<Ensured, EnsureError> {
896 let paths = opts.paths();
897 if !auto {
898 registry::reap_stale(&paths);
900 return if registry::is_alive(&paths) {
901 Ok(Ensured::Running)
902 } else {
903 Err(EnsureError::AutospawnDisabled)
904 };
905 }
906 let _ = std::fs::create_dir_all(&paths.dir);
907 before_spawn_lock();
908 let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
909 .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
910 registry::reap_stale(&paths);
914 if registry::is_alive(&paths) {
915 return Ok(Ensured::Running);
916 }
917 let mut opts = opts.clone();
922 if opts.idle_exit.is_none() {
923 opts.idle_exit = autospawn_idle_exit();
924 }
925 spawn(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
926}
927
928pub fn autospawn_idle_exit() -> Option<Duration> {
934 let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
936 autospawn_idle_exit_from(
937 std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
938 cfg_secs,
939 )
940}
941
942fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
943 let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
946 match v.map(str::trim) {
947 Some("0" | "never" | "off") => None,
948 Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
949 None => from_cfg(),
950 }
951}
952
953pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
955
956
957pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
961 if !enabled() {
962 anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
963 }
964 #[cfg(windows)]
968 reload::take_over_from_env(&opts.paths())?;
969 let host = EngineHost::boot_and_install(crate::HostOpts {
972 profile: opts.profile.clone(),
973 no_extensions: false,
974 mcp_discovery: crate::McpDiscovery::Background,
975 })
976 .await?;
977 host.report_config_warnings();
978 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
981 let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
982 let wait = async {
983 while let Some(ev) = rx.recv().await {
984 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
985 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
986 break;
987 }
988 }
989 };
990 if tokio::time::timeout(EXTENSION_DISCOVERY_WAIT, wait).await.is_err() {
991 tracing::warn!(
992 wait = ?EXTENSION_DISCOVERY_WAIT,
993 "daemon: extension discovery still running; accepting anyway"
994 );
995 }
996 let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
999
1000 let mut opts = opts;
1001 if opts.factory.is_none() {
1002 opts.factory = Some(host_factory(&host));
1003 }
1004 let mcp_host = Arc::clone(&host);
1005 let daemon = Daemon::start(host, opts).await?;
1006 let _mcp_discovery = mcp_host.spawn_background_mcp_discovery();
1010 drop(mcp_host);
1011 let state = Arc::clone(&daemon.state);
1012 tokio::spawn(async move {
1013 #[cfg(unix)]
1014 {
1015 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
1016 tokio::select! {
1017 _ = tokio::signal::ctrl_c() => {}
1018 _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
1019 _ = state.shutdown.cancelled() => return,
1020 }
1021 }
1022 #[cfg(not(unix))]
1023 {
1024 tokio::select! {
1025 _ = tokio::signal::ctrl_c() => {}
1026 _ = state.shutdown.cancelled() => return,
1027 }
1028 }
1029 tracing::info!("daemon: signal received, shutting down");
1030 state.request_shutdown(false);
1031 });
1032 daemon.wait().await;
1033 Ok(())
1034}
1035
1036#[cfg(test)]
1037mod flag_tests {
1038 use super::*;
1039
1040 #[test]
1041 fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
1042 let d = AUTOSPAWN_IDLE_EXIT.as_secs();
1044 assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
1045 assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
1046 assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
1047 assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
1048 for never in ["0", "never", "off"] {
1049 assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
1050 }
1051 assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
1053 assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
1055 assert_eq!(autospawn_idle_exit_from(None, 0), None);
1057 assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
1059 }
1060
1061 #[test]
1062 fn enabled_env_matrix() {
1063 assert!(enabled_from(None), "unset → on");
1064 for on in ["1", "true", "yes", "on", "", "anything"] {
1065 assert!(enabled_from(Some(on)), "{on:?} → on");
1066 }
1067 for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
1068 assert!(!enabled_from(Some(off)), "{off:?} → off");
1069 }
1070 }
1071
1072 #[test]
1073 fn profile_mismatch_refuses_a_daemon_of_another_profile() {
1074 assert_eq!(profile_mismatch(None, None), None);
1075 assert_eq!(profile_mismatch(Some("axon"), Some("axon")), None);
1076 assert_eq!(profile_mismatch(Some(""), None), None);
1078 assert_eq!(profile_mismatch(None, Some("")), None);
1079 let m = profile_mismatch(None, Some("axon")).expect("mismatch");
1081 assert!(m.contains("the default profile") && m.contains("profile `axon`"), "{m}");
1082 assert!(m.contains("synaps daemon stop"), "{m}");
1083 let m = profile_mismatch(Some("axon"), None).expect("mismatch");
1084 assert!(m.contains("synaps --profile axon daemon stop"), "{m}");
1085 assert!(profile_mismatch(Some("a.b"), Some("a_b")).is_some());
1087 }
1088
1089 #[test]
1090 fn env_is_off_is_case_and_space_insensitive() {
1091 assert!(env_is_off(Some("NO")));
1092 assert!(!env_is_off(Some("1")));
1093 assert!(!env_is_off(None));
1094 }
1095}
1096
1097#[cfg(all(test, target_os = "linux"))]
1098mod scope_tests {
1099 use super::*;
1100 use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
1101
1102 fn launch(env: &[(String, String)]) -> (std::ffi::OsString, Vec<String>) {
1103 let env: Vec<(std::ffi::OsString, std::ffi::OsString)> =
1104 env.iter().map(|(k, v)| (k.into(), v.into())).collect();
1105 let exe = PathBuf::from("/opt/synaps/bin/synaps");
1106 let (program, args) = daemon_launch(&env, exe, vec!["daemon".into(), "--foreground".into()]);
1107 (program, args.iter().map(|a| a.to_string_lossy().into_owned()).collect())
1108 }
1109
1110 #[test]
1113 #[serial_test::serial(synaps_base_dir)]
1114 fn the_daemon_scope_goes_in_the_daemon_slice_only() {
1115 let base = crate::test_env::BaseDirGuard::new();
1116 let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
1117 let dir = tempfile::tempdir().unwrap();
1118 let fake = fake_systemd_run(dir.path());
1119 let env = fake_env(&fake);
1120 let today = [
1121 "--user",
1122 "--scope",
1123 "--quiet",
1124 "--collect",
1125 "--description=synaps daemon",
1126 "--",
1127 "/opt/synaps/bin/synaps",
1128 "daemon",
1129 "--foreground",
1130 ];
1131 let (program, args) = launch(&env);
1132 assert_eq!(program, fake.clone().into_os_string());
1133 assert_eq!(args, today);
1134 let config = base.path().join("config");
1135 std::fs::write(&config, "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1136 let (program, args) = launch(&env);
1137 assert_eq!(program, fake.clone().into_os_string());
1138 assert_eq!(
1139 args,
1140 [
1141 "--user",
1142 "--scope",
1143 "--quiet",
1144 "--collect",
1145 "--slice=d_daemon.slice",
1146 "--description=synaps daemon",
1147 "--",
1148 "/opt/synaps/bin/synaps",
1149 "daemon",
1150 "--foreground",
1151 ]
1152 );
1153 assert!(recorded(dir.path(), "probe.args").contains(&"--slice=d_daemon.slice".to_string()));
1154 std::fs::write(&config, "tool_scope_slice = t_tools.slice\n").unwrap();
1156 assert_eq!(launch(&env).1, today);
1157 std::fs::write(&config, "process_scopes = off\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1159 let (program, args) = launch(&env);
1160 assert_eq!(program, std::ffi::OsString::from("/opt/synaps/bin/synaps"));
1161 assert_eq!(args, ["daemon", "--foreground"]);
1162 }
1163}
1164
1165#[cfg(test)]
1166mod ensure_tests {
1167 use super::*;
1168 use std::sync::mpsc;
1169
1170 const WAIT: Duration = Duration::from_secs(3);
1171 const BLOCKED: Duration = Duration::from_millis(100);
1172
1173 fn serialization_case(stale_metadata: bool) {
1174 let dir = tempfile::tempdir().unwrap();
1175 let opts = DaemonOpts {
1176 runtime_dir: Some(dir.path().to_path_buf()),
1177 profile: Some("ensure-serialization".into()),
1178 idle_exit: Some(Duration::from_secs(1)),
1180 ..Default::default()
1181 };
1182 let paths = opts.paths();
1183 let spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1184 let cleanup_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1185 if stale_metadata {
1186 std::fs::write(&paths.json, b"stale metadata").unwrap();
1187 std::fs::write(&paths.pid, b"0\n").unwrap();
1188 }
1189 let count = Arc::new(AtomicUsize::new(0));
1190 let ready = Arc::new(AtomicBool::new(false));
1191 let (attempt_tx, attempt_rx) = mpsc::channel();
1192 let (daemon_tx, daemon_rx) = mpsc::channel();
1193 let (ready_tx, ready_rx) = mpsc::channel();
1194 let (done_tx, done_rx) = mpsc::channel();
1195 let first_opts = opts.clone();
1196 let first_count = Arc::clone(&count);
1197 let first_ready = Arc::clone(&ready);
1198 let first = std::thread::spawn(move || {
1199 let result = ensure_running_with(&first_opts, true, |opts| {
1200 first_count.fetch_add(1, Ordering::SeqCst);
1201 let paths = opts.paths();
1202 assert!(!paths.json.exists());
1204 assert!(!paths.pid.exists());
1205 let lock = DaemonLock::try_acquire(&paths)?.expect("daemon lock free after reap");
1206 let pid = std::process::id();
1207 registry::write_daemon_json(&paths, &DaemonInfo {
1208 pid,
1209 protocol_version: 1,
1210 daemon_version: "test".into(),
1211 profile: opts.profile.clone(),
1212 started_at: chrono::Utc::now(),
1213 socket: paths.sock.display().to_string(),
1214 exe: None,
1215 generation: 1,
1216 })?;
1217 daemon_tx.send(lock).unwrap();
1219 ready_rx.recv_timeout(WAIT).unwrap();
1220 first_ready.store(true, Ordering::SeqCst);
1221 Ok(pid)
1222 }, || attempt_tx.send(()).unwrap());
1223 done_tx.send(result).unwrap();
1224 });
1225 attempt_rx.recv_timeout(WAIT).unwrap();
1227 assert_eq!(done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1228 assert_eq!(count.load(Ordering::SeqCst), 0);
1229 assert_eq!(paths.json.exists(), stale_metadata);
1230 assert_eq!(paths.pid.exists(), stale_metadata);
1231 drop(cleanup_lock);
1232 drop(spawn_lock);
1233 let daemon_lock = daemon_rx.recv_timeout(WAIT).unwrap();
1234
1235 let (second_attempt_tx, second_attempt_rx) = mpsc::channel();
1238 let (second_done_tx, second_done_rx) = mpsc::channel();
1239 let second_count = Arc::clone(&count);
1240 let second_ready = Arc::clone(&ready);
1241 let second = std::thread::spawn(move || {
1242 let result = ensure_running_with(&opts, true, |_| {
1243 second_count.fetch_add(1, Ordering::SeqCst);
1244 panic!("warm attach must not spawn");
1245 }, || second_attempt_tx.send(()).unwrap());
1246 second_done_tx.send((result, second_ready.load(Ordering::SeqCst))).unwrap();
1247 });
1248 second_attempt_rx.recv_timeout(WAIT).unwrap();
1249 assert_eq!(second_done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1250 assert!(!ready.load(Ordering::SeqCst));
1251 ready_tx.send(()).unwrap();
1252 assert_eq!(done_rx.recv_timeout(WAIT).unwrap(), Ok(Ensured::Spawned(std::process::id())));
1253 assert_eq!(second_done_rx.recv_timeout(WAIT).unwrap(), (Ok(Ensured::Running), true));
1254 first.join().unwrap();
1255 second.join().unwrap();
1256 assert_eq!(count.load(Ordering::SeqCst), 1);
1257 assert_eq!(registry::read_daemon_json(&paths).unwrap().pid, std::process::id());
1258 drop(daemon_lock);
1259 }
1260
1261 #[test]
1262 fn ensure_serializes_before_probe_with_missing_metadata() {
1263 serialization_case(false);
1264 }
1265
1266 #[test]
1267 fn ensure_serializes_before_probe_with_stale_metadata() {
1268 serialization_case(true);
1269 }
1270
1271 #[test]
1272 fn disabled_ensure_keeps_warm_attach_without_spawn_lock() {
1273 let dir = tempfile::tempdir().unwrap();
1274 let opts = DaemonOpts {
1275 runtime_dir: Some(dir.path().to_path_buf()),
1276 ..Default::default()
1277 };
1278 let paths = opts.paths();
1279 let _spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1280 let daemon_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1281 let ensure = || ensure_running_with(&opts, false,
1282 |_| panic!("disabled must not spawn"),
1283 || panic!("disabled must not attempt spawn lock"));
1284 assert_eq!(ensure(), Ok(Ensured::Running));
1285 drop(daemon_lock);
1286 std::fs::write(&paths.json, b"stale").unwrap();
1287 std::fs::write(&paths.pid, b"0\n").unwrap();
1288 assert_eq!(ensure(), Err(EnsureError::AutospawnDisabled));
1289 assert!(!paths.json.exists());
1290 assert!(!paths.pid.exists());
1291 }
1292}