1pub mod close;
12pub mod conn;
13pub mod extension_reload;
14pub mod lifecycle;
15pub mod listener;
16pub mod pin;
17pub mod pins;
18pub mod registry;
19pub mod reload;
20
21use std::collections::HashMap;
22use std::future::Future;
23use std::path::PathBuf;
24use std::pin::Pin;
25use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
26use std::sync::{Arc, Mutex};
27use std::time::{Duration, Instant};
28
29use tokio_util::sync::CancellationToken;
30
31use crate::session::{SessionConfig, SessionHandle, SessionId, SessionMeta};
32use crate::EngineHost;
33use registry::{DaemonInfo, DaemonLock, DaemonPaths};
34
35pub const EXIT_REFUSED: i32 = 3;
37pub const EXIT_VERSION: i32 = 2;
39pub const EXIT_DAEMON_LOST: i32 = 4;
42pub const READY_FD_ENV: &str = "SYNAPS_DAEMON_READY_FD";
44pub const EXTENSION_DISCOVERY_WAIT: Duration = Duration::from_secs(10);
48const SPAWN_READY_MARGIN: Duration = Duration::from_secs(5);
51pub const SPAWN_READY_TIMEOUT: Duration =
56 Duration::from_secs(EXTENSION_DISCOVERY_WAIT.as_secs() + SPAWN_READY_MARGIN.as_secs());
57const _: () = assert!(
60 SPAWN_READY_TIMEOUT.as_secs() > EXTENSION_DISCOVERY_WAIT.as_secs(),
61 "SPAWN_READY_TIMEOUT must exceed EXTENSION_DISCOVERY_WAIT"
62);
63
64pub fn enabled() -> bool {
67 enabled_from(std::env::var("SYNAPS_DAEMON").ok().as_deref())
68}
69
70fn env_is_off(v: Option<&str>) -> bool {
71 matches!(v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("0" | "false" | "off" | "no"))
72}
73
74fn enabled_from(v: Option<&str>) -> bool {
75 !env_is_off(v)
76}
77
78pub fn autospawn_enabled() -> bool {
82 !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
83}
84
85pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
87
88pub fn profile_mismatch(daemon: Option<&str>, client: Option<&str>) -> Option<String> {
95 let norm = |p: Option<&str>| p.filter(|s| !s.is_empty()).map(str::to_owned);
96 let (daemon, client) = (norm(daemon), norm(client));
97 if daemon == client {
98 return None;
99 }
100 let name = |p: &Option<String>| match p {
101 Some(p) => format!("profile `{p}`"),
102 None => "the default profile".to_string(),
103 };
104 let stop = match &daemon {
105 Some(p) => format!("synaps --profile {p} daemon stop"),
106 None => "synaps daemon stop".to_string(),
107 };
108 Some(format!(
109 "refusing to attach: this daemon serves {}, but this client runs {} — \
110 stop it ({stop}) or use the matching --profile",
111 name(&daemon),
112 name(&client),
113 ))
114}
115
116pub type SessionFactory = Arc<
119 dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
120 + Send
121 + Sync,
122>;
123
124#[cfg(any(test, feature = "testing"))]
126pub fn echo_factory() -> SessionFactory {
127 Arc::new(|cfg: SessionConfig| {
128 Box::pin(async move {
129 let id = SessionId(format!(
130 "echo-{}-{}",
131 chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"),
132 uuid::Uuid::new_v4().simple()
133 ));
134 let (handle, _task) = SessionHandle::echo_for_test(id);
135 let _ = cfg;
136 Ok(handle)
137 })
138 })
139}
140
141pub fn host_factory(host: &Arc<EngineHost>) -> SessionFactory {
144 let host = Arc::clone(host);
145 Arc::new(move |cfg: SessionConfig| {
146 let host = Arc::clone(&host);
147 Box::pin(async move { host.create_session(cfg).await.map_err(|e| e.to_string()) })
148 })
149}
150
151#[derive(Clone, Default)]
152pub struct DaemonOpts {
153 pub socket: Option<PathBuf>,
155 pub profile: Option<String>,
156 pub idle_exit: Option<Duration>,
161 pub allow_legacy_mcp: bool,
165 pub runtime_dir: Option<PathBuf>,
167 pub factory: Option<SessionFactory>,
169}
170
171impl DaemonOpts {
172 pub fn paths(&self) -> DaemonPaths {
173 let mut p = match &self.runtime_dir {
174 Some(d) => registry::daemon_paths_in(d, self.profile.as_deref()),
175 None => registry::daemon_paths(self.profile.as_deref()),
176 };
177 if let Some(s) = &self.socket {
178 p.sock = s.clone();
179 }
180 p
181 }
182}
183
184pub struct DaemonState {
186 pub host: Arc<EngineHost>,
187 pub paths: DaemonPaths,
188 pub profile: Option<String>,
189 pub started: Instant,
190 pub factory: SessionFactory,
191 pub connections: AtomicUsize,
192 pub shutdown: CancellationToken,
193 pub force_shutdown: AtomicBool,
194 pub generation: u64,
197 pub reloading: AtomicBool,
199 pub reload_generation: std::sync::atomic::AtomicU64,
201 pub reload_announce: Mutex<CancellationToken>,
205 pub lock_fd: Mutex<Option<DaemonLock>>,
208 pub reload_aliases: Mutex<HashMap<String, String>>,
211 pub meta: registry::Metadata,
215 pub pins: Arc<pins::PinBook>,
217 pub restoring: AtomicBool,
219}
220
221impl DaemonState {
222 pub fn uptime_s(&self) -> u64 {
223 self.started.elapsed().as_secs()
224 }
225
226 pub fn announce(&self) -> CancellationToken {
228 self.reload_announce
229 .lock()
230 .unwrap_or_else(|e| e.into_inner())
231 .clone()
232 }
233
234 pub fn fire_announce(&self) {
236 self.announce().cancel();
237 }
238
239 pub fn reset_announce(&self) {
241 *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
242 }
243
244 pub fn daemon_info(&self) -> DaemonInfo {
246 self.meta.info()
247 }
248
249 pub fn heal_daemon_json(&self) {
260 let lock = self.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
261 let Some(lock) = lock.as_ref() else {
262 return;
263 };
264 if self.shutdown.is_cancelled() {
265 return;
266 }
267 match self.meta.heal(lock) {
268 Ok(registry::Healed::Yes { info, log: Some(heals) }) => tracing::info!(
269 path = %self.paths.json.display(),
270 pid = info.pid,
271 generation = info.generation,
272 heals,
273 "daemon: daemon.json rewritten (it was missing or named another pid)"
274 ),
275 Ok(_) => {}
276 Err(e) => tracing::debug!(error = %e, "daemon: daemon.json heal failed"),
277 }
278 }
279
280 pub fn live_sessions(&self) -> Vec<SessionHandle> {
282 self.host.session_handles()
283 }
284
285 pub fn session_metas(&self) -> Vec<SessionMeta> {
288 self.host.sessions()
289 }
290
291 pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
296 self.host.attach(id).filter(|h| h.is_alive()).or_else(|| {
297 let alias = self
298 .reload_aliases
299 .lock()
300 .unwrap_or_else(|e| e.into_inner())
301 .get(id.as_str())
302 .cloned()?;
303 self.host
304 .attach(&SessionId::from(alias.as_str()))
305 .filter(|h| h.is_alive())
306 })
307 }
308
309 pub fn insert(&self, handle: SessionHandle) {
310 self.host.adopt_session(handle);
311 }
312
313 pub fn remove(&self, id: &SessionId) {
314 self.host.remove_session(id);
315 }
316
317 pub async fn create(&self, cfg: SessionConfig) -> Result<SessionHandle, String> {
318 let handle = (self.factory)(cfg).await?;
319 self.insert(handle.clone());
320 Ok(handle)
321 }
322
323 pub async fn retry_locked_placeholder(&self, placeholder: SessionHandle) -> SessionHandle {
330 let cfg = SessionConfig {
331 continue_session: Some(Some(placeholder.id.as_str().to_string())),
332 ..placeholder.placeholder_config().cloned().unwrap_or_default()
333 };
334 self.remove(&placeholder.id);
335 match (self.factory)(cfg).await {
336 Ok(real) if real.id == placeholder.id || real.journal_id() == placeholder.id.as_str() => {
337 tracing::info!(session = %placeholder.id, "daemon: locked journal released — placeholder replaced by the real session");
338 self.insert(real.clone());
339 real
340 }
341 Ok(other) => {
342 tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
344 self.insert(other.clone());
345 other
346 }
347 Err(e) => {
348 tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
349 self.insert(placeholder.clone());
350 placeholder
351 }
352 }
353 }
354
355 pub fn request_shutdown(&self, force: bool) {
356 if force {
357 self.force_shutdown.store(true, Ordering::SeqCst);
358 }
359 self.shutdown.cancel();
360 }
361}
362
363pub struct Daemon {
365 pub paths: DaemonPaths,
366 pub state: Arc<DaemonState>,
367 accept: tokio::task::JoinHandle<()>,
368}
369
370impl Daemon {
371 pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
374 let paths = opts.paths();
375 std::fs::create_dir_all(&paths.dir)?;
376 #[cfg(unix)]
377 {
378 use std::os::unix::fs::PermissionsExt;
379 std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
380 }
381 let adopted = reload::adopt_from_env()?;
384 let (lock, reload_state) = match adopted {
385 Some((lock, rs, rs_path)) => {
386 let _ = std::fs::remove_file(&rs_path);
387 (lock, Some(rs))
388 }
389 None => {
390 registry::reap_stale(&paths);
391 let lock = match DaemonLock::try_acquire(&paths)? {
392 Some(l) => l,
393 None => {
394 let who = registry::read_daemon_json(&paths).map(|i| i.pid);
395 anyhow::bail!(
396 "another daemon holds {} (pid {})",
397 paths.lock.display(),
398 who.map_or("unknown".to_string(), |p| p.to_string())
399 );
400 }
401 };
402 (lock, None)
403 }
404 };
405 let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
406 let listener = listener::bind(&paths.sock)?;
407
408 let info = DaemonInfo {
409 pid: std::process::id(),
410 protocol_version: crate::session::wire::PROTOCOL_VERSION,
411 daemon_version: crate::session::wire::binary_version(),
412 profile: opts.profile.clone(),
413 started_at: chrono::Utc::now(),
414 socket: paths.sock.to_string_lossy().into_owned(),
415 exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
416 generation,
417 };
418 let meta = registry::Metadata::start(paths.clone(), info.clone())?;
419
420 let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
421 let state = Arc::new(DaemonState {
422 host,
423 paths: paths.clone(),
424 profile: opts.profile.clone(),
425 started: Instant::now(),
426 factory,
427 connections: AtomicUsize::new(0),
428 shutdown: CancellationToken::new(),
429 force_shutdown: AtomicBool::new(false),
430 generation,
431 reloading: AtomicBool::new(false),
432 reload_generation: std::sync::atomic::AtomicU64::new(generation),
433 reload_announce: Mutex::new(CancellationToken::new()),
434 lock_fd: Mutex::new(Some(lock)),
435 reload_aliases: Mutex::new(HashMap::new()),
436 meta,
437 pins: Arc::new(pins::PinBook::for_active_profile()),
438 restoring: AtomicBool::new(false),
439 });
440 state.host.set_pin_sink(Arc::clone(&state.pins) as Arc<dyn crate::session::PinSink>);
441
442 let own_pid = reload_state.as_ref().map(|_| info.pid);
450 let swept = crate::events::registry::sweep_stale_registrations(own_pid);
451 if swept > 0 {
452 tracing::info!(swept, "daemon: removed stale session registrations");
453 }
454
455 if let Some(rs) = &reload_state {
457 reload::rehydrate(&state, rs).await;
458 }
459
460 let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
461 if let Some(idle) = opts.idle_exit {
462 tokio::spawn(idle_monitor(Arc::clone(&state), idle));
463 }
464 signal_ready();
465 if reload_state.is_none() && pins::restore_enabled() {
467 tokio::spawn(pins::restore_all(Arc::clone(&state)));
468 }
469 tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
470 Ok(Self { paths, state, accept })
471 }
472
473 pub fn shutdown_token(&self) -> CancellationToken {
474 self.state.shutdown.clone()
475 }
476
477 pub async fn wait(self) {
480 self.state.shutdown.cancelled().await;
481 let force = self.state.force_shutdown.load(Ordering::SeqCst);
482 lifecycle::shutdown_all(&self.state, force).await;
483 self.accept.abort();
484 let _ = self.accept.await;
485 crate::events::socket::cleanup_socket(&self.paths.sock.to_string_lossy());
486 {
487 let lock = self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
488 if let Some(lock) = lock.as_ref() {
489 self.state.meta.cleanup(lock);
490 }
491 }
492 tracing::info!("daemon: stopped");
493 drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
495 }
496}
497
498const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
500
501pub async fn session_is_idle(handle: &SessionHandle) -> bool {
507 use crate::session::wire::IDLE_PROBE_QUERY_ID;
508 use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
509 if handle.lifecycle() == SessionLifecycle::Parked {
511 return true;
512 }
513 let mut rx = handle.subscribe();
514 if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
515 return false;
516 }
517 let probe = async {
518 loop {
519 match rx.recv().await {
520 Ok(env) => {
521 if let SessionEventWire::QueryResult { id, value } = env.event {
522 if id == IDLE_PROBE_QUERY_ID {
523 let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
526 let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
527 return !streaming && prompts == 0;
528 }
529 }
530 }
531 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
532 Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
533 }
534 }
535 };
536 tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
537}
538
539pub async fn daemon_is_idle(state: &DaemonState) -> bool {
544 if state.connections.load(Ordering::SeqCst) > 0 {
545 return false;
546 }
547 if state.restoring.load(Ordering::SeqCst) {
548 return false;
549 }
550 let sessions = state.live_sessions();
551 if sessions.iter().any(|h| h.pinned() && h.is_alive()) {
552 return false;
553 }
554 for h in sessions {
555 if !session_is_idle(&h).await {
556 return false;
557 }
558 }
559 true
560}
561
562async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
563 let mut idle_since: Option<Instant> = None;
564 let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
565 loop {
566 tokio::select! {
567 _ = state.shutdown.cancelled() => return,
568 _ = tokio::time::sleep(tick) => {}
569 }
570 if !daemon_is_idle(&state).await {
571 idle_since = None;
572 continue;
573 }
574 let since = *idle_since.get_or_insert_with(Instant::now);
575 if since.elapsed() >= idle {
576 tracing::info!("daemon: idle for {:?}, exiting", idle);
577 state.request_shutdown(false);
578 return;
579 }
580 }
581}
582
583pub fn signal_ready() {
586 #[cfg(unix)]
587 {
588 let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
589 return;
590 };
591 std::env::remove_var(READY_FD_ENV);
592 unsafe {
594 let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
595 libc::close(fd);
596 }
597 }
598}
599
600#[cfg(unix)]
605pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
606 use std::io::Read;
607 use std::os::unix::process::CommandExt;
608
609 let exe = std::env::current_exe()?;
610
611
612 let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
613 if let Some(p) = &opts.profile {
614 cmd_args.extend(["--profile".into(), p.into()]);
615 }
616 if let Some(s) = &opts.socket {
617 cmd_args.extend(["--socket".into(), s.into()]);
618 }
619 if let Some(i) = opts.idle_exit {
620 cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
621 }
622 if opts.allow_legacy_mcp {
623 cmd_args.push("--allow-legacy-mcp".into());
624 }
625 let mut effective_env = crate::process_scope::inherited_env();
628 crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
629 effective_env.retain(|(k, _)| k != READY_FD_ENV);
630 let (program, cmd_args) = match crate::process_scope::launcher_for_effective_env(&effective_env, None) {
631 Some(launcher) => launcher.wrap("synaps daemon", exe, cmd_args),
632 None => (exe.into_os_string(), cmd_args),
633 };
634 let mut fds = [0i32; 2];
635 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
637 return Err(std::io::Error::last_os_error().into());
638 }
639 let (read_fd, write_fd) = (fds[0], fds[1]);
640 for fd in [read_fd, write_fd] {
642 if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
643 let error = std::io::Error::last_os_error();
644 unsafe { libc::close(read_fd); libc::close(write_fd); }
645 return Err(error.into());
646 }
647 }
648 crate::process_scope::set_env(&mut effective_env, READY_FD_ENV, write_fd.to_string());
649 let mut cmd = std::process::Command::new(program);
650 cmd.args(cmd_args);
651 cmd.env_clear().envs(effective_env)
652 .stdin(std::process::Stdio::null())
653 .stdout(std::process::Stdio::null())
654 .stderr(std::process::Stdio::piped());
655 unsafe {
657 cmd.pre_exec(move || {
658 libc::close(read_fd);
659 let flags = libc::fcntl(write_fd, libc::F_GETFD);
661 if flags >= 0 {
662 libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
663 }
664 libc::setsid();
665 Ok(())
666 });
667 }
668 let mut child = match cmd.spawn() {
669 Ok(child) => child,
670 Err(error) => {
671 unsafe { libc::close(read_fd); libc::close(write_fd); }
672 return Err(error.into());
673 }
674 };
675 unsafe { libc::close(write_fd) };
677 let pid = child.id();
678
679 let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
681 let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
682 let ready = loop {
683 let left = deadline.saturating_duration_since(Instant::now());
684 if left.is_zero() {
685 break false;
686 }
687 let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
689 if n < 0 {
690 let e = std::io::Error::last_os_error();
691 if e.kind() == std::io::ErrorKind::Interrupted {
692 continue;
693 }
694 break false;
695 }
696 if n == 0 {
697 break false;
698 }
699 let mut byte = [0u8; 1];
700 let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
702 break r == 1 && byte[0] == b'R';
703 };
704 unsafe { libc::close(read_fd) };
706
707 if ready {
708 if let Some(mut err) = child.stderr.take() {
710 std::thread::spawn(move || {
711 let mut sink = Vec::new();
712 let _ = err.read_to_end(&mut sink);
713 });
714 }
715 drop(child);
716 return Ok(pid);
717 }
718 let status = child.try_wait().ok().flatten().or_else(|| {
720 let _ = child.kill();
721 child.wait().ok()
722 });
723 let mut tail = String::new();
724 if let Some(mut err) = child.stderr.take() {
725 use std::os::fd::AsRawFd;
726 let fd = err.as_raw_fd();
727 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
728 if flags >= 0 && unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } >= 0 {
729 let mut bytes = Vec::new();
732 let _ = err.by_ref().take(64 * 1024).read_to_end(&mut bytes);
733 tail = String::from_utf8_lossy(&bytes).into_owned();
734 }
735 }
736 let reason = tail
737 .lines()
738 .map(str::trim)
739 .rfind(|l| !l.is_empty())
740 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
741 match (status, reason) {
742 (Some(st), Some(r)) if st.code().is_some() => {
743 anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
744 }
745 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
746 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
747 }
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
752pub enum Ensured {
753 Running,
755 Spawned(u32),
757}
758
759#[derive(Debug, Clone, PartialEq, Eq)]
761pub enum EnsureError {
762 AutospawnDisabled,
764 Spawn(String),
766}
767
768impl std::fmt::Display for EnsureError {
769 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
770 match self {
771 Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
772 Self::Spawn(r) => write!(f, "{r}"),
773 }
774 }
775}
776
777const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
780
781#[cfg(unix)]
791pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
792 ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
793}
794
795#[cfg(unix)]
798fn ensure_running_with(
799 opts: &DaemonOpts,
800 auto: bool,
801 spawn: impl FnOnce(&DaemonOpts) -> anyhow::Result<u32>,
802 before_spawn_lock: impl FnOnce(),
803) -> Result<Ensured, EnsureError> {
804 let paths = opts.paths();
805 if !auto {
806 registry::reap_stale(&paths);
808 return if registry::is_alive(&paths) {
809 Ok(Ensured::Running)
810 } else {
811 Err(EnsureError::AutospawnDisabled)
812 };
813 }
814 let _ = std::fs::create_dir_all(&paths.dir);
815 before_spawn_lock();
816 let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
817 .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
818 registry::reap_stale(&paths);
822 if registry::is_alive(&paths) {
823 return Ok(Ensured::Running);
824 }
825 let mut opts = opts.clone();
830 if opts.idle_exit.is_none() {
831 opts.idle_exit = autospawn_idle_exit();
832 }
833 spawn(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
834}
835
836pub fn autospawn_idle_exit() -> Option<Duration> {
842 let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
844 autospawn_idle_exit_from(
845 std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
846 cfg_secs,
847 )
848}
849
850fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
851 let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
854 match v.map(str::trim) {
855 Some("0" | "never" | "off") => None,
856 Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
857 None => from_cfg(),
858 }
859}
860
861pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
863
864
865pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
869 if !enabled() {
870 anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
871 }
872 let host = EngineHost::boot_and_install(crate::HostOpts {
875 profile: opts.profile.clone(),
876 no_extensions: false,
877 mcp_discovery: crate::McpDiscovery::Background,
878 })
879 .await?;
880 host.report_config_warnings();
881 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
884 let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
885 let wait = async {
886 while let Some(ev) = rx.recv().await {
887 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
888 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
889 break;
890 }
891 }
892 };
893 if tokio::time::timeout(EXTENSION_DISCOVERY_WAIT, wait).await.is_err() {
894 tracing::warn!(
895 wait = ?EXTENSION_DISCOVERY_WAIT,
896 "daemon: extension discovery still running; accepting anyway"
897 );
898 }
899 let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
902
903 let mut opts = opts;
904 if opts.factory.is_none() {
905 opts.factory = Some(host_factory(&host));
906 }
907 let mcp_host = Arc::clone(&host);
908 let daemon = Daemon::start(host, opts).await?;
909 let _mcp_discovery = mcp_host.spawn_background_mcp_discovery();
913 drop(mcp_host);
914 let state = Arc::clone(&daemon.state);
915 tokio::spawn(async move {
916 #[cfg(unix)]
917 {
918 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
919 tokio::select! {
920 _ = tokio::signal::ctrl_c() => {}
921 _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
922 _ = state.shutdown.cancelled() => return,
923 }
924 }
925 #[cfg(not(unix))]
926 {
927 tokio::select! {
928 _ = tokio::signal::ctrl_c() => {}
929 _ = state.shutdown.cancelled() => return,
930 }
931 }
932 tracing::info!("daemon: signal received, shutting down");
933 state.request_shutdown(false);
934 });
935 daemon.wait().await;
936 Ok(())
937}
938
939#[cfg(test)]
940mod flag_tests {
941 use super::*;
942
943 #[test]
944 fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
945 let d = AUTOSPAWN_IDLE_EXIT.as_secs();
947 assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
948 assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
949 assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
950 assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
951 for never in ["0", "never", "off"] {
952 assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
953 }
954 assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
956 assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
958 assert_eq!(autospawn_idle_exit_from(None, 0), None);
960 assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
962 }
963
964 #[test]
965 fn enabled_env_matrix() {
966 assert!(enabled_from(None), "unset → on");
967 for on in ["1", "true", "yes", "on", "", "anything"] {
968 assert!(enabled_from(Some(on)), "{on:?} → on");
969 }
970 for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
971 assert!(!enabled_from(Some(off)), "{off:?} → off");
972 }
973 }
974
975 #[test]
976 fn profile_mismatch_refuses_a_daemon_of_another_profile() {
977 assert_eq!(profile_mismatch(None, None), None);
978 assert_eq!(profile_mismatch(Some("axon"), Some("axon")), None);
979 assert_eq!(profile_mismatch(Some(""), None), None);
981 assert_eq!(profile_mismatch(None, Some("")), None);
982 let m = profile_mismatch(None, Some("axon")).expect("mismatch");
984 assert!(m.contains("the default profile") && m.contains("profile `axon`"), "{m}");
985 assert!(m.contains("synaps daemon stop"), "{m}");
986 let m = profile_mismatch(Some("axon"), None).expect("mismatch");
987 assert!(m.contains("synaps --profile axon daemon stop"), "{m}");
988 assert!(profile_mismatch(Some("a.b"), Some("a_b")).is_some());
990 }
991
992 #[test]
993 fn env_is_off_is_case_and_space_insensitive() {
994 assert!(env_is_off(Some("NO")));
995 assert!(!env_is_off(Some("1")));
996 assert!(!env_is_off(None));
997 }
998}
999
1000#[cfg(all(test, unix))]
1001mod ensure_tests {
1002 use super::*;
1003 use std::sync::mpsc;
1004
1005 const WAIT: Duration = Duration::from_secs(3);
1006 const BLOCKED: Duration = Duration::from_millis(100);
1007
1008 fn serialization_case(stale_metadata: bool) {
1009 let dir = tempfile::tempdir().unwrap();
1010 let opts = DaemonOpts {
1011 runtime_dir: Some(dir.path().to_path_buf()),
1012 profile: Some("ensure-serialization".into()),
1013 idle_exit: Some(Duration::from_secs(1)),
1015 ..Default::default()
1016 };
1017 let paths = opts.paths();
1018 let spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1019 let cleanup_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1020 if stale_metadata {
1021 std::fs::write(&paths.json, b"stale metadata").unwrap();
1022 std::fs::write(&paths.pid, b"0\n").unwrap();
1023 }
1024 let count = Arc::new(AtomicUsize::new(0));
1025 let ready = Arc::new(AtomicBool::new(false));
1026 let (attempt_tx, attempt_rx) = mpsc::channel();
1027 let (daemon_tx, daemon_rx) = mpsc::channel();
1028 let (ready_tx, ready_rx) = mpsc::channel();
1029 let (done_tx, done_rx) = mpsc::channel();
1030 let first_opts = opts.clone();
1031 let first_count = Arc::clone(&count);
1032 let first_ready = Arc::clone(&ready);
1033 let first = std::thread::spawn(move || {
1034 let result = ensure_running_with(&first_opts, true, |opts| {
1035 first_count.fetch_add(1, Ordering::SeqCst);
1036 let paths = opts.paths();
1037 assert!(!paths.json.exists());
1039 assert!(!paths.pid.exists());
1040 let lock = DaemonLock::try_acquire(&paths)?.expect("daemon lock free after reap");
1041 let pid = std::process::id();
1042 registry::write_daemon_json(&paths, &DaemonInfo {
1043 pid,
1044 protocol_version: 1,
1045 daemon_version: "test".into(),
1046 profile: opts.profile.clone(),
1047 started_at: chrono::Utc::now(),
1048 socket: paths.sock.display().to_string(),
1049 exe: None,
1050 generation: 1,
1051 })?;
1052 daemon_tx.send(lock).unwrap();
1054 ready_rx.recv_timeout(WAIT).unwrap();
1055 first_ready.store(true, Ordering::SeqCst);
1056 Ok(pid)
1057 }, || attempt_tx.send(()).unwrap());
1058 done_tx.send(result).unwrap();
1059 });
1060 attempt_rx.recv_timeout(WAIT).unwrap();
1062 assert_eq!(done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1063 assert_eq!(count.load(Ordering::SeqCst), 0);
1064 assert_eq!(paths.json.exists(), stale_metadata);
1065 assert_eq!(paths.pid.exists(), stale_metadata);
1066 drop(cleanup_lock);
1067 drop(spawn_lock);
1068 let daemon_lock = daemon_rx.recv_timeout(WAIT).unwrap();
1069
1070 let (second_attempt_tx, second_attempt_rx) = mpsc::channel();
1073 let (second_done_tx, second_done_rx) = mpsc::channel();
1074 let second_count = Arc::clone(&count);
1075 let second_ready = Arc::clone(&ready);
1076 let second = std::thread::spawn(move || {
1077 let result = ensure_running_with(&opts, true, |_| {
1078 second_count.fetch_add(1, Ordering::SeqCst);
1079 panic!("warm attach must not spawn");
1080 }, || second_attempt_tx.send(()).unwrap());
1081 second_done_tx.send((result, second_ready.load(Ordering::SeqCst))).unwrap();
1082 });
1083 second_attempt_rx.recv_timeout(WAIT).unwrap();
1084 assert_eq!(second_done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1085 assert!(!ready.load(Ordering::SeqCst));
1086 ready_tx.send(()).unwrap();
1087 assert_eq!(done_rx.recv_timeout(WAIT).unwrap(), Ok(Ensured::Spawned(std::process::id())));
1088 assert_eq!(second_done_rx.recv_timeout(WAIT).unwrap(), (Ok(Ensured::Running), true));
1089 first.join().unwrap();
1090 second.join().unwrap();
1091 assert_eq!(count.load(Ordering::SeqCst), 1);
1092 assert_eq!(registry::read_daemon_json(&paths).unwrap().pid, std::process::id());
1093 drop(daemon_lock);
1094 }
1095
1096 #[test]
1097 fn ensure_serializes_before_probe_with_missing_metadata() {
1098 serialization_case(false);
1099 }
1100
1101 #[test]
1102 fn ensure_serializes_before_probe_with_stale_metadata() {
1103 serialization_case(true);
1104 }
1105
1106 #[test]
1107 fn disabled_ensure_keeps_warm_attach_without_spawn_lock() {
1108 let dir = tempfile::tempdir().unwrap();
1109 let opts = DaemonOpts {
1110 runtime_dir: Some(dir.path().to_path_buf()),
1111 ..Default::default()
1112 };
1113 let paths = opts.paths();
1114 let _spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1115 let daemon_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1116 let ensure = || ensure_running_with(&opts, false,
1117 |_| panic!("disabled must not spawn"),
1118 || panic!("disabled must not attempt spawn lock"));
1119 assert_eq!(ensure(), Ok(Ensured::Running));
1120 drop(daemon_lock);
1121 std::fs::write(&paths.json, b"stale").unwrap();
1122 std::fs::write(&paths.pid, b"0\n").unwrap();
1123 assert_eq!(ensure(), Err(EnsureError::AutospawnDisabled));
1124 assert!(!paths.json.exists());
1125 assert!(!paths.pid.exists());
1126 }
1127}