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 SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(5);
42
43pub fn enabled() -> bool {
46 enabled_from(std::env::var("SYNAPS_DAEMON").ok().as_deref())
47}
48
49fn env_is_off(v: Option<&str>) -> bool {
50 matches!(v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("0" | "false" | "off" | "no"))
51}
52
53fn enabled_from(v: Option<&str>) -> bool {
54 !env_is_off(v)
55}
56
57pub fn autospawn_enabled() -> bool {
61 !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
62}
63
64pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
66
67pub fn allow_legacy_mcp_env() -> bool {
69 matches!(std::env::var("SYNAPS_DAEMON_ALLOW_LEGACY_MCP").as_deref(), Ok("1") | Ok("true"))
70}
71
72pub type SessionFactory = Arc<
75 dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
76 + Send
77 + Sync,
78>;
79
80#[cfg(any(test, feature = "testing"))]
82pub fn echo_factory() -> SessionFactory {
83 Arc::new(|cfg: SessionConfig| {
84 Box::pin(async move {
85 let id = SessionId(format!(
86 "echo-{}-{}",
87 chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"),
88 uuid::Uuid::new_v4().simple()
89 ));
90 let (handle, _task) = SessionHandle::echo_for_test(id);
91 let _ = cfg;
92 Ok(handle)
93 })
94 })
95}
96
97pub fn host_factory(host: &Arc<EngineHost>) -> SessionFactory {
100 let host = Arc::clone(host);
101 Arc::new(move |cfg: SessionConfig| {
102 let host = Arc::clone(&host);
103 Box::pin(async move { host.create_session(cfg).await.map_err(|e| e.to_string()) })
104 })
105}
106
107#[derive(Clone, Default)]
108pub struct DaemonOpts {
109 pub socket: Option<PathBuf>,
111 pub profile: Option<String>,
112 pub idle_exit: Option<Duration>,
116 pub allow_legacy_mcp: bool,
117 pub runtime_dir: Option<PathBuf>,
119 pub factory: Option<SessionFactory>,
121}
122
123impl DaemonOpts {
124 pub fn paths(&self) -> DaemonPaths {
125 let mut p = match &self.runtime_dir {
126 Some(d) => registry::daemon_paths_in(d, self.profile.as_deref()),
127 None => registry::daemon_paths(self.profile.as_deref()),
128 };
129 if let Some(s) = &self.socket {
130 p.sock = s.clone();
131 }
132 p
133 }
134}
135
136pub struct DaemonState {
138 pub host: Arc<EngineHost>,
139 pub paths: DaemonPaths,
140 pub profile: Option<String>,
141 pub started: Instant,
142 pub factory: SessionFactory,
143 pub connections: AtomicUsize,
144 pub shutdown: CancellationToken,
145 pub force_shutdown: AtomicBool,
146 pub generation: u64,
149 pub reloading: AtomicBool,
151 pub reload_generation: std::sync::atomic::AtomicU64,
153 pub reload_announce: Mutex<CancellationToken>,
157 pub lock_fd: Mutex<Option<DaemonLock>>,
160 pub reload_aliases: Mutex<HashMap<String, String>>,
163}
164
165impl DaemonState {
166 pub fn uptime_s(&self) -> u64 {
167 self.started.elapsed().as_secs()
168 }
169
170 pub fn announce(&self) -> CancellationToken {
172 self.reload_announce
173 .lock()
174 .unwrap_or_else(|e| e.into_inner())
175 .clone()
176 }
177
178 pub fn fire_announce(&self) {
180 self.announce().cancel();
181 }
182
183 pub fn reset_announce(&self) {
185 *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
186 }
187
188 pub fn live_sessions(&self) -> Vec<SessionHandle> {
190 self.host.session_handles()
191 }
192
193 pub fn session_metas(&self) -> Vec<SessionMeta> {
196 self.host.sessions()
197 }
198
199 pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
204 self.host.attach(id).filter(|h| h.is_alive()).or_else(|| {
205 let alias = self
206 .reload_aliases
207 .lock()
208 .unwrap_or_else(|e| e.into_inner())
209 .get(id.as_str())
210 .cloned()?;
211 self.host
212 .attach(&SessionId::from(alias.as_str()))
213 .filter(|h| h.is_alive())
214 })
215 }
216
217 pub fn insert(&self, handle: SessionHandle) {
218 self.host.adopt_session(handle);
219 }
220
221 pub fn remove(&self, id: &SessionId) {
222 self.host.remove_session(id);
223 }
224
225 pub async fn create(&self, cfg: SessionConfig) -> Result<SessionHandle, String> {
226 let handle = (self.factory)(cfg).await?;
227 self.insert(handle.clone());
228 Ok(handle)
229 }
230
231 pub async fn retry_locked_placeholder(&self, placeholder: SessionHandle) -> SessionHandle {
238 let cfg = SessionConfig {
239 continue_session: Some(Some(placeholder.id.as_str().to_string())),
240 ..placeholder.placeholder_config().cloned().unwrap_or_default()
241 };
242 self.remove(&placeholder.id);
243 match (self.factory)(cfg).await {
244 Ok(real) if real.id == placeholder.id || real.journal_id() == placeholder.id.as_str() => {
245 tracing::info!(session = %placeholder.id, "daemon: locked journal released — placeholder replaced by the real session");
246 self.insert(real.clone());
247 real
248 }
249 Ok(other) => {
250 tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
252 self.insert(other.clone());
253 other
254 }
255 Err(e) => {
256 tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
257 self.insert(placeholder.clone());
258 placeholder
259 }
260 }
261 }
262
263 pub fn request_shutdown(&self, force: bool) {
264 if force {
265 self.force_shutdown.store(true, Ordering::SeqCst);
266 }
267 self.shutdown.cancel();
268 }
269}
270
271pub struct Daemon {
273 pub paths: DaemonPaths,
274 pub state: Arc<DaemonState>,
275 accept: tokio::task::JoinHandle<()>,
276}
277
278pub fn legacy_mcp_conflict(host: &EngineHost, allow: bool) -> Option<String> {
281 if allow || allow_legacy_mcp_env() {
282 return None;
283 }
284 let cfg = host.config();
285 if !cfg.progressive_tool_disclosure && host.mcp_server_count() > 0 {
286 return Some(format!(
287 "progressive_tool_disclosure=false with {} MCP server(s) configured: legacy McpTool connections would be shared across sessions. Set progressive_tool_disclosure=true, or pass --allow-legacy-mcp / SYNAPS_DAEMON_ALLOW_LEGACY_MCP=1",
288 host.mcp_server_count()
289 ));
290 }
291 None
292}
293
294impl Daemon {
295 pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
298 let paths = opts.paths();
299 std::fs::create_dir_all(&paths.dir)?;
300 #[cfg(unix)]
301 {
302 use std::os::unix::fs::PermissionsExt;
303 std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
304 }
305 if let Some(msg) = legacy_mcp_conflict(&host, opts.allow_legacy_mcp) {
306 anyhow::bail!("refusing to start: {msg}");
307 }
308 let adopted = reload::adopt_from_env()?;
311 let (lock, reload_state) = match adopted {
312 Some((lock, rs, rs_path)) => {
313 let _ = std::fs::remove_file(&rs_path);
314 (lock, Some(rs))
315 }
316 None => {
317 registry::reap_stale(&paths);
318 let lock = match DaemonLock::try_acquire(&paths)? {
319 Some(l) => l,
320 None => {
321 let who = registry::read_daemon_json(&paths).map(|i| i.pid);
322 anyhow::bail!(
323 "another daemon holds {} (pid {})",
324 paths.lock.display(),
325 who.map_or("unknown".to_string(), |p| p.to_string())
326 );
327 }
328 };
329 (lock, None)
330 }
331 };
332 let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
333 let listener = listener::bind(&paths.sock)?;
334
335 let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
336 let state = Arc::new(DaemonState {
337 host,
338 paths: paths.clone(),
339 profile: opts.profile.clone(),
340 started: Instant::now(),
341 factory,
342 connections: AtomicUsize::new(0),
343 shutdown: CancellationToken::new(),
344 force_shutdown: AtomicBool::new(false),
345 generation,
346 reloading: AtomicBool::new(false),
347 reload_generation: std::sync::atomic::AtomicU64::new(generation),
348 reload_announce: Mutex::new(CancellationToken::new()),
349 lock_fd: Mutex::new(Some(lock)),
350 reload_aliases: Mutex::new(HashMap::new()),
351 });
352
353 let info = DaemonInfo {
354 pid: std::process::id(),
355 protocol_version: crate::session::wire::PROTOCOL_VERSION,
356 daemon_version: crate::session::wire::binary_version(),
357 profile: opts.profile.clone(),
358 started_at: chrono::Utc::now(),
359 socket: paths.sock.to_string_lossy().into_owned(),
360 exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
361 generation,
362 };
363 registry::write_daemon_json(&paths, &info)?;
364
365 if let Some(rs) = &reload_state {
367 reload::rehydrate(&state, rs).await;
368 }
369
370 let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
371 if let Some(idle) = opts.idle_exit {
372 tokio::spawn(idle_monitor(Arc::clone(&state), idle));
373 }
374 signal_ready();
375 tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
376 Ok(Self { paths, state, accept })
377 }
378
379 pub fn shutdown_token(&self) -> CancellationToken {
380 self.state.shutdown.clone()
381 }
382
383 pub async fn wait(self) {
386 self.state.shutdown.cancelled().await;
387 let force = self.state.force_shutdown.load(Ordering::SeqCst);
388 lifecycle::shutdown_all(&self.state, force).await;
389 self.accept.abort();
390 let _ = self.accept.await;
391 for p in [&self.paths.sock, &self.paths.json, &self.paths.pid] {
392 crate::events::socket::cleanup_socket(&p.to_string_lossy());
393 }
394 tracing::info!("daemon: stopped");
395 drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
397 }
398}
399
400const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
402
403pub async fn session_is_idle(handle: &SessionHandle) -> bool {
409 use crate::session::wire::IDLE_PROBE_QUERY_ID;
410 use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
411 if handle.lifecycle() == SessionLifecycle::Parked {
413 return true;
414 }
415 let mut rx = handle.subscribe();
416 if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
417 return false;
418 }
419 let probe = async {
420 loop {
421 match rx.recv().await {
422 Ok(env) => {
423 if let SessionEventWire::QueryResult { id, value } = env.event {
424 if id == IDLE_PROBE_QUERY_ID {
425 let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
428 let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
429 return !streaming && prompts == 0;
430 }
431 }
432 }
433 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
434 Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
435 }
436 }
437 };
438 tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
439}
440
441pub async fn daemon_is_idle(state: &DaemonState) -> bool {
445 if state.connections.load(Ordering::SeqCst) > 0 {
446 return false;
447 }
448 for h in state.live_sessions() {
449 if !session_is_idle(&h).await {
450 return false;
451 }
452 }
453 true
454}
455
456async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
457 let mut idle_since: Option<Instant> = None;
458 let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
459 loop {
460 tokio::select! {
461 _ = state.shutdown.cancelled() => return,
462 _ = tokio::time::sleep(tick) => {}
463 }
464 if !daemon_is_idle(&state).await {
465 idle_since = None;
466 continue;
467 }
468 let since = *idle_since.get_or_insert_with(Instant::now);
469 if since.elapsed() >= idle {
470 tracing::info!("daemon: idle for {:?}, exiting", idle);
471 state.request_shutdown(false);
472 return;
473 }
474 }
475}
476
477pub fn signal_ready() {
480 #[cfg(unix)]
481 {
482 let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
483 return;
484 };
485 std::env::remove_var(READY_FD_ENV);
486 unsafe {
488 let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
489 libc::close(fd);
490 }
491 }
492}
493
494#[cfg(unix)]
498pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
499 use std::io::Read;
500 use std::os::unix::process::CommandExt;
501
502 let exe = std::env::current_exe()?;
503 let mut fds = [0i32; 2];
504 if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
506 return Err(std::io::Error::last_os_error().into());
507 }
508 let (read_fd, write_fd) = (fds[0], fds[1]);
509
510 let mut cmd = std::process::Command::new(exe);
511 cmd.arg("daemon").arg("--foreground");
512 if let Some(p) = &opts.profile {
513 cmd.arg("--profile").arg(p);
514 }
515 if let Some(s) = &opts.socket {
516 cmd.arg("--socket").arg(s);
517 }
518 if let Some(i) = opts.idle_exit {
519 cmd.arg("--idle-exit").arg(i.as_secs().to_string());
520 }
521 if opts.allow_legacy_mcp {
522 cmd.arg("--allow-legacy-mcp");
523 }
524 cmd.env("SYNAPS_DAEMON", "1")
525 .env(READY_FD_ENV, write_fd.to_string())
526 .stdin(std::process::Stdio::null())
527 .stdout(std::process::Stdio::null())
528 .stderr(std::process::Stdio::piped());
529 unsafe {
531 cmd.pre_exec(move || {
532 libc::close(read_fd);
533 let flags = libc::fcntl(write_fd, libc::F_GETFD);
535 if flags >= 0 {
536 libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
537 }
538 libc::setsid();
539 Ok(())
540 });
541 }
542 let mut child = cmd.spawn()?;
543 unsafe { libc::close(write_fd) };
545 let pid = child.id();
546
547 let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
549 let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
550 let ready = loop {
551 let left = deadline.saturating_duration_since(Instant::now());
552 if left.is_zero() {
553 break false;
554 }
555 let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
557 if n < 0 {
558 let e = std::io::Error::last_os_error();
559 if e.kind() == std::io::ErrorKind::Interrupted {
560 continue;
561 }
562 break false;
563 }
564 if n == 0 {
565 break false;
566 }
567 let mut byte = [0u8; 1];
568 let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
570 break r == 1 && byte[0] == b'R';
571 };
572 unsafe { libc::close(read_fd) };
574
575 if ready {
576 if let Some(mut err) = child.stderr.take() {
578 std::thread::spawn(move || {
579 let mut sink = Vec::new();
580 let _ = err.read_to_end(&mut sink);
581 });
582 }
583 drop(child);
584 return Ok(pid);
585 }
586 let mut tail = String::new();
587 if let Some(mut err) = child.stderr.take() {
588 let _ = err.read_to_string(&mut tail);
589 }
590 let status = child.try_wait().ok().flatten().or_else(|| {
593 let _ = child.kill();
594 child.wait().ok()
595 });
596 let reason = tail
597 .lines()
598 .map(str::trim)
599 .rfind(|l| !l.is_empty())
600 .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
601 match (status, reason) {
602 (Some(st), Some(r)) if st.code().is_some() => {
603 anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
604 }
605 (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
606 (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
607 }
608}
609
610#[derive(Debug, Clone, PartialEq, Eq)]
612pub enum Ensured {
613 Running,
615 Spawned(u32),
617}
618
619#[derive(Debug, Clone, PartialEq, Eq)]
621pub enum EnsureError {
622 AutospawnDisabled,
624 Spawn(String),
626}
627
628impl std::fmt::Display for EnsureError {
629 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
630 match self {
631 Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
632 Self::Spawn(r) => write!(f, "{r}"),
633 }
634 }
635}
636
637const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
640
641#[cfg(unix)]
648pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
649 let paths = opts.paths();
650 registry::reap_stale(&paths);
651 if registry::is_alive(&paths) {
652 return Ok(Ensured::Running);
653 }
654 if !autospawn_enabled() {
655 return Err(EnsureError::AutospawnDisabled);
656 }
657 let _ = std::fs::create_dir_all(&paths.dir);
658 let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
659 .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
660 registry::reap_stale(&paths);
662 if registry::is_alive(&paths) {
663 return Ok(Ensured::Running);
664 }
665 let mut opts = opts.clone();
670 if opts.idle_exit.is_none() {
671 opts.idle_exit = autospawn_idle_exit();
672 }
673 spawn_detached(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
674}
675
676pub fn autospawn_idle_exit() -> Option<Duration> {
682 let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
684 autospawn_idle_exit_from(
685 std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
686 cfg_secs,
687 )
688}
689
690fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
691 let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
694 match v.map(str::trim) {
695 Some("0" | "never" | "off") => None,
696 Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
697 None => from_cfg(),
698 }
699}
700
701pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
703
704
705pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
708 if !enabled() {
709 anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
710 }
711 let host = EngineHost::boot_and_install(crate::HostOpts { profile: opts.profile.clone(), no_extensions: false }).await?;
712 if let Some(msg) = legacy_mcp_conflict(&host, opts.allow_legacy_mcp) {
713 anyhow::bail!("refusing to start: {msg} (exit {EXIT_REFUSED})");
714 }
715 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
717 let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
718 let wait = async {
719 while let Some(ev) = rx.recv().await {
720 if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
721 tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
722 break;
723 }
724 }
725 };
726 if tokio::time::timeout(Duration::from_secs(10), wait).await.is_err() {
727 tracing::warn!("daemon: extension discovery still running after 10 s; accepting anyway");
728 }
729 let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
732
733 let mut opts = opts;
734 if opts.factory.is_none() {
735 opts.factory = Some(host_factory(&host));
736 }
737 let daemon = Daemon::start(host, opts).await?;
738 let state = Arc::clone(&daemon.state);
739 tokio::spawn(async move {
740 #[cfg(unix)]
741 {
742 let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
743 tokio::select! {
744 _ = tokio::signal::ctrl_c() => {}
745 _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
746 _ = state.shutdown.cancelled() => return,
747 }
748 }
749 #[cfg(not(unix))]
750 {
751 tokio::select! {
752 _ = tokio::signal::ctrl_c() => {}
753 _ = state.shutdown.cancelled() => return,
754 }
755 }
756 tracing::info!("daemon: signal received, shutting down");
757 state.request_shutdown(false);
758 });
759 daemon.wait().await;
760 Ok(())
761}
762
763#[cfg(test)]
764mod flag_tests {
765 use super::*;
766
767 #[test]
768 fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
769 let d = AUTOSPAWN_IDLE_EXIT.as_secs();
771 assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
772 assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
773 assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
774 assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
775 for never in ["0", "never", "off"] {
776 assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
777 }
778 assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
780 assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
782 assert_eq!(autospawn_idle_exit_from(None, 0), None);
784 assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
786 }
787
788 #[test]
789 fn enabled_env_matrix() {
790 assert!(enabled_from(None), "unset → on");
791 for on in ["1", "true", "yes", "on", "", "anything"] {
792 assert!(enabled_from(Some(on)), "{on:?} → on");
793 }
794 for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
795 assert!(!enabled_from(Some(off)), "{off:?} → off");
796 }
797 }
798
799 #[test]
800 fn env_is_off_is_case_and_space_insensitive() {
801 assert!(env_is_off(Some("NO")));
802 assert!(!env_is_off(Some("1")));
803 assert!(!env_is_off(None));
804 }
805}