1pub mod conn;
12pub mod lifecycle;
13pub mod listener;
14pub mod registry;
15pub mod reload;
16
17use std::collections::HashMap;
18use std::future::Future;
19use std::path::PathBuf;
20use std::pin::Pin;
21use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
22use std::sync::{Arc, Mutex};
23use std::time::{Duration, Instant};
24
25use tokio_util::sync::CancellationToken;
26
27use crate::session::{SessionConfig, SessionHandle, SessionId, SessionMeta};
28use crate::EngineHost;
29use registry::{DaemonInfo, DaemonLock, DaemonPaths};
30
31pub const EXIT_REFUSED: i32 = 3;
33pub const EXIT_VERSION: i32 = 2;
35pub const EXIT_DAEMON_LOST: i32 = 4;
38pub const READY_FD_ENV: &str = "SYNAPS_DAEMON_READY_FD";
40pub const EXTENSION_DISCOVERY_WAIT: Duration = Duration::from_secs(10);
44const SPAWN_READY_MARGIN: Duration = Duration::from_secs(5);
47pub const SPAWN_READY_TIMEOUT: Duration =
52 Duration::from_secs(EXTENSION_DISCOVERY_WAIT.as_secs() + SPAWN_READY_MARGIN.as_secs());
53const _: () = assert!(
56 SPAWN_READY_TIMEOUT.as_secs() > EXTENSION_DISCOVERY_WAIT.as_secs(),
57 "SPAWN_READY_TIMEOUT must exceed EXTENSION_DISCOVERY_WAIT"
58);
59
60pub fn enabled() -> bool {
63 enabled_from(std::env::var("SYNAPS_DAEMON").ok().as_deref())
64}
65
66fn env_is_off(v: Option<&str>) -> bool {
67 matches!(v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("0" | "false" | "off" | "no"))
68}
69
70fn enabled_from(v: Option<&str>) -> bool {
71 !env_is_off(v)
72}
73
74pub fn autospawn_enabled() -> bool {
78 !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
79}
80
81pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
83
84pub type SessionFactory = Arc<
87 dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
88 + Send
89 + Sync,
90>;
91
92#[cfg(any(test, feature = "testing"))]
94pub fn echo_factory() -> SessionFactory {
95 Arc::new(|cfg: SessionConfig| {
96 Box::pin(async move {
97 let id = SessionId(format!(
98 "echo-{}-{}",
99 chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"),
100 uuid::Uuid::new_v4().simple()
101 ));
102 let (handle, _task) = SessionHandle::echo_for_test(id);
103 let _ = cfg;
104 Ok(handle)
105 })
106 })
107}
108
109pub fn host_factory(host: &Arc<EngineHost>) -> SessionFactory {
112 let host = Arc::clone(host);
113 Arc::new(move |cfg: SessionConfig| {
114 let host = Arc::clone(&host);
115 Box::pin(async move { host.create_session(cfg).await.map_err(|e| e.to_string()) })
116 })
117}
118
119#[derive(Clone, Default)]
120pub struct DaemonOpts {
121 pub socket: Option<PathBuf>,
123 pub profile: Option<String>,
124 pub idle_exit: Option<Duration>,
128 pub allow_legacy_mcp: bool,
132 pub runtime_dir: Option<PathBuf>,
134 pub factory: Option<SessionFactory>,
136}
137
138impl DaemonOpts {
139 pub fn paths(&self) -> DaemonPaths {
140 let mut p = match &self.runtime_dir {
141 Some(d) => registry::daemon_paths_in(d, self.profile.as_deref()),
142 None => registry::daemon_paths(self.profile.as_deref()),
143 };
144 if let Some(s) = &self.socket {
145 p.sock = s.clone();
146 }
147 p
148 }
149}
150
151pub struct DaemonState {
153 pub host: Arc<EngineHost>,
154 pub paths: DaemonPaths,
155 pub profile: Option<String>,
156 pub started: Instant,
157 pub factory: SessionFactory,
158 pub connections: AtomicUsize,
159 pub shutdown: CancellationToken,
160 pub force_shutdown: AtomicBool,
161 pub generation: u64,
164 pub reloading: AtomicBool,
166 pub reload_generation: std::sync::atomic::AtomicU64,
168 pub reload_announce: Mutex<CancellationToken>,
172 pub lock_fd: Mutex<Option<DaemonLock>>,
175 pub reload_aliases: Mutex<HashMap<String, String>>,
178}
179
180impl DaemonState {
181 pub fn uptime_s(&self) -> u64 {
182 self.started.elapsed().as_secs()
183 }
184
185 pub fn announce(&self) -> CancellationToken {
187 self.reload_announce
188 .lock()
189 .unwrap_or_else(|e| e.into_inner())
190 .clone()
191 }
192
193 pub fn fire_announce(&self) {
195 self.announce().cancel();
196 }
197
198 pub fn reset_announce(&self) {
200 *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
201 }
202
203 pub fn live_sessions(&self) -> Vec<SessionHandle> {
205 self.host.session_handles()
206 }
207
208 pub fn session_metas(&self) -> Vec<SessionMeta> {
211 self.host.sessions()
212 }
213
214 pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
219 self.host.attach(id).filter(|h| h.is_alive()).or_else(|| {
220 let alias = self
221 .reload_aliases
222 .lock()
223 .unwrap_or_else(|e| e.into_inner())
224 .get(id.as_str())
225 .cloned()?;
226 self.host
227 .attach(&SessionId::from(alias.as_str()))
228 .filter(|h| h.is_alive())
229 })
230 }
231
232 pub fn insert(&self, handle: SessionHandle) {
233 self.host.adopt_session(handle);
234 }
235
236 pub fn remove(&self, id: &SessionId) {
237 self.host.remove_session(id);
238 }
239
240 pub async fn create(&self, cfg: SessionConfig) -> Result<SessionHandle, String> {
241 let handle = (self.factory)(cfg).await?;
242 self.insert(handle.clone());
243 Ok(handle)
244 }
245
246 pub async fn retry_locked_placeholder(&self, placeholder: SessionHandle) -> SessionHandle {
253 let cfg = SessionConfig {
254 continue_session: Some(Some(placeholder.id.as_str().to_string())),
255 ..placeholder.placeholder_config().cloned().unwrap_or_default()
256 };
257 self.remove(&placeholder.id);
258 match (self.factory)(cfg).await {
259 Ok(real) if real.id == placeholder.id || real.journal_id() == placeholder.id.as_str() => {
260 tracing::info!(session = %placeholder.id, "daemon: locked journal released — placeholder replaced by the real session");
261 self.insert(real.clone());
262 real
263 }
264 Ok(other) => {
265 tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
267 self.insert(other.clone());
268 other
269 }
270 Err(e) => {
271 tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
272 self.insert(placeholder.clone());
273 placeholder
274 }
275 }
276 }
277
278 pub fn request_shutdown(&self, force: bool) {
279 if force {
280 self.force_shutdown.store(true, Ordering::SeqCst);
281 }
282 self.shutdown.cancel();
283 }
284}
285
286pub struct Daemon {
288 pub paths: DaemonPaths,
289 pub state: Arc<DaemonState>,
290 accept: tokio::task::JoinHandle<()>,
291}
292
293impl Daemon {
294 pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
297 let paths = opts.paths();
298 std::fs::create_dir_all(&paths.dir)?;
299 #[cfg(unix)]
300 {
301 use std::os::unix::fs::PermissionsExt;
302 std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
303 }
304 let adopted = reload::adopt_from_env()?;
307 let (lock, reload_state) = match adopted {
308 Some((lock, rs, rs_path)) => {
309 let _ = std::fs::remove_file(&rs_path);
310 (lock, Some(rs))
311 }
312 None => {
313 registry::reap_stale(&paths);
314 let lock = match DaemonLock::try_acquire(&paths)? {
315 Some(l) => l,
316 None => {
317 let who = registry::read_daemon_json(&paths).map(|i| i.pid);
318 anyhow::bail!(
319 "another daemon holds {} (pid {})",
320 paths.lock.display(),
321 who.map_or("unknown".to_string(), |p| p.to_string())
322 );
323 }
324 };
325 (lock, None)
326 }
327 };
328 let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
329 let listener = listener::bind(&paths.sock)?;
330
331 let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
332 let state = Arc::new(DaemonState {
333 host,
334 paths: paths.clone(),
335 profile: opts.profile.clone(),
336 started: Instant::now(),
337 factory,
338 connections: AtomicUsize::new(0),
339 shutdown: CancellationToken::new(),
340 force_shutdown: AtomicBool::new(false),
341 generation,
342 reloading: AtomicBool::new(false),
343 reload_generation: std::sync::atomic::AtomicU64::new(generation),
344 reload_announce: Mutex::new(CancellationToken::new()),
345 lock_fd: Mutex::new(Some(lock)),
346 reload_aliases: Mutex::new(HashMap::new()),
347 });
348
349 let info = DaemonInfo {
350 pid: std::process::id(),
351 protocol_version: crate::session::wire::PROTOCOL_VERSION,
352 daemon_version: crate::session::wire::binary_version(),
353 profile: opts.profile.clone(),
354 started_at: chrono::Utc::now(),
355 socket: paths.sock.to_string_lossy().into_owned(),
356 exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
357 generation,
358 };
359 registry::write_daemon_json(&paths, &info)?;
360
361 let own_pid = reload_state.as_ref().map(|_| info.pid);
369 let swept = crate::events::registry::sweep_stale_registrations(own_pid);
370 if swept > 0 {
371 tracing::info!(swept, "daemon: removed stale session registrations");
372 }
373
374 if let Some(rs) = &reload_state {
376 reload::rehydrate(&state, rs).await;
377 }
378
379 let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
380 if let Some(idle) = opts.idle_exit {
381 tokio::spawn(idle_monitor(Arc::clone(&state), idle));
382 }
383 signal_ready();
384 tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
385 Ok(Self { paths, state, accept })
386 }
387
388 pub fn shutdown_token(&self) -> CancellationToken {
389 self.state.shutdown.clone()
390 }
391
392 pub async fn wait(self) {
395 self.state.shutdown.cancelled().await;
396 let force = self.state.force_shutdown.load(Ordering::SeqCst);
397 lifecycle::shutdown_all(&self.state, force).await;
398 self.accept.abort();
399 let _ = self.accept.await;
400 crate::events::socket::cleanup_socket(&self.paths.sock.to_string_lossy());
401 {
402 let lock = self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
403 if let Some(lock) = lock.as_ref() {
404 registry::cleanup_metadata(&self.paths, lock);
405 }
406 }
407 tracing::info!("daemon: stopped");
408 drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
410 }
411}
412
413const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
415
416pub async fn session_is_idle(handle: &SessionHandle) -> bool {
422 use crate::session::wire::IDLE_PROBE_QUERY_ID;
423 use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
424 if handle.lifecycle() == SessionLifecycle::Parked {
426 return true;
427 }
428 let mut rx = handle.subscribe();
429 if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
430 return false;
431 }
432 let probe = async {
433 loop {
434 match rx.recv().await {
435 Ok(env) => {
436 if let SessionEventWire::QueryResult { id, value } = env.event {
437 if id == IDLE_PROBE_QUERY_ID {
438 let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
441 let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
442 return !streaming && prompts == 0;
443 }
444 }
445 }
446 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
447 Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
448 }
449 }
450 };
451 tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
452}
453
454pub async fn daemon_is_idle(state: &DaemonState) -> bool {
458 if state.connections.load(Ordering::SeqCst) > 0 {
459 return false;
460 }
461 for h in state.live_sessions() {
462 if !session_is_idle(&h).await {
463 return false;
464 }
465 }
466 true
467}
468
469async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
470 let mut idle_since: Option<Instant> = None;
471 let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
472 loop {
473 tokio::select! {
474 _ = state.shutdown.cancelled() => return,
475 _ = tokio::time::sleep(tick) => {}
476 }
477 if !daemon_is_idle(&state).await {
478 idle_since = None;
479 continue;
480 }
481 let since = *idle_since.get_or_insert_with(Instant::now);
482 if since.elapsed() >= idle {
483 tracing::info!("daemon: idle for {:?}, exiting", idle);
484 state.request_shutdown(false);
485 return;
486 }
487 }
488}
489
490pub fn signal_ready() {
493 #[cfg(unix)]
494 {
495 let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
496 return;
497 };
498 std::env::remove_var(READY_FD_ENV);
499 unsafe {
501 let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
502 libc::close(fd);
503 }
504 }
505}
506
507#[cfg(unix)]
512pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
513 use std::io::Read;
514 use std::os::unix::process::CommandExt;
515
516 let exe = std::env::current_exe()?;
517
518
519 let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
520 if let Some(p) = &opts.profile {
521 cmd_args.extend(["--profile".into(), p.into()]);
522 }
523 if let Some(s) = &opts.socket {
524 cmd_args.extend(["--socket".into(), s.into()]);
525 }
526 if let Some(i) = opts.idle_exit {
527 cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
528 }
529 if opts.allow_legacy_mcp {
530 cmd_args.push("--allow-legacy-mcp".into());
531 }
532 let mut effective_env = crate::process_scope::inherited_env();
535 crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
536 effective_env.retain(|(k, _)| k != READY_FD_ENV);
537 let (program, cmd_args) = match crate::process_scope::launcher_for_effective_env(&effective_env, None) {
538 Some(launcher) => launcher.wrap("synaps daemon", exe, cmd_args),
539 None => (exe.into_os_string(), cmd_args),
540 };
541 let mut fds = [0i32; 2];
542 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
544 return Err(std::io::Error::last_os_error().into());
545 }
546 let (read_fd, write_fd) = (fds[0], fds[1]);
547 for fd in [read_fd, write_fd] {
549 if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
550 let error = std::io::Error::last_os_error();
551 unsafe { libc::close(read_fd); libc::close(write_fd); }
552 return Err(error.into());
553 }
554 }
555 crate::process_scope::set_env(&mut effective_env, READY_FD_ENV, write_fd.to_string());
556 let mut cmd = std::process::Command::new(program);
557 cmd.args(cmd_args);
558 cmd.env_clear().envs(effective_env)
559 .stdin(std::process::Stdio::null())
560 .stdout(std::process::Stdio::null())
561 .stderr(std::process::Stdio::piped());
562 unsafe {
564 cmd.pre_exec(move || {
565 libc::close(read_fd);
566 let flags = libc::fcntl(write_fd, libc::F_GETFD);
568 if flags >= 0 {
569 libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
570 }
571 libc::setsid();
572 Ok(())
573 });
574 }
575 let mut child = match cmd.spawn() {
576 Ok(child) => child,
577 Err(error) => {
578 unsafe { libc::close(read_fd); libc::close(write_fd); }
579 return Err(error.into());
580 }
581 };
582 unsafe { libc::close(write_fd) };
584 let pid = child.id();
585
586 let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
588 let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
589 let ready = loop {
590 let left = deadline.saturating_duration_since(Instant::now());
591 if left.is_zero() {
592 break false;
593 }
594 let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
596 if n < 0 {
597 let e = std::io::Error::last_os_error();
598 if e.kind() == std::io::ErrorKind::Interrupted {
599 continue;
600 }
601 break false;
602 }
603 if n == 0 {
604 break false;
605 }
606 let mut byte = [0u8; 1];
607 let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
609 break r == 1 && byte[0] == b'R';
610 };
611 unsafe { libc::close(read_fd) };
613
614 if ready {
615 if let Some(mut err) = child.stderr.take() {
617 std::thread::spawn(move || {
618 let mut sink = Vec::new();
619 let _ = err.read_to_end(&mut sink);
620 });
621 }
622 drop(child);
623 return Ok(pid);
624 }
625 let status = child.try_wait().ok().flatten().or_else(|| {
627 let _ = child.kill();
628 child.wait().ok()
629 });
630 let mut tail = String::new();
631 if let Some(mut err) = child.stderr.take() {
632 use std::os::fd::AsRawFd;
633 let fd = err.as_raw_fd();
634 let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
635 if flags >= 0 && unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } >= 0 {
636 let mut bytes = Vec::new();
639 let _ = err.by_ref().take(64 * 1024).read_to_end(&mut bytes);
640 tail = String::from_utf8_lossy(&bytes).into_owned();
641 }
642 }
643 let reason = tail
644 .lines()
645 .map(str::trim)
646 .rfind(|l| !l.is_empty())
647 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
648 match (status, reason) {
649 (Some(st), Some(r)) if st.code().is_some() => {
650 anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
651 }
652 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
653 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
654 }
655}
656
657#[derive(Debug, Clone, PartialEq, Eq)]
659pub enum Ensured {
660 Running,
662 Spawned(u32),
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum EnsureError {
669 AutospawnDisabled,
671 Spawn(String),
673}
674
675impl std::fmt::Display for EnsureError {
676 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
677 match self {
678 Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
679 Self::Spawn(r) => write!(f, "{r}"),
680 }
681 }
682}
683
684const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
687
688#[cfg(unix)]
698pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
699 ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
700}
701
702#[cfg(unix)]
705fn ensure_running_with(
706 opts: &DaemonOpts,
707 auto: bool,
708 spawn: impl FnOnce(&DaemonOpts) -> anyhow::Result<u32>,
709 before_spawn_lock: impl FnOnce(),
710) -> Result<Ensured, EnsureError> {
711 let paths = opts.paths();
712 if !auto {
713 registry::reap_stale(&paths);
715 return if registry::is_alive(&paths) {
716 Ok(Ensured::Running)
717 } else {
718 Err(EnsureError::AutospawnDisabled)
719 };
720 }
721 let _ = std::fs::create_dir_all(&paths.dir);
722 before_spawn_lock();
723 let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
724 .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
725 registry::reap_stale(&paths);
729 if registry::is_alive(&paths) {
730 return Ok(Ensured::Running);
731 }
732 let mut opts = opts.clone();
737 if opts.idle_exit.is_none() {
738 opts.idle_exit = autospawn_idle_exit();
739 }
740 spawn(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
741}
742
743pub fn autospawn_idle_exit() -> Option<Duration> {
749 let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
751 autospawn_idle_exit_from(
752 std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
753 cfg_secs,
754 )
755}
756
757fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
758 let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
761 match v.map(str::trim) {
762 Some("0" | "never" | "off") => None,
763 Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
764 None => from_cfg(),
765 }
766}
767
768pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
770
771
772pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
776 if !enabled() {
777 anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
778 }
779 let host = EngineHost::boot_and_install(crate::HostOpts {
782 profile: opts.profile.clone(),
783 no_extensions: false,
784 mcp_discovery: crate::McpDiscovery::Background,
785 })
786 .await?;
787 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
790 let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
791 let wait = async {
792 while let Some(ev) = rx.recv().await {
793 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
794 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
795 break;
796 }
797 }
798 };
799 if tokio::time::timeout(EXTENSION_DISCOVERY_WAIT, wait).await.is_err() {
800 tracing::warn!(
801 wait = ?EXTENSION_DISCOVERY_WAIT,
802 "daemon: extension discovery still running; accepting anyway"
803 );
804 }
805 let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
808
809 let mut opts = opts;
810 if opts.factory.is_none() {
811 opts.factory = Some(host_factory(&host));
812 }
813 let mcp_host = Arc::clone(&host);
814 let daemon = Daemon::start(host, opts).await?;
815 let _mcp_discovery = mcp_host.spawn_background_mcp_discovery();
819 drop(mcp_host);
820 let state = Arc::clone(&daemon.state);
821 tokio::spawn(async move {
822 #[cfg(unix)]
823 {
824 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
825 tokio::select! {
826 _ = tokio::signal::ctrl_c() => {}
827 _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
828 _ = state.shutdown.cancelled() => return,
829 }
830 }
831 #[cfg(not(unix))]
832 {
833 tokio::select! {
834 _ = tokio::signal::ctrl_c() => {}
835 _ = state.shutdown.cancelled() => return,
836 }
837 }
838 tracing::info!("daemon: signal received, shutting down");
839 state.request_shutdown(false);
840 });
841 daemon.wait().await;
842 Ok(())
843}
844
845#[cfg(test)]
846mod flag_tests {
847 use super::*;
848
849 #[test]
850 fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
851 let d = AUTOSPAWN_IDLE_EXIT.as_secs();
853 assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
854 assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
855 assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
856 assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
857 for never in ["0", "never", "off"] {
858 assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
859 }
860 assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
862 assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
864 assert_eq!(autospawn_idle_exit_from(None, 0), None);
866 assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
868 }
869
870 #[test]
871 fn enabled_env_matrix() {
872 assert!(enabled_from(None), "unset → on");
873 for on in ["1", "true", "yes", "on", "", "anything"] {
874 assert!(enabled_from(Some(on)), "{on:?} → on");
875 }
876 for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
877 assert!(!enabled_from(Some(off)), "{off:?} → off");
878 }
879 }
880
881 #[test]
882 fn env_is_off_is_case_and_space_insensitive() {
883 assert!(env_is_off(Some("NO")));
884 assert!(!env_is_off(Some("1")));
885 assert!(!env_is_off(None));
886 }
887}
888
889#[cfg(all(test, unix))]
890mod ensure_tests {
891 use super::*;
892 use std::sync::mpsc;
893
894 const WAIT: Duration = Duration::from_secs(3);
895 const BLOCKED: Duration = Duration::from_millis(100);
896
897 fn serialization_case(stale_metadata: bool) {
898 let dir = tempfile::tempdir().unwrap();
899 let opts = DaemonOpts {
900 runtime_dir: Some(dir.path().to_path_buf()),
901 profile: Some("ensure-serialization".into()),
902 idle_exit: Some(Duration::from_secs(1)),
904 ..Default::default()
905 };
906 let paths = opts.paths();
907 let spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
908 let cleanup_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
909 if stale_metadata {
910 std::fs::write(&paths.json, b"stale metadata").unwrap();
911 std::fs::write(&paths.pid, b"0\n").unwrap();
912 }
913 let count = Arc::new(AtomicUsize::new(0));
914 let ready = Arc::new(AtomicBool::new(false));
915 let (attempt_tx, attempt_rx) = mpsc::channel();
916 let (daemon_tx, daemon_rx) = mpsc::channel();
917 let (ready_tx, ready_rx) = mpsc::channel();
918 let (done_tx, done_rx) = mpsc::channel();
919 let first_opts = opts.clone();
920 let first_count = Arc::clone(&count);
921 let first_ready = Arc::clone(&ready);
922 let first = std::thread::spawn(move || {
923 let result = ensure_running_with(&first_opts, true, |opts| {
924 first_count.fetch_add(1, Ordering::SeqCst);
925 let paths = opts.paths();
926 assert!(!paths.json.exists());
928 assert!(!paths.pid.exists());
929 let lock = DaemonLock::try_acquire(&paths)?.expect("daemon lock free after reap");
930 let pid = std::process::id();
931 registry::write_daemon_json(&paths, &DaemonInfo {
932 pid,
933 protocol_version: 1,
934 daemon_version: "test".into(),
935 profile: opts.profile.clone(),
936 started_at: chrono::Utc::now(),
937 socket: paths.sock.display().to_string(),
938 exe: None,
939 generation: 1,
940 })?;
941 daemon_tx.send(lock).unwrap();
943 ready_rx.recv_timeout(WAIT).unwrap();
944 first_ready.store(true, Ordering::SeqCst);
945 Ok(pid)
946 }, || attempt_tx.send(()).unwrap());
947 done_tx.send(result).unwrap();
948 });
949 attempt_rx.recv_timeout(WAIT).unwrap();
951 assert_eq!(done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
952 assert_eq!(count.load(Ordering::SeqCst), 0);
953 assert_eq!(paths.json.exists(), stale_metadata);
954 assert_eq!(paths.pid.exists(), stale_metadata);
955 drop(cleanup_lock);
956 drop(spawn_lock);
957 let daemon_lock = daemon_rx.recv_timeout(WAIT).unwrap();
958
959 let (second_attempt_tx, second_attempt_rx) = mpsc::channel();
962 let (second_done_tx, second_done_rx) = mpsc::channel();
963 let second_count = Arc::clone(&count);
964 let second_ready = Arc::clone(&ready);
965 let second = std::thread::spawn(move || {
966 let result = ensure_running_with(&opts, true, |_| {
967 second_count.fetch_add(1, Ordering::SeqCst);
968 panic!("warm attach must not spawn");
969 }, || second_attempt_tx.send(()).unwrap());
970 second_done_tx.send((result, second_ready.load(Ordering::SeqCst))).unwrap();
971 });
972 second_attempt_rx.recv_timeout(WAIT).unwrap();
973 assert_eq!(second_done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
974 assert!(!ready.load(Ordering::SeqCst));
975 ready_tx.send(()).unwrap();
976 assert_eq!(done_rx.recv_timeout(WAIT).unwrap(), Ok(Ensured::Spawned(std::process::id())));
977 assert_eq!(second_done_rx.recv_timeout(WAIT).unwrap(), (Ok(Ensured::Running), true));
978 first.join().unwrap();
979 second.join().unwrap();
980 assert_eq!(count.load(Ordering::SeqCst), 1);
981 assert_eq!(registry::read_daemon_json(&paths).unwrap().pid, std::process::id());
982 drop(daemon_lock);
983 }
984
985 #[test]
986 fn ensure_serializes_before_probe_with_missing_metadata() {
987 serialization_case(false);
988 }
989
990 #[test]
991 fn ensure_serializes_before_probe_with_stale_metadata() {
992 serialization_case(true);
993 }
994
995 #[test]
996 fn disabled_ensure_keeps_warm_attach_without_spawn_lock() {
997 let dir = tempfile::tempdir().unwrap();
998 let opts = DaemonOpts {
999 runtime_dir: Some(dir.path().to_path_buf()),
1000 ..Default::default()
1001 };
1002 let paths = opts.paths();
1003 let _spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1004 let daemon_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1005 let ensure = || ensure_running_with(&opts, false,
1006 |_| panic!("disabled must not spawn"),
1007 || panic!("disabled must not attempt spawn lock"));
1008 assert_eq!(ensure(), Ok(Ensured::Running));
1009 drop(daemon_lock);
1010 std::fs::write(&paths.json, b"stale").unwrap();
1011 std::fs::write(&paths.pid, b"0\n").unwrap();
1012 assert_eq!(ensure(), Err(EnsureError::AutospawnDisabled));
1013 assert!(!paths.json.exists());
1014 assert!(!paths.pid.exists());
1015 }
1016}