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 if real.is_owned() {
351 arm_owner_rebind(&real).await;
352 }
353 real
354 }
355 Ok(other) => {
356 tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
358 self.insert(other.clone());
359 other
360 }
361 Err(e) => {
362 tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
363 self.insert(placeholder.clone());
364 placeholder
365 }
366 }
367 }
368
369 pub fn request_shutdown(&self, force: bool) {
370 if force {
371 self.force_shutdown.store(true, Ordering::SeqCst);
372 }
373 self.shutdown.cancel();
374 }
375}
376
377pub struct Daemon {
379 pub paths: DaemonPaths,
380 pub state: Arc<DaemonState>,
381 accept: tokio::task::JoinHandle<()>,
382}
383
384impl Daemon {
385 pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
388 let paths = opts.paths();
389 std::fs::create_dir_all(&paths.dir)?;
390 #[cfg(unix)]
391 {
392 use std::os::unix::fs::PermissionsExt;
393 std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
394 }
395 let adopted = reload::adopt_from_env(&paths)?;
398 let (lock, reload_state) = match adopted {
399 Some((lock, rs, rs_path)) => {
400 let _ = std::fs::remove_file(&rs_path);
401 (lock, Some(rs))
402 }
403 None => {
404 registry::reap_stale(&paths);
405 let lock = match DaemonLock::try_acquire(&paths)? {
406 Some(l) => l,
407 None => {
408 let who = registry::read_daemon_json(&paths).map(|i| i.pid);
409 anyhow::bail!(
410 "another daemon holds {} (pid {})",
411 paths.lock.display(),
412 who.map_or("unknown".to_string(), |p| p.to_string())
413 );
414 }
415 };
416 (lock, None)
417 }
418 };
419 let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
420 let listener = listener::bind(&paths.sock)?;
421
422 let info = DaemonInfo {
423 pid: std::process::id(),
424 protocol_version: crate::session::wire::PROTOCOL_VERSION,
425 daemon_version: crate::session::wire::binary_version(),
426 profile: opts.profile.clone(),
427 started_at: chrono::Utc::now(),
428 socket: paths.sock.to_string_lossy().into_owned(),
429 exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
430 generation,
431 };
432 let meta = registry::Metadata::start(paths.clone(), info.clone())?;
433
434 let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
435 let state = Arc::new(DaemonState {
436 host,
437 paths: paths.clone(),
438 profile: opts.profile.clone(),
439 started: Instant::now(),
440 factory,
441 connections: AtomicUsize::new(0),
442 shutdown: CancellationToken::new(),
443 force_shutdown: AtomicBool::new(false),
444 generation,
445 reloading: AtomicBool::new(false),
446 reload_generation: std::sync::atomic::AtomicU64::new(generation),
447 reload_announce: Mutex::new(CancellationToken::new()),
448 lock_fd: Mutex::new(Some(lock)),
449 reload_aliases: Mutex::new(HashMap::new()),
450 meta,
451 pins: Arc::new(pins::PinBook::for_active_profile()),
452 restoring: AtomicBool::new(false),
453 });
454 state.host.set_pin_sink(Arc::clone(&state.pins) as Arc<dyn crate::session::PinSink>);
455
456 let own_pid = reload_state.as_ref().map(|_| info.pid);
464 let swept = crate::events::registry::sweep_stale_registrations(own_pid);
465 if swept > 0 {
466 tracing::info!(swept, "daemon: removed stale session registrations");
467 }
468
469 if let Some(rs) = &reload_state {
471 reload::rehydrate(&state, rs).await;
472 }
473
474 let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
475 if reload_state.is_some() {
479 for h in state.live_sessions().into_iter().filter(|h| h.is_owned()) {
480 arm_owner_rebind(&h).await;
481 }
482 }
483 if let Some(idle) = opts.idle_exit {
484 tokio::spawn(idle_monitor(Arc::clone(&state), idle));
485 }
486 signal_ready();
487 if reload_state.is_none() && pins::restore_enabled() {
489 tokio::spawn(pins::restore_all(Arc::clone(&state)));
490 }
491 tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
492 Ok(Self { paths, state, accept })
493 }
494
495 pub fn shutdown_token(&self) -> CancellationToken {
496 self.state.shutdown.clone()
497 }
498
499 pub async fn wait(self) {
502 self.state.shutdown.cancelled().await;
503 let force = self.state.force_shutdown.load(Ordering::SeqCst);
504 lifecycle::shutdown_all(&self.state, force).await;
505 self.accept.abort();
506 let _ = self.accept.await;
507 crate::events::socket::cleanup_socket(&self.paths.sock.to_string_lossy());
508 {
509 let lock = self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
510 if let Some(lock) = lock.as_ref() {
511 self.state.meta.cleanup(lock);
512 }
513 }
514 tracing::info!("daemon: stopped");
515 drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
517 }
518}
519
520const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
522
523pub async fn session_is_idle(handle: &SessionHandle) -> bool {
529 use crate::session::wire::IDLE_PROBE_QUERY_ID;
530 use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
531 if handle.lifecycle() == SessionLifecycle::Parked {
533 return true;
534 }
535 let mut rx = handle.subscribe();
536 if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
537 return false;
538 }
539 let probe = async {
540 loop {
541 match rx.recv().await {
542 Ok(env) => {
543 if let SessionEventWire::QueryResult { id, value } = env.event {
544 if id == IDLE_PROBE_QUERY_ID {
545 let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
548 let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
549 return !streaming && prompts == 0;
550 }
551 }
552 }
553 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
554 Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
555 }
556 }
557 };
558 tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
559}
560
561pub async fn daemon_is_idle(state: &DaemonState) -> bool {
566 if state.connections.load(Ordering::SeqCst) > 0 {
567 return false;
568 }
569 if state.restoring.load(Ordering::SeqCst) {
570 return false;
571 }
572 let sessions = state.live_sessions();
573 if sessions.iter().any(|h| h.pinned() && h.is_alive()) {
574 return false;
575 }
576 for h in sessions {
577 if !session_is_idle(&h).await {
578 return false;
579 }
580 }
581 true
582}
583
584async fn arm_owner_rebind(h: &crate::session::SessionHandle) {
588 let deadline = tokio::time::Instant::now() + close::CLOSE_SEND_RETRY;
589 loop {
590 match h.send(crate::session::SessionCommand::ArmOwnerRebind).await {
591 Err(crate::session::TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
592 tokio::time::sleep(Duration::from_millis(20)).await;
593 }
594 Err(crate::session::TransportError::Backpressure) => {
595 tracing::warn!(session = %h.id, "daemon: owned session's queue stayed full; its fallback deadline applies");
596 return;
597 }
598 _ => return,
599 }
600 }
601}
602
603async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
604 let mut idle_since: Option<Instant> = None;
605 let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
606 loop {
607 tokio::select! {
608 _ = state.shutdown.cancelled() => return,
609 _ = tokio::time::sleep(tick) => {}
610 }
611 if !daemon_is_idle(&state).await {
612 idle_since = None;
613 continue;
614 }
615 let since = *idle_since.get_or_insert_with(Instant::now);
616 if since.elapsed() >= idle {
617 tracing::info!("daemon: idle for {:?}, exiting", idle);
618 state.request_shutdown(false);
619 return;
620 }
621 }
622}
623
624pub fn signal_ready() {
627 #[cfg(unix)]
628 {
629 let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
630 return;
631 };
632 std::env::remove_var(READY_FD_ENV);
633 unsafe {
635 let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
636 libc::close(fd);
637 }
638 }
639 #[cfg(windows)]
640 {
641 let Ok(name) = std::env::var(READY_EVENT_ENV) else {
642 return;
643 };
644 std::env::remove_var(READY_EVENT_ENV);
645 if let Err(e) = win::signal_event(&name) {
646 tracing::warn!(error = %e, "daemon: could not signal the spawner's ready event");
647 }
648 win::detach_stdio();
650 }
651}
652
653#[cfg(unix)]
657fn daemon_launch(
658 effective_env: &[(std::ffi::OsString, std::ffi::OsString)],
659 exe: PathBuf,
660 cmd_args: Vec<std::ffi::OsString>,
661) -> (std::ffi::OsString, Vec<std::ffi::OsString>) {
662 let kind = crate::process_scope::ScopeKind::Daemon;
663 match crate::process_scope::launcher_for_effective_env(effective_env, None, kind) {
664 Some(launcher) => launcher.wrap("synaps daemon", exe, cmd_args),
665 None => (exe.into_os_string(), cmd_args),
666 }
667}
668
669#[cfg(unix)]
674pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
675 use std::io::Read;
676 use std::os::unix::process::CommandExt;
677
678 let exe = std::env::current_exe()?;
679
680
681 let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
682 if let Some(p) = &opts.profile {
683 cmd_args.extend(["--profile".into(), p.into()]);
684 }
685 if let Some(s) = &opts.socket {
686 cmd_args.extend(["--socket".into(), s.into()]);
687 }
688 if let Some(i) = opts.idle_exit {
689 cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
690 }
691 if opts.allow_legacy_mcp {
692 cmd_args.push("--allow-legacy-mcp".into());
693 }
694 let mut effective_env = crate::process_scope::inherited_env();
697 crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
698 effective_env.retain(|(k, _)| k != READY_FD_ENV);
699 let (program, cmd_args) = daemon_launch(&effective_env, exe, cmd_args);
700 let mut fds = [0i32; 2];
701 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
703 return Err(std::io::Error::last_os_error().into());
704 }
705 let (read_fd, write_fd) = (fds[0], fds[1]);
706 for fd in [read_fd, write_fd] {
708 if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
709 let error = std::io::Error::last_os_error();
710 unsafe { libc::close(read_fd); libc::close(write_fd); }
711 return Err(error.into());
712 }
713 }
714 crate::process_scope::set_env(&mut effective_env, READY_FD_ENV, write_fd.to_string());
715 let mut cmd = std::process::Command::new(program);
716 cmd.args(cmd_args);
717 cmd.env_clear().envs(effective_env)
718 .stdin(std::process::Stdio::null())
719 .stdout(std::process::Stdio::null())
720 .stderr(std::process::Stdio::piped());
721 unsafe {
723 cmd.pre_exec(move || {
724 libc::close(read_fd);
725 let flags = libc::fcntl(write_fd, libc::F_GETFD);
727 if flags >= 0 {
728 libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
729 }
730 libc::setsid();
731 Ok(())
732 });
733 }
734 let mut child = match cmd.spawn() {
735 Ok(child) => child,
736 Err(error) => {
737 unsafe { libc::close(read_fd); libc::close(write_fd); }
738 return Err(error.into());
739 }
740 };
741 unsafe { libc::close(write_fd) };
743 let pid = child.id();
744
745 let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
747 let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
748 let ready = loop {
749 let left = deadline.saturating_duration_since(Instant::now());
750 if left.is_zero() {
751 break false;
752 }
753 let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
755 if n < 0 {
756 let e = std::io::Error::last_os_error();
757 if e.kind() == std::io::ErrorKind::Interrupted {
758 continue;
759 }
760 break false;
761 }
762 if n == 0 {
763 break false;
764 }
765 let mut byte = [0u8; 1];
766 let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
768 break r == 1 && byte[0] == b'R';
769 };
770 unsafe { libc::close(read_fd) };
772
773 if ready {
774 if let Some(mut err) = child.stderr.take() {
776 std::thread::spawn(move || {
777 let mut sink = Vec::new();
778 let _ = err.read_to_end(&mut sink);
779 });
780 }
781 drop(child);
782 return Ok(pid);
783 }
784 let status = child.try_wait().ok().flatten().or_else(|| {
786 let _ = child.kill();
787 child.wait().ok()
788 });
789 let mut tail = String::new();
790 if let Some(mut err) = child.stderr.take() {
791 use std::os::fd::AsRawFd;
792 let fd = err.as_raw_fd();
793 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
794 if flags >= 0 && unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } >= 0 {
795 let mut bytes = Vec::new();
798 let _ = err.by_ref().take(64 * 1024).read_to_end(&mut bytes);
799 tail = String::from_utf8_lossy(&bytes).into_owned();
800 }
801 }
802 let reason = tail
803 .lines()
804 .map(str::trim)
805 .rfind(|l| !l.is_empty())
806 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
807 match (status, reason) {
808 (Some(st), Some(r)) if st.code().is_some() => {
809 anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
810 }
811 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
812 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
813 }
814}
815
816#[cfg(windows)]
822pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
823 let exe = std::env::current_exe()?;
824 let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
825 if let Some(p) = &opts.profile {
826 cmd_args.extend(["--profile".into(), p.into()]);
827 }
828 if let Some(s) = &opts.socket {
829 cmd_args.extend(["--socket".into(), s.into()]);
830 }
831 if let Some(i) = opts.idle_exit {
832 cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
833 }
834 if opts.allow_legacy_mcp {
835 cmd_args.push("--allow-legacy-mcp".into());
836 }
837 let mut effective_env = crate::process_scope::inherited_env();
838 crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
839 effective_env.retain(|(k, _)| k != READY_FD_ENV && k != READY_EVENT_ENV);
840 let ready = win::Event::create_unique("ready")?;
841 crate::process_scope::set_env(&mut effective_env, READY_EVENT_ENV, ready.name());
842 let mut child = win::spawn_detached(&exe, &cmd_args, &effective_env)?;
843 let pid = child.id();
844
845 if win::wait_event_or_exit(&ready, &child, SPAWN_READY_TIMEOUT) == win::Wait::Signalled {
846 if let Some(mut err) = child.take_stderr() {
848 std::thread::spawn(move || {
849 let mut sink = Vec::new();
850 let _ = std::io::Read::read_to_end(&mut err, &mut sink);
851 });
852 }
853 return Ok(pid);
854 }
855 let exited = child.wait(Duration::ZERO);
858 if exited.is_none() {
859 child.kill();
860 let _ = child.wait(Duration::from_secs(5));
861 }
862 let tail = win::stderr_tail(child.take_stderr(), 64 * 1024, Duration::from_millis(500));
863 let reason = tail
864 .lines()
865 .map(str::trim)
866 .rfind(|l| !l.is_empty())
867 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
868 match (exited, reason) {
869 (Some(code), Some(r)) => anyhow::bail!("daemon exited (code {code}): {r}"),
870 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
871 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
872 }
873}
874
875#[derive(Debug, Clone, PartialEq, Eq)]
877pub enum Ensured {
878 Running,
880 Spawned(u32),
882}
883
884#[derive(Debug, Clone, PartialEq, Eq)]
886pub enum EnsureError {
887 AutospawnDisabled,
889 Spawn(String),
891}
892
893impl std::fmt::Display for EnsureError {
894 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
895 match self {
896 Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
897 Self::Spawn(r) => write!(f, "{r}"),
898 }
899 }
900}
901
902const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
905
906pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
916 ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
917}
918
919fn ensure_running_with(
922 opts: &DaemonOpts,
923 auto: bool,
924 spawn: impl FnOnce(&DaemonOpts) -> anyhow::Result<u32>,
925 before_spawn_lock: impl FnOnce(),
926) -> Result<Ensured, EnsureError> {
927 let paths = opts.paths();
928 if !auto {
929 registry::reap_stale(&paths);
931 return if registry::is_alive(&paths) {
932 Ok(Ensured::Running)
933 } else {
934 Err(EnsureError::AutospawnDisabled)
935 };
936 }
937 let _ = std::fs::create_dir_all(&paths.dir);
938 before_spawn_lock();
939 let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
940 .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
941 registry::reap_stale(&paths);
945 if registry::is_alive(&paths) {
946 return Ok(Ensured::Running);
947 }
948 let mut opts = opts.clone();
953 if opts.idle_exit.is_none() {
954 opts.idle_exit = autospawn_idle_exit();
955 }
956 spawn(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
957}
958
959pub fn autospawn_idle_exit() -> Option<Duration> {
965 let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
967 autospawn_idle_exit_from(
968 std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
969 cfg_secs,
970 )
971}
972
973fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
974 let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
977 match v.map(str::trim) {
978 Some("0" | "never" | "off") => None,
979 Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
980 None => from_cfg(),
981 }
982}
983
984pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
986
987
988pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
992 if !enabled() {
993 anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
994 }
995 #[cfg(windows)]
999 reload::take_over_from_env(&opts.paths())?;
1000 let host = EngineHost::boot_and_install(crate::HostOpts {
1003 profile: opts.profile.clone(),
1004 no_extensions: false,
1005 mcp_discovery: crate::McpDiscovery::Background,
1006 })
1007 .await?;
1008 host.report_config_warnings();
1009 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1012 let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
1013 let wait = async {
1014 while let Some(ev) = rx.recv().await {
1015 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
1016 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
1017 break;
1018 }
1019 }
1020 };
1021 if tokio::time::timeout(EXTENSION_DISCOVERY_WAIT, wait).await.is_err() {
1022 tracing::warn!(
1023 wait = ?EXTENSION_DISCOVERY_WAIT,
1024 "daemon: extension discovery still running; accepting anyway"
1025 );
1026 }
1027 let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
1030
1031 let mut opts = opts;
1032 if opts.factory.is_none() {
1033 opts.factory = Some(host_factory(&host));
1034 }
1035 let mcp_host = Arc::clone(&host);
1036 let daemon = Daemon::start(host, opts).await?;
1037 let _mcp_discovery = mcp_host.spawn_background_mcp_discovery();
1041 drop(mcp_host);
1042 let state = Arc::clone(&daemon.state);
1043 tokio::spawn(async move {
1044 #[cfg(unix)]
1045 {
1046 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
1047 tokio::select! {
1048 _ = tokio::signal::ctrl_c() => {}
1049 _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
1050 _ = state.shutdown.cancelled() => return,
1051 }
1052 }
1053 #[cfg(not(unix))]
1054 {
1055 tokio::select! {
1056 _ = tokio::signal::ctrl_c() => {}
1057 _ = state.shutdown.cancelled() => return,
1058 }
1059 }
1060 tracing::info!("daemon: signal received, shutting down");
1061 state.request_shutdown(false);
1062 });
1063 daemon.wait().await;
1064 Ok(())
1065}
1066
1067#[cfg(test)]
1068mod flag_tests {
1069 use super::*;
1070
1071 #[test]
1072 fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
1073 let d = AUTOSPAWN_IDLE_EXIT.as_secs();
1075 assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
1076 assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
1077 assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
1078 assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
1079 for never in ["0", "never", "off"] {
1080 assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
1081 }
1082 assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
1084 assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
1086 assert_eq!(autospawn_idle_exit_from(None, 0), None);
1088 assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
1090 }
1091
1092 #[test]
1093 fn enabled_env_matrix() {
1094 assert!(enabled_from(None), "unset → on");
1095 for on in ["1", "true", "yes", "on", "", "anything"] {
1096 assert!(enabled_from(Some(on)), "{on:?} → on");
1097 }
1098 for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
1099 assert!(!enabled_from(Some(off)), "{off:?} → off");
1100 }
1101 }
1102
1103 #[test]
1104 fn profile_mismatch_refuses_a_daemon_of_another_profile() {
1105 assert_eq!(profile_mismatch(None, None), None);
1106 assert_eq!(profile_mismatch(Some("axon"), Some("axon")), None);
1107 assert_eq!(profile_mismatch(Some(""), None), None);
1109 assert_eq!(profile_mismatch(None, Some("")), None);
1110 let m = profile_mismatch(None, Some("axon")).expect("mismatch");
1112 assert!(m.contains("the default profile") && m.contains("profile `axon`"), "{m}");
1113 assert!(m.contains("synaps daemon stop"), "{m}");
1114 let m = profile_mismatch(Some("axon"), None).expect("mismatch");
1115 assert!(m.contains("synaps --profile axon daemon stop"), "{m}");
1116 assert!(profile_mismatch(Some("a.b"), Some("a_b")).is_some());
1118 }
1119
1120 #[test]
1121 fn env_is_off_is_case_and_space_insensitive() {
1122 assert!(env_is_off(Some("NO")));
1123 assert!(!env_is_off(Some("1")));
1124 assert!(!env_is_off(None));
1125 }
1126}
1127
1128#[cfg(all(test, target_os = "linux"))]
1129mod scope_tests {
1130 use super::*;
1131 use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
1132
1133 fn launch(env: &[(String, String)]) -> (std::ffi::OsString, Vec<String>) {
1134 let env: Vec<(std::ffi::OsString, std::ffi::OsString)> =
1135 env.iter().map(|(k, v)| (k.into(), v.into())).collect();
1136 let exe = PathBuf::from("/opt/synaps/bin/synaps");
1137 let (program, args) = daemon_launch(&env, exe, vec!["daemon".into(), "--foreground".into()]);
1138 (program, args.iter().map(|a| a.to_string_lossy().into_owned()).collect())
1139 }
1140
1141 #[test]
1144 #[serial_test::serial(synaps_base_dir)]
1145 fn the_daemon_scope_goes_in_the_daemon_slice_only() {
1146 let base = crate::test_env::BaseDirGuard::new();
1147 let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
1148 let dir = tempfile::tempdir().unwrap();
1149 let fake = fake_systemd_run(dir.path());
1150 let env = fake_env(&fake);
1151 let today = [
1152 "--user",
1153 "--scope",
1154 "--quiet",
1155 "--collect",
1156 "--description=synaps daemon",
1157 "--",
1158 "/opt/synaps/bin/synaps",
1159 "daemon",
1160 "--foreground",
1161 ];
1162 let (program, args) = launch(&env);
1163 assert_eq!(program, fake.clone().into_os_string());
1164 assert_eq!(args, today);
1165 let config = base.path().join("config");
1166 std::fs::write(&config, "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1167 let (program, args) = launch(&env);
1168 assert_eq!(program, fake.clone().into_os_string());
1169 assert_eq!(
1170 args,
1171 [
1172 "--user",
1173 "--scope",
1174 "--quiet",
1175 "--collect",
1176 "--slice=d_daemon.slice",
1177 "--description=synaps daemon",
1178 "--",
1179 "/opt/synaps/bin/synaps",
1180 "daemon",
1181 "--foreground",
1182 ]
1183 );
1184 assert!(recorded(dir.path(), "probe.args").contains(&"--slice=d_daemon.slice".to_string()));
1185 std::fs::write(&config, "tool_scope_slice = t_tools.slice\n").unwrap();
1187 assert_eq!(launch(&env).1, today);
1188 std::fs::write(&config, "process_scopes = off\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1190 let (program, args) = launch(&env);
1191 assert_eq!(program, std::ffi::OsString::from("/opt/synaps/bin/synaps"));
1192 assert_eq!(args, ["daemon", "--foreground"]);
1193 }
1194}
1195
1196#[cfg(test)]
1197mod ensure_tests {
1198 use super::*;
1199 use std::sync::mpsc;
1200
1201 const WAIT: Duration = Duration::from_secs(3);
1202 const BLOCKED: Duration = Duration::from_millis(100);
1203
1204 fn serialization_case(stale_metadata: bool) {
1205 let dir = tempfile::tempdir().unwrap();
1206 let opts = DaemonOpts {
1207 runtime_dir: Some(dir.path().to_path_buf()),
1208 profile: Some("ensure-serialization".into()),
1209 idle_exit: Some(Duration::from_secs(1)),
1211 ..Default::default()
1212 };
1213 let paths = opts.paths();
1214 let spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1215 let cleanup_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1216 if stale_metadata {
1217 std::fs::write(&paths.json, b"stale metadata").unwrap();
1218 std::fs::write(&paths.pid, b"0\n").unwrap();
1219 }
1220 let count = Arc::new(AtomicUsize::new(0));
1221 let ready = Arc::new(AtomicBool::new(false));
1222 let (attempt_tx, attempt_rx) = mpsc::channel();
1223 let (daemon_tx, daemon_rx) = mpsc::channel();
1224 let (ready_tx, ready_rx) = mpsc::channel();
1225 let (done_tx, done_rx) = mpsc::channel();
1226 let first_opts = opts.clone();
1227 let first_count = Arc::clone(&count);
1228 let first_ready = Arc::clone(&ready);
1229 let first = std::thread::spawn(move || {
1230 let result = ensure_running_with(&first_opts, true, |opts| {
1231 first_count.fetch_add(1, Ordering::SeqCst);
1232 let paths = opts.paths();
1233 assert!(!paths.json.exists());
1235 assert!(!paths.pid.exists());
1236 let lock = DaemonLock::try_acquire(&paths)?.expect("daemon lock free after reap");
1237 let pid = std::process::id();
1238 registry::write_daemon_json(&paths, &DaemonInfo {
1239 pid,
1240 protocol_version: 1,
1241 daemon_version: "test".into(),
1242 profile: opts.profile.clone(),
1243 started_at: chrono::Utc::now(),
1244 socket: paths.sock.display().to_string(),
1245 exe: None,
1246 generation: 1,
1247 })?;
1248 daemon_tx.send(lock).unwrap();
1250 ready_rx.recv_timeout(WAIT).unwrap();
1251 first_ready.store(true, Ordering::SeqCst);
1252 Ok(pid)
1253 }, || attempt_tx.send(()).unwrap());
1254 done_tx.send(result).unwrap();
1255 });
1256 attempt_rx.recv_timeout(WAIT).unwrap();
1258 assert_eq!(done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1259 assert_eq!(count.load(Ordering::SeqCst), 0);
1260 assert_eq!(paths.json.exists(), stale_metadata);
1261 assert_eq!(paths.pid.exists(), stale_metadata);
1262 drop(cleanup_lock);
1263 drop(spawn_lock);
1264 let daemon_lock = daemon_rx.recv_timeout(WAIT).unwrap();
1265
1266 let (second_attempt_tx, second_attempt_rx) = mpsc::channel();
1269 let (second_done_tx, second_done_rx) = mpsc::channel();
1270 let second_count = Arc::clone(&count);
1271 let second_ready = Arc::clone(&ready);
1272 let second = std::thread::spawn(move || {
1273 let result = ensure_running_with(&opts, true, |_| {
1274 second_count.fetch_add(1, Ordering::SeqCst);
1275 panic!("warm attach must not spawn");
1276 }, || second_attempt_tx.send(()).unwrap());
1277 second_done_tx.send((result, second_ready.load(Ordering::SeqCst))).unwrap();
1278 });
1279 second_attempt_rx.recv_timeout(WAIT).unwrap();
1280 assert_eq!(second_done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1281 assert!(!ready.load(Ordering::SeqCst));
1282 ready_tx.send(()).unwrap();
1283 assert_eq!(done_rx.recv_timeout(WAIT).unwrap(), Ok(Ensured::Spawned(std::process::id())));
1284 assert_eq!(second_done_rx.recv_timeout(WAIT).unwrap(), (Ok(Ensured::Running), true));
1285 first.join().unwrap();
1286 second.join().unwrap();
1287 assert_eq!(count.load(Ordering::SeqCst), 1);
1288 assert_eq!(registry::read_daemon_json(&paths).unwrap().pid, std::process::id());
1289 drop(daemon_lock);
1290 }
1291
1292 #[test]
1293 fn ensure_serializes_before_probe_with_missing_metadata() {
1294 serialization_case(false);
1295 }
1296
1297 #[test]
1298 fn ensure_serializes_before_probe_with_stale_metadata() {
1299 serialization_case(true);
1300 }
1301
1302 #[test]
1303 fn disabled_ensure_keeps_warm_attach_without_spawn_lock() {
1304 let dir = tempfile::tempdir().unwrap();
1305 let opts = DaemonOpts {
1306 runtime_dir: Some(dir.path().to_path_buf()),
1307 ..Default::default()
1308 };
1309 let paths = opts.paths();
1310 let _spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1311 let daemon_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1312 let ensure = || ensure_running_with(&opts, false,
1313 |_| panic!("disabled must not spawn"),
1314 || panic!("disabled must not attempt spawn lock"));
1315 assert_eq!(ensure(), Ok(Ensured::Running));
1316 drop(daemon_lock);
1317 std::fs::write(&paths.json, b"stale").unwrap();
1318 std::fs::write(&paths.pid, b"0\n").unwrap();
1319 assert_eq!(ensure(), Err(EnsureError::AutospawnDisabled));
1320 assert!(!paths.json.exists());
1321 assert!(!paths.pid.exists());
1322 }
1323}