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/// Upper bound `run_foreground` waits for extension discovery before it
41/// starts accepting and signals ready (sidecars spawn once per daemon, and
42/// early sessions must see extension tools/hooks).
43pub const EXTENSION_DISCOVERY_WAIT: Duration = Duration::from_secs(10);
44/// Headroom for the rest of the daemon's pre-ready work (host boot with
45/// cached MCP descriptors only, bind, registry files) and a loaded machine.
46const SPAWN_READY_MARGIN: Duration = Duration::from_secs(5);
47/// How long `spawn_detached` waits for the child's ready byte. Strictly
48/// greater than the daemon's bounded pre-ready work: the extension wait plus
49/// a margin. MCP discovery never counts here — the daemon runs it in the
50/// background after it is accepting ([`crate::McpDiscovery::Background`]).
51pub const SPAWN_READY_TIMEOUT: Duration =
52    Duration::from_secs(EXTENSION_DISCOVERY_WAIT.as_secs() + SPAWN_READY_MARGIN.as_secs());
53// The spawner must outwait the daemon's bounded pre-ready work, or one slow
54// extension walk makes every auto-start fail (killed before `R`).
55const _: () = assert!(
56    SPAWN_READY_TIMEOUT.as_secs() > EXTENSION_DISCOVERY_WAIT.as_secs(),
57    "SPAWN_READY_TIMEOUT must exceed EXTENSION_DISCOVERY_WAIT"
58);
59
60/// `SYNAPS_DAEMON` feature flag: ON unless set to `0`/`false`/`off`/`no`.
61/// `SYNAPS_DAEMON=1` is accepted (no-op, kept for older scripts).
62pub 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
74/// `SYNAPS_DAEMON_AUTOSPAWN`: ON unless `0`/`false`/`off`/`no`. Off means
75/// an attach with no live daemon fails with the "no daemon running" message
76/// instead of starting one.
77pub fn autospawn_enabled() -> bool {
78    !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
79}
80
81/// One-line reason used by every attach path when the flag is off.
82pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
83
84/// Builds a session for `Attach::Create`: `host_factory` (real actor) or
85/// `echo_factory` (tests).
86pub type SessionFactory = Arc<
87    dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
88        + Send
89        + Sync,
90>;
91
92/// EchoActor-backed factory (B's socket tests before A1).
93#[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
109/// The default factory for a booted host: `EngineHost::create_session` (A1) —
110/// the real `SessionActor` owning one `Runtime` + `ConversationState`.
111pub 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    /// Override the socket path (the lock/json/pid stay under `registry_dir()`).
122    pub socket: Option<PathBuf>,
123    pub profile: Option<String>,
124    /// Exit after this long with zero connections and no session running a
125    /// turn (idle, client-less sessions are saved on exit; `--continue`
126    /// brings them back). A turn in flight always blocks the exit.
127    pub idle_exit: Option<Duration>,
128    /// Deprecated, ignored: legacy MCP was removed (every mode uses
129    /// per-session MCP leases). Still forwarded so an older client can spawn
130    /// a newer daemon.
131    pub allow_legacy_mcp: bool,
132    /// Test seam: `registry_dir()` replacement.
133    pub runtime_dir: Option<PathBuf>,
134    /// Test seam / A1 hook: how `Attach::Create` builds a session.
135    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
151/// Shared by the accept loop and every connection.
152pub 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    // ── C3 reload ──
162    /// Reload counter (`Welcome.generation`; starts at 1).
163    pub generation: u64,
164    /// Drain: refuse `Attach::Create` / new turns while set.
165    pub reloading: AtomicBool,
166    /// Generation announced in `Reloading`/`Bye{Reloading}`.
167    pub reload_generation: std::sync::atomic::AtomicU64,
168    /// Cancelled once per reload attempt: every conn sends `Reloading` +
169    /// `Bye`. Replaced with a fresh token when the exec fails (M4) so the
170    /// surviving image accepts connections again — read via `announce()`.
171    pub reload_announce: Mutex<CancellationToken>,
172    /// The flock, held here (not on `Daemon`) so `reload` can hand its fd
173    /// to the next image.
174    pub lock_fd: Mutex<Option<DaemonLock>>,
175    /// `old id → new id` for one generation (ids differ only if a
176    /// LinkedSuccessor compaction happened since boot).
177    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    /// The current reload-announce token (clone; select on `.cancelled()`).
186    pub fn announce(&self) -> CancellationToken {
187        self.reload_announce
188            .lock()
189            .unwrap_or_else(|e| e.into_inner())
190            .clone()
191    }
192
193    /// Fire the announce (every conn says `Reloading` + `Bye`).
194    pub fn fire_announce(&self) {
195        self.announce().cancel();
196    }
197
198    /// Exec failed: arm a fresh token so new connections are served again.
199    pub fn reset_announce(&self) {
200        *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
201    }
202
203    /// B4: ONE session map — `EngineHost` owns it; these delegate.
204    pub fn live_sessions(&self) -> Vec<SessionHandle> {
205        self.host.session_handles()
206    }
207
208    /// Listing metas with lifecycle / clients / input_owner / journal_id
209    /// (B4: `EngineHost::sessions()` fills the handle cells).
210    pub fn session_metas(&self) -> Vec<SessionMeta> {
211        self.host.sessions()
212    }
213
214    /// Live handle by id; falls back to the C3 reload alias
215    /// (`old id → new id`, set when a LinkedSuccessor compaction happened
216    /// since boot) so clients reconnecting after `daemon reload` find
217    /// their session under the id they last saw.
218    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    /// F23: a lock-held placeholder (`meta.locked_by.is_some()`) is retried on
247    /// every attach. Pull it out of the map first — otherwise the host's
248    /// attach-if-live short-circuit hands the placeholder straight back —
249    /// and re-run the real `create` from the journal. Success replaces the
250    /// placeholder under the same id; still-held puts it back and the caller
251    /// attaches to it (→ `AttachRefused` naming the holder).
252    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                // Journal resolved somewhere else (e.g. compacted_into follow): keep it, drop the placeholder.
266                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
286/// A running daemon (bound socket, lock held, accept loop spawned).
287pub struct Daemon {
288    pub paths: DaemonPaths,
289    pub state: Arc<DaemonState>,
290    accept: tokio::task::JoinHandle<()>,
291}
292
293impl Daemon {
294    /// Reap stale files, take the flock, bind (dir 0700 / sock 0600), write
295    /// `daemon.json`, spawn the accept loop, signal the ready fd if any.
296    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        // C3: after `reload` the new image ADOPTS the inherited flock and
305        // skips `reap_stale` (the old image's files are ours).
306        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        // Before any session of this image registers: drop registrations no
362        // running session owns (dead owner pid). After a reload, also every
363        // one naming our own pid: the old image exec'd without ending its
364        // sessions, and the ones still live re-register when they rehydrate
365        // below, under their current (e.g. post-compaction) id. On a fresh
366        // start our pid may already own live registrations (a daemon hosted
367        // in a process that runs other sessions), so it is left alone.
368        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        // C3: rehydrate BEFORE accepting so a reconnecting client finds its session.
375        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    /// Block until shutdown is requested (frame, signal, idle), then end
393    /// every session inside one budget and unlink the files.
394    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        // Release the flock last.
409        drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
410    }
411}
412
413/// How long a `Status` probe may take before the session counts as busy.
414const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
415
416/// Whether `handle`'s actor is between turns. Asks `Query{Status}` under the
417/// reserved [`crate::session::wire::IDLE_PROBE_QUERY_ID`] (transports swallow
418/// it) and reads `streaming`. A no-answer inside [`IDLE_PROBE_TIMEOUT`] —
419/// the actor is inside `compact()`/preflight, or its queue is full — counts
420/// as busy: the daemon never exits under a running turn (jcode mistake #1).
421pub 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    // B3: a parked session is idle by construction — no probe, no wake.
425    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                            // Real actor: {"streaming": bool, "pending_prompts": n}. A backend that
439                            // does not implement Status (echo) has no turns to protect.
440                            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
454/// Idle = zero connections AND every live session between turns, held for
455/// `idle` continuously. Clients count first so a probe is never sent while
456/// someone is attached.
457pub 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
490/// Child side of the ready pipe: write `R` to `SYNAPS_DAEMON_READY_FD` (if
491/// set), close it, and scrub the env so tool subprocesses do not inherit it.
492pub 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        // SAFETY: fd is the write end handed to us by the parent; we own it.
500        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/// Parent side: `current_exe daemon --foreground …` in its own session
508/// (`setsid`), stdout → null, stderr → pipe; waits ≤ [`SPAWN_READY_TIMEOUT`]
509/// (15 s: the daemon's ≤ 10 s extension wait plus margin) for `R` on the
510/// ready pipe. EOF before `R` = child died → error with its stderr tail.
511#[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    // Scope wrapping execs in place: PID, ready FD and stdio remain intact.
533    // Separate scopes do not protect against ancestor/global OOM.
534    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    // SAFETY: plain pipe(2) on a valid 2-slot array.
543    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    // Probe has finished before readiness capabilities exist.
548    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    // SAFETY: pre_exec runs in the forked child before exec; only async-signal-safe calls.
563    unsafe {
564        cmd.pre_exec(move || {
565            libc::close(read_fd);
566            // Clear CLOEXEC on the write end so it survives exec.
567            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    // SAFETY: parent no longer needs the write end; closing it makes EOF mean "child died".
583    unsafe { libc::close(write_fd) };
584    let pid = child.id();
585
586    // Wait for 'R' with a deadline (poll(2) on the read end).
587    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        // SAFETY: one valid pollfd.
595        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        // SAFETY: reading one byte into a 1-byte buffer.
608        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    // SAFETY: done with the read end.
612    unsafe { libc::close(read_fd) };
613
614    if ready {
615        // Keep draining stderr so the child never blocks on a full pipe.
616        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    // Stop/reap before reading stderr; descendants may retain its write end.
626    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            // Bounded bytes and nonblocking reads, even if a descendant holds
637            // the pipe open or keeps writing after the wrapper exits.
638            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/// Result of [`ensure_running`].
658#[derive(Debug, Clone, PartialEq, Eq)]
659pub enum Ensured {
660    /// A daemon already held the flock.
661    Running,
662    /// We spawned one (pid) and it signalled ready.
663    Spawned(u32),
664}
665
666/// Why [`ensure_running`] could not hand back a live daemon.
667#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum EnsureError {
669    /// `SYNAPS_DAEMON_AUTOSPAWN=0` and nobody is running.
670    AutospawnDisabled,
671    /// The spawn failed or the child refused/died: one-line reason.
672    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
684/// How long a second first-client waits for the spawn lock (the winner's
685/// `spawn_detached` is bounded by [`SPAWN_READY_TIMEOUT`]).
686const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
687
688/// Attach-path entry: a live daemon or a freshly spawned one (jcode model —
689/// first client spawns via `setsid` + ready-fd; the daemon holds the flock).
690/// The **spawn lock** (`<stem>.spawn.lock` next to the flock) serialises
691/// concurrent first-clients: the loser blocks on it, then re-probes the
692/// flock and finds the winner's daemon. Enabled warm attaches also acquire
693/// this lock (extra open/flock overhead, and the lock must be writable).
694/// Never touches the socket on ECONNREFUSED alone — `reap_stale` + the
695/// flock decide. Daemon startup must NOT acquire the spawn lock: its parent
696/// holds it until the child's ready-fd acknowledgement.
697#[cfg(unix)]
698pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
699    ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
700}
701
702// Keep the real cleanup/probe/locking path testable without spawning the unit
703// test executable. The hook marks entry to the lock attempt, not readiness.
704#[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        // Preserve the disabled path: no spawn-lock dependency, even warm.
714        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    // No enabled caller may reap/probe before this lock: another client's
726    // transient cleanup/probe DaemonLock is not evidence of a ready daemon.
727    // Somebody may have spawned while we waited.
728    registry::reap_stale(&paths);
729    if registry::is_alive(&paths) {
730        return Ok(Ensured::Running);
731    }
732    // An auto-spawned daemon exits on its own once the last client is gone
733    // and no turn is running (parked sessions are journaled; `--continue`
734    // brings them back). A daemon started by hand (`synaps daemon`) keeps
735    // whatever its caller asked for.
736    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
743/// `SYNAPS_DAEMON_IDLE_EXIT_SECS` for auto-spawned daemons: default 10 s —
744/// when the last client disconnects and no turn is running, the daemon is
745/// holding ~100 MB for one reason only: a re-launch inside the window skips
746/// extension boot. Ten seconds covers "oops, wrong window"; `0`/`never` →
747/// never exit on idle.
748pub fn autospawn_idle_exit() -> Option<Duration> {
749    // Precedence: env > config (`daemon.idle_exit_secs`) > built-in default.
750    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    // `0` (env or config) disables idle-exit; otherwise config is the default
759    // when the env var is absent or unparseable.
760    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
768/// Default idle grace for auto-spawned daemons.
769pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
770
771
772/// `synaps daemon --foreground` body: gate → boot host (cached MCP
773/// descriptors only) → extension discovery once → start → background MCP
774/// discovery → wait for SIGTERM/SIGINT/Shutdown frame/idle.
775pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
776    if !enabled() {
777        anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
778    }
779    // Background MCP discovery: a hung/dead MCP server must never delay the
780    // ready byte (the spawner waits only SPAWN_READY_TIMEOUT).
781    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    // Sidecars spawn once per daemon: discover before accepting (bounded by
788    // EXTENSION_DISCOVERY_WAIT, which SPAWN_READY_TIMEOUT outwaits).
789    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    // C3 router: widget.* frames from every sidecar fan out to every live
806    // session (daemon-global widgets today — frames carry no session id).
807    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    // Accepting and ready: now discover uncached MCP servers. New servers'
816    // tools join the shared registry when it finishes (see
817    // `EngineHost::spawn_background_mcp_discovery` for live-session effects).
818    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        // cfg_secs = 10 mirrors the DaemonConfig default.
852        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        // garbage env → the config default, never "no exit"
861        assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
862        // env absent → config value is the default (env > config > builtin).
863        assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
864        // config 0 disables idle-exit when env is absent.
865        assert_eq!(autospawn_idle_exit_from(None, 0), None);
866        // env still wins over config.
867        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            // Avoid reading ambient idle-exit configuration in this test.
903            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                // The real ensure path must have reaped the old metadata.
927                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                // Main retains daemon ownership past both callers' returns.
942                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        // Trace entry to the actual lock attempt before asserting absence.
950        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        // A second real ensure caller sees daemon ownership and metadata, but
960        // must still wait for the spawning caller's ready acknowledgement.
961        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}