Skip to main content

agent_engine/daemon/
mod.rs

1//! Daemon front-end (`synaps daemon`): UDS listener, per-connection pump,
2//! registry file, lifecycle (PLAN-phase2 §2.11). Lives in `agent-engine`
3//! (S289 D-1 amended) — `agent-tui` must never appear in this graph.
4//!
5//! On by default: the first `--attach`/`attach` client auto-spawns the
6//! daemon ([`ensure_running`]); `SYNAPS_DAEMON=0` disables every daemon
7//! feature (exit 3 with a one-line reason). Safety properties that are
8//! NEVER stubbed: the flock liveness oracle, the ready-fd pipe, socket
9//! perms 0700/0600, the spawn lock (one spawner per registry dir).
10
11pub 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
31/// Exit code when the daemon refuses to start (flag off / legacy MCP).
32pub const EXIT_REFUSED: i32 = 3;
33/// Exit code for a protocol/version refusal on the client side.
34pub const EXIT_VERSION: i32 = 2;
35/// The daemon was lost (SIGKILL / crash) and reconnection failed within the
36/// budget (`SYNAPS_TUI_ATTACH_RECONNECT_SECS`).
37pub const EXIT_DAEMON_LOST: i32 = 4;
38/// Env var carrying the write end of the ready pipe to a spawned daemon.
39pub const READY_FD_ENV: &str = "SYNAPS_DAEMON_READY_FD";
40/// How long `spawn_detached` waits for the child's ready byte.
41pub const SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(5);
42
43/// `SYNAPS_DAEMON` feature flag: ON unless set to `0`/`false`/`off`/`no`.
44/// `SYNAPS_DAEMON=1` is accepted (no-op, kept for older scripts).
45pub 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
57/// `SYNAPS_DAEMON_AUTOSPAWN`: ON unless `0`/`false`/`off`/`no`. Off means
58/// an attach with no live daemon fails with the "no daemon running" message
59/// instead of starting one.
60pub fn autospawn_enabled() -> bool {
61    !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
62}
63
64/// One-line reason used by every attach path when the flag is off.
65pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
66
67/// `SYNAPS_DAEMON_ALLOW_LEGACY_MCP=1` (§4.4).
68pub fn allow_legacy_mcp_env() -> bool {
69    matches!(std::env::var("SYNAPS_DAEMON_ALLOW_LEGACY_MCP").as_deref(), Ok("1") | Ok("true"))
70}
71
72/// Builds a session for `Attach::Create`: `host_factory` (real actor) or
73/// `echo_factory` (tests).
74pub type SessionFactory = Arc<
75    dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
76        + Send
77        + Sync,
78>;
79
80/// EchoActor-backed factory (B's socket tests before A1).
81#[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
97/// The default factory for a booted host: `EngineHost::create_session` (A1) —
98/// the real `SessionActor` owning one `Runtime` + `ConversationState`.
99pub 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    /// Override the socket path (the lock/json/pid stay under `registry_dir()`).
110    pub socket: Option<PathBuf>,
111    pub profile: Option<String>,
112    /// Exit after this long with zero connections and no session running a
113    /// turn (idle, client-less sessions are saved on exit; `--continue`
114    /// brings them back). A turn in flight always blocks the exit.
115    pub idle_exit: Option<Duration>,
116    pub allow_legacy_mcp: bool,
117    /// Test seam: `registry_dir()` replacement.
118    pub runtime_dir: Option<PathBuf>,
119    /// Test seam / A1 hook: how `Attach::Create` builds a session.
120    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
136/// Shared by the accept loop and every connection.
137pub 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    // ── C3 reload ──
147    /// Reload counter (`Welcome.generation`; starts at 1).
148    pub generation: u64,
149    /// Drain: refuse `Attach::Create` / new turns while set.
150    pub reloading: AtomicBool,
151    /// Generation announced in `Reloading`/`Bye{Reloading}`.
152    pub reload_generation: std::sync::atomic::AtomicU64,
153    /// Cancelled once per reload attempt: every conn sends `Reloading` +
154    /// `Bye`. Replaced with a fresh token when the exec fails (M4) so the
155    /// surviving image accepts connections again — read via `announce()`.
156    pub reload_announce: Mutex<CancellationToken>,
157    /// The flock, held here (not on `Daemon`) so `reload` can hand its fd
158    /// to the next image.
159    pub lock_fd: Mutex<Option<DaemonLock>>,
160    /// `old id → new id` for one generation (ids differ only if a
161    /// LinkedSuccessor compaction happened since boot).
162    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    /// The current reload-announce token (clone; select on `.cancelled()`).
171    pub fn announce(&self) -> CancellationToken {
172        self.reload_announce
173            .lock()
174            .unwrap_or_else(|e| e.into_inner())
175            .clone()
176    }
177
178    /// Fire the announce (every conn says `Reloading` + `Bye`).
179    pub fn fire_announce(&self) {
180        self.announce().cancel();
181    }
182
183    /// Exec failed: arm a fresh token so new connections are served again.
184    pub fn reset_announce(&self) {
185        *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
186    }
187
188    /// B4: ONE session map — `EngineHost` owns it; these delegate.
189    pub fn live_sessions(&self) -> Vec<SessionHandle> {
190        self.host.session_handles()
191    }
192
193    /// Listing metas with lifecycle / clients / input_owner / journal_id
194    /// (B4: `EngineHost::sessions()` fills the handle cells).
195    pub fn session_metas(&self) -> Vec<SessionMeta> {
196        self.host.sessions()
197    }
198
199    /// Live handle by id; falls back to the C3 reload alias
200    /// (`old id → new id`, set when a LinkedSuccessor compaction happened
201    /// since boot) so clients reconnecting after `daemon reload` find
202    /// their session under the id they last saw.
203    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    /// F23: a lock-held placeholder (`meta.locked_by.is_some()`) is retried on
232    /// every attach. Pull it out of the map first — otherwise the host's
233    /// attach-if-live short-circuit hands the placeholder straight back —
234    /// and re-run the real `create` from the journal. Success replaces the
235    /// placeholder under the same id; still-held puts it back and the caller
236    /// attaches to it (→ `AttachRefused` naming the holder).
237    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                // Journal resolved somewhere else (e.g. compacted_into follow): keep it, drop the placeholder.
251                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
271/// A running daemon (bound socket, lock held, accept loop spawned).
272pub struct Daemon {
273    pub paths: DaemonPaths,
274    pub state: Arc<DaemonState>,
275    accept: tokio::task::JoinHandle<()>,
276}
277
278/// Refuse-to-start check (§2.11): legacy `McpTool` connections would be
279/// shared across sessions.
280pub 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    /// Reap stale files, take the flock, bind (dir 0700 / sock 0600), write
296    /// `daemon.json`, spawn the accept loop, signal the ready fd if any.
297    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        // C3: after `reload` the new image ADOPTS the inherited flock and
309        // skips `reap_stale` (the old image's files are ours).
310        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        // C3: rehydrate BEFORE accepting so a reconnecting client finds its session.
366        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    /// Block until shutdown is requested (frame, signal, idle), then end
384    /// every session inside one budget and unlink the files.
385    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        // Release the flock last.
396        drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
397    }
398}
399
400/// How long a `Status` probe may take before the session counts as busy.
401const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
402
403/// Whether `handle`'s actor is between turns. Asks `Query{Status}` under the
404/// reserved [`crate::session::wire::IDLE_PROBE_QUERY_ID`] (transports swallow
405/// it) and reads `streaming`. A no-answer inside [`IDLE_PROBE_TIMEOUT`] —
406/// the actor is inside `compact()`/preflight, or its queue is full — counts
407/// as busy: the daemon never exits under a running turn (jcode mistake #1).
408pub 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    // B3: a parked session is idle by construction — no probe, no wake.
412    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                            // Real actor: {"streaming": bool, "pending_prompts": n}. A backend that
426                            // does not implement Status (echo) has no turns to protect.
427                            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
441/// Idle = zero connections AND every live session between turns, held for
442/// `idle` continuously. Clients count first so a probe is never sent while
443/// someone is attached.
444pub 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
477/// Child side of the ready pipe: write `R` to `SYNAPS_DAEMON_READY_FD` (if
478/// set), close it, and scrub the env so tool subprocesses do not inherit it.
479pub 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        // SAFETY: fd is the write end handed to us by the parent; we own it.
487        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/// Parent side: `current_exe daemon --foreground …` in its own session
495/// (`setsid`), stdout → null, stderr → pipe; waits ≤ 5 s for `R` on the
496/// ready pipe. EOF before `R` = child died → error with its stderr tail.
497#[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    // SAFETY: plain pipe(2) on a valid 2-slot array.
505    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    // SAFETY: pre_exec runs in the forked child before exec; only async-signal-safe calls.
530    unsafe {
531        cmd.pre_exec(move || {
532            libc::close(read_fd);
533            // Clear CLOEXEC on the write end so it survives exec.
534            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    // SAFETY: parent no longer needs the write end; closing it makes EOF mean "child died".
544    unsafe { libc::close(write_fd) };
545    let pid = child.id();
546
547    // Wait for 'R' with a deadline (poll(2) on the read end).
548    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        // SAFETY: one valid pollfd.
556        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        // SAFETY: reading one byte into a 1-byte buffer.
569        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    // SAFETY: done with the read end.
573    unsafe { libc::close(read_fd) };
574
575    if ready {
576        // Keep draining stderr so the child never blocks on a full pipe.
577        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    // EOF before `R`: the child is gone (or dying) — collect its status. A
591    // still-running child that just never signalled is killed.
592    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/// Result of [`ensure_running`].
611#[derive(Debug, Clone, PartialEq, Eq)]
612pub enum Ensured {
613    /// A daemon already held the flock.
614    Running,
615    /// We spawned one (pid) and it signalled ready.
616    Spawned(u32),
617}
618
619/// Why [`ensure_running`] could not hand back a live daemon.
620#[derive(Debug, Clone, PartialEq, Eq)]
621pub enum EnsureError {
622    /// `SYNAPS_DAEMON_AUTOSPAWN=0` and nobody is running.
623    AutospawnDisabled,
624    /// The spawn failed or the child refused/died: one-line reason.
625    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
637/// How long a second first-client waits for the spawn lock (the winner's
638/// `spawn_detached` is bounded by [`SPAWN_READY_TIMEOUT`]).
639const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
640
641/// Attach-path entry: a live daemon or a freshly spawned one (jcode model —
642/// first client spawns via `setsid` + ready-fd; the daemon holds the flock).
643/// The **spawn lock** (`<stem>.spawn.lock` next to the flock) serialises
644/// concurrent first-clients: the loser blocks on it, then re-probes the
645/// flock and finds the winner's daemon. Never touches the socket on
646/// ECONNREFUSED alone — `reap_stale` + the flock decide.
647#[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    // Somebody may have spawned while we waited.
661    registry::reap_stale(&paths);
662    if registry::is_alive(&paths) {
663        return Ok(Ensured::Running);
664    }
665    // An auto-spawned daemon exits on its own once the last client is gone
666    // and no turn is running (parked sessions are journaled; `--continue`
667    // brings them back). A daemon started by hand (`synaps daemon`) keeps
668    // whatever its caller asked for.
669    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
676/// `SYNAPS_DAEMON_IDLE_EXIT_SECS` for auto-spawned daemons: default 10 s —
677/// when the last client disconnects and no turn is running, the daemon is
678/// holding ~100 MB for one reason only: a re-launch inside the window skips
679/// extension boot. Ten seconds covers "oops, wrong window"; `0`/`never` →
680/// never exit on idle.
681pub fn autospawn_idle_exit() -> Option<Duration> {
682    // Precedence: env > config (`daemon.idle_exit_secs`) > built-in default.
683    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    // `0` (env or config) disables idle-exit; otherwise config is the default
692    // when the env var is absent or unparseable.
693    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
701/// Default idle grace for auto-spawned daemons.
702pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
703
704
705/// `synaps daemon --foreground` body: gate → boot host → extension discovery
706/// once → start → wait for SIGTERM/SIGINT/Shutdown frame/idle.
707pub 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    // Sidecars spawn once per daemon: discover before accepting (bounded 10 s).
716    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    // C3 router: widget.* frames from every sidecar fan out to every live
730    // session (daemon-global widgets today — frames carry no session id).
731    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        // cfg_secs = 10 mirrors the DaemonConfig default.
770        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        // garbage env → the config default, never "no exit"
779        assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
780        // env absent → config value is the default (env > config > builtin).
781        assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
782        // config 0 disables idle-exit when env is absent.
783        assert_eq!(autospawn_idle_exit_from(None, 0), None);
784        // env still wins over config.
785        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}