Skip to main content

agent_engine/daemon/
mod.rs

1//! Daemon front-end (`synaps daemon`): UDS listener (an owner-only named
2//! pipe on Windows — [`crate::ipc`]), per-connection pump, registry file,
3//! lifecycle (PLAN-phase2 §2.11). Lives in `agent-engine`
4//! (S289 D-1 amended) — `agent-tui` must never appear in this graph.
5//!
6//! On by default: the first `--attach`/`attach` client auto-spawns the
7//! daemon ([`ensure_running`]); `SYNAPS_DAEMON=0` disables every daemon
8//! feature (exit 3 with a one-line reason). Safety properties that are
9//! NEVER stubbed: the flock liveness oracle, the ready-fd pipe, socket
10//! perms 0700/0600, the spawn lock (one spawner per registry dir). On
11//! Windows the same properties hold with native mechanisms ([`win`]): the
12//! `LockFileEx` lock is the oracle, a named event is the ready signal, the
13//! pipe's DACL and server-identity check stand in for the file modes.
14
15pub mod close;
16pub mod conn;
17pub mod extension_reload;
18pub mod lifecycle;
19pub mod listener;
20pub mod pin;
21pub mod pins;
22pub mod registry;
23pub mod reload;
24pub mod rename;
25#[cfg(windows)]
26pub(crate) mod win;
27
28use std::collections::HashMap;
29use std::future::Future;
30use std::path::PathBuf;
31use std::pin::Pin;
32use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
33use std::sync::{Arc, Mutex};
34use std::time::{Duration, Instant};
35
36use tokio_util::sync::CancellationToken;
37
38use crate::session::{SessionConfig, SessionHandle, SessionId, SessionMeta};
39use crate::EngineHost;
40use registry::{DaemonInfo, DaemonLock, DaemonPaths};
41
42/// Exit code when the daemon refuses to start (flag off / legacy MCP).
43pub const EXIT_REFUSED: i32 = 3;
44/// Exit code for a protocol/version refusal on the client side.
45pub const EXIT_VERSION: i32 = 2;
46/// The daemon was lost (SIGKILL / crash) and reconnection failed within the
47/// budget (`SYNAPS_TUI_ATTACH_RECONNECT_SECS`).
48pub const EXIT_DAEMON_LOST: i32 = 4;
49/// Env var carrying the write end of the ready pipe to a spawned daemon.
50pub const READY_FD_ENV: &str = "SYNAPS_DAEMON_READY_FD";
51/// Windows: env var naming the event a spawned daemon sets once ready (the
52/// ready pipe's counterpart).
53pub const READY_EVENT_ENV: &str = "SYNAPS_DAEMON_READY_EVENT";
54/// Upper bound `run_foreground` waits for extension discovery before it
55/// starts accepting and signals ready (sidecars spawn once per daemon, and
56/// early sessions must see extension tools/hooks).
57pub const EXTENSION_DISCOVERY_WAIT: Duration = Duration::from_secs(10);
58/// Headroom for the rest of the daemon's pre-ready work (host boot with
59/// cached MCP descriptors only, bind, registry files) and a loaded machine.
60const SPAWN_READY_MARGIN: Duration = Duration::from_secs(5);
61/// How long `spawn_detached` waits for the child's ready byte. Strictly
62/// greater than the daemon's bounded pre-ready work: the extension wait plus
63/// a margin. MCP discovery never counts here — the daemon runs it in the
64/// background after it is accepting ([`crate::McpDiscovery::Background`]).
65pub const SPAWN_READY_TIMEOUT: Duration =
66    Duration::from_secs(EXTENSION_DISCOVERY_WAIT.as_secs() + SPAWN_READY_MARGIN.as_secs());
67// The spawner must outwait the daemon's bounded pre-ready work, or one slow
68// extension walk makes every auto-start fail (killed before `R`).
69const _: () = assert!(
70    SPAWN_READY_TIMEOUT.as_secs() > EXTENSION_DISCOVERY_WAIT.as_secs(),
71    "SPAWN_READY_TIMEOUT must exceed EXTENSION_DISCOVERY_WAIT"
72);
73
74/// `SYNAPS_DAEMON` feature flag: ON unless set to `0`/`false`/`off`/`no`.
75/// `SYNAPS_DAEMON=1` is accepted (no-op, kept for older scripts).
76pub fn enabled() -> bool {
77    enabled_from(std::env::var("SYNAPS_DAEMON").ok().as_deref())
78}
79
80fn env_is_off(v: Option<&str>) -> bool {
81    matches!(v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("0" | "false" | "off" | "no"))
82}
83
84fn enabled_from(v: Option<&str>) -> bool {
85    !env_is_off(v)
86}
87
88/// `SYNAPS_DAEMON_AUTOSPAWN`: ON unless `0`/`false`/`off`/`no`. Off means
89/// an attach with no live daemon fails with the "no daemon running" message
90/// instead of starting one.
91pub fn autospawn_enabled() -> bool {
92    !env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
93}
94
95/// One-line reason used by every attach path when the flag is off.
96pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
97
98/// A client running `client` profile must never drive a daemon that serves
99/// another one: its sessions, model and config writes would land in the
100/// wrong profile. The socket is per profile, but its stem is sanitised
101/// (`a.b` and `a_b` share `daemon-a_b.sock`), so the handshake's
102/// `Welcome.profile` is the authority. `None` and `Some("")` both mean the
103/// default profile. Returns the refusal message on a mismatch.
104pub fn profile_mismatch(daemon: Option<&str>, client: Option<&str>) -> Option<String> {
105    let norm = |p: Option<&str>| p.filter(|s| !s.is_empty()).map(str::to_owned);
106    let (daemon, client) = (norm(daemon), norm(client));
107    if daemon == client {
108        return None;
109    }
110    let name = |p: &Option<String>| match p {
111        Some(p) => format!("profile `{p}`"),
112        None => "the default profile".to_string(),
113    };
114    let stop = match &daemon {
115        Some(p) => format!("synaps --profile {p} daemon stop"),
116        None => "synaps daemon stop".to_string(),
117    };
118    Some(format!(
119        "refusing to attach: this daemon serves {}, but this client runs {} — \
120         stop it ({stop}) or use the matching --profile",
121        name(&daemon),
122        name(&client),
123    ))
124}
125
126/// Builds a session for `Attach::Create`: `host_factory` (real actor) or
127/// `echo_factory` (tests).
128pub type SessionFactory = Arc<
129    dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
130        + Send
131        + Sync,
132>;
133
134/// EchoActor-backed factory (B's socket tests before A1).
135#[cfg(any(test, feature = "testing"))]
136pub fn echo_factory() -> SessionFactory {
137    Arc::new(|cfg: SessionConfig| {
138        Box::pin(async move {
139            let id = SessionId(format!(
140                "echo-{}-{}",
141                chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"),
142                uuid::Uuid::new_v4().simple()
143            ));
144            let (handle, _task) = SessionHandle::echo_for_test(id);
145            let _ = cfg;
146            Ok(handle)
147        })
148    })
149}
150
151/// The default factory for a booted host: `EngineHost::create_session` (A1) —
152/// the real `SessionActor` owning one `Runtime` + `ConversationState`.
153pub fn host_factory(host: &Arc<EngineHost>) -> SessionFactory {
154    let host = Arc::clone(host);
155    Arc::new(move |cfg: SessionConfig| {
156        let host = Arc::clone(&host);
157        Box::pin(async move { host.create_session(cfg).await.map_err(|e| e.to_string()) })
158    })
159}
160
161#[derive(Clone, Default)]
162pub struct DaemonOpts {
163    /// Override the socket path (the lock/json/pid stay under `registry_dir()`).
164    pub socket: Option<PathBuf>,
165    pub profile: Option<String>,
166    /// Exit after this long with zero connections, no pinned session and no
167    /// session running a turn (idle, client-less sessions are saved on exit;
168    /// `--continue` brings them back). A turn in flight or a pin always
169    /// blocks the exit.
170    pub idle_exit: Option<Duration>,
171    /// Deprecated, ignored: legacy MCP was removed (every mode uses
172    /// per-session MCP leases). Still forwarded so an older client can spawn
173    /// a newer daemon.
174    pub allow_legacy_mcp: bool,
175    /// Test seam: `registry_dir()` replacement.
176    pub runtime_dir: Option<PathBuf>,
177    /// Test seam / A1 hook: how `Attach::Create` builds a session.
178    pub factory: Option<SessionFactory>,
179}
180
181impl DaemonOpts {
182    pub fn paths(&self) -> DaemonPaths {
183        let mut p = match &self.runtime_dir {
184            Some(d) => registry::daemon_paths_in(d, self.profile.as_deref()),
185            None => registry::daemon_paths(self.profile.as_deref()),
186        };
187        if let Some(s) = &self.socket {
188            p.sock = s.clone();
189        }
190        p
191    }
192}
193
194/// Shared by the accept loop and every connection.
195pub struct DaemonState {
196    pub host: Arc<EngineHost>,
197    pub paths: DaemonPaths,
198    pub profile: Option<String>,
199    pub started: Instant,
200    pub factory: SessionFactory,
201    pub connections: AtomicUsize,
202    pub shutdown: CancellationToken,
203    pub force_shutdown: AtomicBool,
204    // ── C3 reload ──
205    /// Reload counter (`Welcome.generation`; starts at 1).
206    pub generation: u64,
207    /// Drain: refuse `Attach::Create` / new turns while set.
208    pub reloading: AtomicBool,
209    /// Generation announced in `Reloading`/`Bye{Reloading}`.
210    pub reload_generation: std::sync::atomic::AtomicU64,
211    /// Cancelled once per reload attempt: every conn sends `Reloading` +
212    /// `Bye`. Replaced with a fresh token when the exec fails (M4) so the
213    /// surviving image accepts connections again — read via `announce()`.
214    pub reload_announce: Mutex<CancellationToken>,
215    /// The flock, held here (not on `Daemon`) so `reload` can hand its fd
216    /// to the next image.
217    pub lock_fd: Mutex<Option<DaemonLock>>,
218    /// `old id → new id` for one generation (ids differ only if a
219    /// LinkedSuccessor compaction happened since boot).
220    pub reload_aliases: Mutex<HashMap<String, String>>,
221    /// The `daemon.json` record this image wrote, and its writes. A Ping
222    /// that finds the file missing or naming another pid puts it back
223    /// ([`Self::heal_daemon_json`]); reload takes its recorded exe from here.
224    pub meta: registry::Metadata,
225    /// The pin book: pinned journals that come back on the next cold start.
226    pub pins: Arc<pins::PinBook>,
227    /// A cold start's pin restore is running (holds the idle exit).
228    pub restoring: AtomicBool,
229}
230
231impl DaemonState {
232    pub fn uptime_s(&self) -> u64 {
233        self.started.elapsed().as_secs()
234    }
235
236    /// The current reload-announce token (clone; select on `.cancelled()`).
237    pub fn announce(&self) -> CancellationToken {
238        self.reload_announce
239            .lock()
240            .unwrap_or_else(|e| e.into_inner())
241            .clone()
242    }
243
244    /// Fire the announce (every conn says `Reloading` + `Bye`).
245    pub fn fire_announce(&self) {
246        self.announce().cancel();
247    }
248
249    /// Exec failed: arm a fresh token so new connections are served again.
250    pub fn reset_announce(&self) {
251        *self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
252    }
253
254    /// The `daemon.json` record this image holds (a copy).
255    pub fn daemon_info(&self) -> DaemonInfo {
256        self.meta.info()
257    }
258
259    /// Ping: if `daemon.json` is missing or names another pid, write this
260    /// image's record back. Only while this image holds the flock and has
261    /// not begun to shut down; the record comes from memory, never from
262    /// disk. One INFO line per [`JSON_HEAL_LOG_EVERY`].
263    ///
264    /// The shutdown check runs under the `lock_fd` mutex: [`Daemon::wait`]
265    /// cancels `shutdown` before it takes that mutex to unlink the files, so
266    /// a Ping still in flight at shutdown can't write the file back after
267    /// the unlink. During a reload's hand-off the record already names the
268    /// new generation and exe, so a heal there writes what the reload wrote.
269    pub fn heal_daemon_json(&self) {
270        let lock = self.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
271        let Some(lock) = lock.as_ref() else {
272            return;
273        };
274        if self.shutdown.is_cancelled() {
275            return;
276        }
277        match self.meta.heal(lock) {
278            Ok(registry::Healed::Yes { info, log: Some(heals) }) => tracing::info!(
279                path = %self.paths.json.display(),
280                pid = info.pid,
281                generation = info.generation,
282                heals,
283                "daemon: daemon.json rewritten (it was missing or named another pid)"
284            ),
285            Ok(_) => {}
286            Err(e) => tracing::debug!(error = %e, "daemon: daemon.json heal failed"),
287        }
288    }
289
290    /// B4: ONE session map — `EngineHost` owns it; these delegate.
291    pub fn live_sessions(&self) -> Vec<SessionHandle> {
292        self.host.session_handles()
293    }
294
295    /// Listing metas with lifecycle / clients / input_owner / journal_id
296    /// (B4: `EngineHost::sessions()` fills the handle cells).
297    pub fn session_metas(&self) -> Vec<SessionMeta> {
298        self.host.sessions()
299    }
300
301    /// Live handle by id; falls back to the C3 reload alias
302    /// (`old id → new id`, set when a LinkedSuccessor compaction happened
303    /// since boot) so clients reconnecting after `daemon reload` find
304    /// their session under the id they last saw.
305    pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
306        self.host.attach(id).filter(|h| h.is_alive()).or_else(|| {
307            let alias = self
308                .reload_aliases
309                .lock()
310                .unwrap_or_else(|e| e.into_inner())
311                .get(id.as_str())
312                .cloned()?;
313            self.host
314                .attach(&SessionId::from(alias.as_str()))
315                .filter(|h| h.is_alive())
316        })
317    }
318
319    pub fn insert(&self, handle: SessionHandle) {
320        self.host.adopt_session(handle);
321    }
322
323    pub fn remove(&self, id: &SessionId) {
324        self.host.remove_session(id);
325    }
326
327    pub async fn create(&self, cfg: SessionConfig) -> Result<SessionHandle, String> {
328        let handle = (self.factory)(cfg).await?;
329        self.insert(handle.clone());
330        Ok(handle)
331    }
332
333    /// F23: a lock-held placeholder (`meta.locked_by.is_some()`) is retried on
334    /// every attach. Pull it out of the map first — otherwise the host's
335    /// attach-if-live short-circuit hands the placeholder straight back —
336    /// and re-run the real `create` from the journal. Success replaces the
337    /// placeholder under the same id; still-held puts it back and the caller
338    /// attaches to it (→ `AttachRefused` naming the holder).
339    pub async fn retry_locked_placeholder(&self, placeholder: SessionHandle) -> SessionHandle {
340        let cfg = SessionConfig {
341            continue_session: Some(Some(placeholder.id.as_str().to_string())),
342            ..placeholder.placeholder_config().cloned().unwrap_or_default()
343        };
344        self.remove(&placeholder.id);
345        match (self.factory)(cfg).await {
346            Ok(real) if real.id == placeholder.id || real.journal_id() == placeholder.id.as_str() => {
347                tracing::info!(session = %placeholder.id, "daemon: locked journal released — placeholder replaced by the real session");
348                self.insert(real.clone());
349                real
350            }
351            Ok(other) => {
352                // Journal resolved somewhere else (e.g. compacted_into follow): keep it, drop the placeholder.
353                tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
354                self.insert(other.clone());
355                other
356            }
357            Err(e) => {
358                tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
359                self.insert(placeholder.clone());
360                placeholder
361            }
362        }
363    }
364
365    pub fn request_shutdown(&self, force: bool) {
366        if force {
367            self.force_shutdown.store(true, Ordering::SeqCst);
368        }
369        self.shutdown.cancel();
370    }
371}
372
373/// A running daemon (bound socket, lock held, accept loop spawned).
374pub struct Daemon {
375    pub paths: DaemonPaths,
376    pub state: Arc<DaemonState>,
377    accept: tokio::task::JoinHandle<()>,
378}
379
380impl Daemon {
381    /// Reap stale files, take the flock, bind (dir 0700 / sock 0600), write
382    /// `daemon.json`, spawn the accept loop, signal the ready fd if any.
383    pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
384        let paths = opts.paths();
385        std::fs::create_dir_all(&paths.dir)?;
386        #[cfg(unix)]
387        {
388            use std::os::unix::fs::PermissionsExt;
389            std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
390        }
391        // C3: after `reload` the new image ADOPTS the inherited flock and
392        // skips `reap_stale` (the old image's files are ours).
393        let adopted = reload::adopt_from_env(&paths)?;
394        let (lock, reload_state) = match adopted {
395            Some((lock, rs, rs_path)) => {
396                let _ = std::fs::remove_file(&rs_path);
397                (lock, Some(rs))
398            }
399            None => {
400                registry::reap_stale(&paths);
401                let lock = match DaemonLock::try_acquire(&paths)? {
402                    Some(l) => l,
403                    None => {
404                        let who = registry::read_daemon_json(&paths).map(|i| i.pid);
405                        anyhow::bail!(
406                            "another daemon holds {} (pid {})",
407                            paths.lock.display(),
408                            who.map_or("unknown".to_string(), |p| p.to_string())
409                        );
410                    }
411                };
412                (lock, None)
413            }
414        };
415        let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
416        let listener = listener::bind(&paths.sock)?;
417
418        let info = DaemonInfo {
419            pid: std::process::id(),
420            protocol_version: crate::session::wire::PROTOCOL_VERSION,
421            daemon_version: crate::session::wire::binary_version(),
422            profile: opts.profile.clone(),
423            started_at: chrono::Utc::now(),
424            socket: paths.sock.to_string_lossy().into_owned(),
425            exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
426            generation,
427        };
428        let meta = registry::Metadata::start(paths.clone(), info.clone())?;
429
430        let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
431        let state = Arc::new(DaemonState {
432            host,
433            paths: paths.clone(),
434            profile: opts.profile.clone(),
435            started: Instant::now(),
436            factory,
437            connections: AtomicUsize::new(0),
438            shutdown: CancellationToken::new(),
439            force_shutdown: AtomicBool::new(false),
440            generation,
441            reloading: AtomicBool::new(false),
442            reload_generation: std::sync::atomic::AtomicU64::new(generation),
443            reload_announce: Mutex::new(CancellationToken::new()),
444            lock_fd: Mutex::new(Some(lock)),
445            reload_aliases: Mutex::new(HashMap::new()),
446            meta,
447            pins: Arc::new(pins::PinBook::for_active_profile()),
448            restoring: AtomicBool::new(false),
449        });
450        state.host.set_pin_sink(Arc::clone(&state.pins) as Arc<dyn crate::session::PinSink>);
451
452        // Before any session of this image registers: drop registrations no
453        // running session owns (dead owner pid). After a reload, also every
454        // one naming our own pid: the old image exec'd without ending its
455        // sessions, and the ones still live re-register when they rehydrate
456        // below, under their current (e.g. post-compaction) id. On a fresh
457        // start our pid may already own live registrations (a daemon hosted
458        // in a process that runs other sessions), so it is left alone.
459        let own_pid = reload_state.as_ref().map(|_| info.pid);
460        let swept = crate::events::registry::sweep_stale_registrations(own_pid);
461        if swept > 0 {
462            tracing::info!(swept, "daemon: removed stale session registrations");
463        }
464
465        // C3: rehydrate BEFORE accepting so a reconnecting client finds its session.
466        if let Some(rs) = &reload_state {
467            reload::rehydrate(&state, rs).await;
468        }
469
470        let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
471        if let Some(idle) = opts.idle_exit {
472            tokio::spawn(idle_monitor(Arc::clone(&state), idle));
473        }
474        signal_ready();
475        // Cold start only: a reload hands its live sessions over already.
476        if reload_state.is_none() && pins::restore_enabled() {
477            tokio::spawn(pins::restore_all(Arc::clone(&state)));
478        }
479        tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
480        Ok(Self { paths, state, accept })
481    }
482
483    pub fn shutdown_token(&self) -> CancellationToken {
484        self.state.shutdown.clone()
485    }
486
487    /// Block until shutdown is requested (frame, signal, idle), then end
488    /// every session inside one budget and unlink the files.
489    pub async fn wait(self) {
490        self.state.shutdown.cancelled().await;
491        let force = self.state.force_shutdown.load(Ordering::SeqCst);
492        lifecycle::shutdown_all(&self.state, force).await;
493        self.accept.abort();
494        let _ = self.accept.await;
495        crate::events::socket::cleanup_socket(&self.paths.sock.to_string_lossy());
496        {
497            let lock = self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
498            if let Some(lock) = lock.as_ref() {
499                self.state.meta.cleanup(lock);
500            }
501        }
502        tracing::info!("daemon: stopped");
503        // Release the flock last.
504        drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
505    }
506}
507
508/// How long a `Status` probe may take before the session counts as busy.
509const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
510
511/// Whether `handle`'s actor is between turns. Asks `Query{Status}` under the
512/// reserved [`crate::session::wire::IDLE_PROBE_QUERY_ID`] (transports swallow
513/// it) and reads `streaming`. A no-answer inside [`IDLE_PROBE_TIMEOUT`] —
514/// the actor is inside `compact()`/preflight, or its queue is full — counts
515/// as busy: the daemon never exits under a running turn (jcode mistake #1).
516pub async fn session_is_idle(handle: &SessionHandle) -> bool {
517    use crate::session::wire::IDLE_PROBE_QUERY_ID;
518    use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
519    // B3: a parked session is idle by construction — no probe, no wake.
520    if handle.lifecycle() == SessionLifecycle::Parked {
521        return true;
522    }
523    let mut rx = handle.subscribe();
524    if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
525        return false;
526    }
527    let probe = async {
528        loop {
529            match rx.recv().await {
530                Ok(env) => {
531                    if let SessionEventWire::QueryResult { id, value } = env.event {
532                        if id == IDLE_PROBE_QUERY_ID {
533                            // Real actor: {"streaming": bool, "pending_prompts": n}. A backend that
534                            // does not implement Status (echo) has no turns to protect.
535                            let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
536                            let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
537                            return !streaming && prompts == 0;
538                        }
539                    }
540                }
541                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
542                Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
543            }
544        }
545    };
546    tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
547}
548
549/// Idle = zero connections, no pinned session AND every live session
550/// between turns, held for `idle` continuously. Clients and pins count
551/// first so a probe is never sent while someone is attached. A pin holds
552/// the exit because exiting would end the session the user asked to keep.
553pub async fn daemon_is_idle(state: &DaemonState) -> bool {
554    if state.connections.load(Ordering::SeqCst) > 0 {
555        return false;
556    }
557    if state.restoring.load(Ordering::SeqCst) {
558        return false;
559    }
560    let sessions = state.live_sessions();
561    if sessions.iter().any(|h| h.pinned() && h.is_alive()) {
562        return false;
563    }
564    for h in sessions {
565        if !session_is_idle(&h).await {
566            return false;
567        }
568    }
569    true
570}
571
572async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
573    let mut idle_since: Option<Instant> = None;
574    let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
575    loop {
576        tokio::select! {
577            _ = state.shutdown.cancelled() => return,
578            _ = tokio::time::sleep(tick) => {}
579        }
580        if !daemon_is_idle(&state).await {
581            idle_since = None;
582            continue;
583        }
584        let since = *idle_since.get_or_insert_with(Instant::now);
585        if since.elapsed() >= idle {
586            tracing::info!("daemon: idle for {:?}, exiting", idle);
587            state.request_shutdown(false);
588            return;
589        }
590    }
591}
592
593/// Child side of the ready pipe: write `R` to `SYNAPS_DAEMON_READY_FD` (if
594/// set), close it, and scrub the env so tool subprocesses do not inherit it.
595pub fn signal_ready() {
596    #[cfg(unix)]
597    {
598        let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
599            return;
600        };
601        std::env::remove_var(READY_FD_ENV);
602        // SAFETY: fd is the write end handed to us by the parent; we own it.
603        unsafe {
604            let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
605            libc::close(fd);
606        }
607    }
608    #[cfg(windows)]
609    {
610        let Ok(name) = std::env::var(READY_EVENT_ENV) else {
611            return;
612        };
613        std::env::remove_var(READY_EVENT_ENV);
614        if let Err(e) = win::signal_event(&name) {
615            tracing::warn!(error = %e, "daemon: could not signal the spawner's ready event");
616        }
617        // The spawner stops reading our stderr now.
618        win::detach_stdio();
619    }
620}
621
622/// The detached daemon's program and argv: `exe cmd_args`, wrapped in the
623/// daemon's own scope (in `daemon_scope_slice` when that is set) when scopes
624/// are available for `effective_env`.
625#[cfg(unix)]
626fn daemon_launch(
627    effective_env: &[(std::ffi::OsString, std::ffi::OsString)],
628    exe: PathBuf,
629    cmd_args: Vec<std::ffi::OsString>,
630) -> (std::ffi::OsString, Vec<std::ffi::OsString>) {
631    let kind = crate::process_scope::ScopeKind::Daemon;
632    match crate::process_scope::launcher_for_effective_env(effective_env, None, kind) {
633        Some(launcher) => launcher.wrap("synaps daemon", exe, cmd_args),
634        None => (exe.into_os_string(), cmd_args),
635    }
636}
637
638/// Parent side: `current_exe daemon --foreground …` in its own session
639/// (`setsid`), stdout → null, stderr → pipe; waits ≤ [`SPAWN_READY_TIMEOUT`]
640/// (15 s: the daemon's ≤ 10 s extension wait plus margin) for `R` on the
641/// ready pipe. EOF before `R` = child died → error with its stderr tail.
642#[cfg(unix)]
643pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
644    use std::io::Read;
645    use std::os::unix::process::CommandExt;
646
647    let exe = std::env::current_exe()?;
648
649
650    let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
651    if let Some(p) = &opts.profile {
652        cmd_args.extend(["--profile".into(), p.into()]);
653    }
654    if let Some(s) = &opts.socket {
655        cmd_args.extend(["--socket".into(), s.into()]);
656    }
657    if let Some(i) = opts.idle_exit {
658        cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
659    }
660    if opts.allow_legacy_mcp {
661        cmd_args.push("--allow-legacy-mcp".into());
662    }
663    // Scope wrapping execs in place: PID, ready FD and stdio remain intact.
664    // Separate scopes do not protect against ancestor/global OOM.
665    let mut effective_env = crate::process_scope::inherited_env();
666    crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
667    effective_env.retain(|(k, _)| k != READY_FD_ENV);
668    let (program, cmd_args) = daemon_launch(&effective_env, exe, cmd_args);
669    let mut fds = [0i32; 2];
670    // SAFETY: plain pipe(2) on a valid 2-slot array.
671    if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
672        return Err(std::io::Error::last_os_error().into());
673    }
674    let (read_fd, write_fd) = (fds[0], fds[1]);
675    // Probe has finished before readiness capabilities exist.
676    for fd in [read_fd, write_fd] {
677        if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
678            let error = std::io::Error::last_os_error();
679            unsafe { libc::close(read_fd); libc::close(write_fd); }
680            return Err(error.into());
681        }
682    }
683    crate::process_scope::set_env(&mut effective_env, READY_FD_ENV, write_fd.to_string());
684    let mut cmd = std::process::Command::new(program);
685    cmd.args(cmd_args);
686    cmd.env_clear().envs(effective_env)
687        .stdin(std::process::Stdio::null())
688        .stdout(std::process::Stdio::null())
689        .stderr(std::process::Stdio::piped());
690    // SAFETY: pre_exec runs in the forked child before exec; only async-signal-safe calls.
691    unsafe {
692        cmd.pre_exec(move || {
693            libc::close(read_fd);
694            // Clear CLOEXEC on the write end so it survives exec.
695            let flags = libc::fcntl(write_fd, libc::F_GETFD);
696            if flags >= 0 {
697                libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
698            }
699            libc::setsid();
700            Ok(())
701        });
702    }
703    let mut child = match cmd.spawn() {
704        Ok(child) => child,
705        Err(error) => {
706            unsafe { libc::close(read_fd); libc::close(write_fd); }
707            return Err(error.into());
708        }
709    };
710    // SAFETY: parent no longer needs the write end; closing it makes EOF mean "child died".
711    unsafe { libc::close(write_fd) };
712    let pid = child.id();
713
714    // Wait for 'R' with a deadline (poll(2) on the read end).
715    let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
716    let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
717    let ready = loop {
718        let left = deadline.saturating_duration_since(Instant::now());
719        if left.is_zero() {
720            break false;
721        }
722        // SAFETY: one valid pollfd.
723        let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
724        if n < 0 {
725            let e = std::io::Error::last_os_error();
726            if e.kind() == std::io::ErrorKind::Interrupted {
727                continue;
728            }
729            break false;
730        }
731        if n == 0 {
732            break false;
733        }
734        let mut byte = [0u8; 1];
735        // SAFETY: reading one byte into a 1-byte buffer.
736        let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
737        break r == 1 && byte[0] == b'R';
738    };
739    // SAFETY: done with the read end.
740    unsafe { libc::close(read_fd) };
741
742    if ready {
743        // Keep draining stderr so the child never blocks on a full pipe.
744        if let Some(mut err) = child.stderr.take() {
745            std::thread::spawn(move || {
746                let mut sink = Vec::new();
747                let _ = err.read_to_end(&mut sink);
748            });
749        }
750        drop(child);
751        return Ok(pid);
752    }
753    // Stop/reap before reading stderr; descendants may retain its write end.
754    let status = child.try_wait().ok().flatten().or_else(|| {
755        let _ = child.kill();
756        child.wait().ok()
757    });
758    let mut tail = String::new();
759    if let Some(mut err) = child.stderr.take() {
760        use std::os::fd::AsRawFd;
761        let fd = err.as_raw_fd();
762        let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
763        if flags >= 0 && unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } >= 0 {
764            // Bounded bytes and nonblocking reads, even if a descendant holds
765            // the pipe open or keeps writing after the wrapper exits.
766            let mut bytes = Vec::new();
767            let _ = err.by_ref().take(64 * 1024).read_to_end(&mut bytes);
768            tail = String::from_utf8_lossy(&bytes).into_owned();
769        }
770    }
771    let reason = tail
772        .lines()
773        .map(str::trim)
774        .rfind(|l| !l.is_empty())
775        .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
776    match (status, reason) {
777        (Some(st), Some(r)) if st.code().is_some() => {
778            anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
779        }
780        (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
781        (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
782    }
783}
784
785/// Windows: as the Unix version — `current_exe daemon --foreground …`,
786/// stdout → null, stderr → pipe, wait ≤ [`SPAWN_READY_TIMEOUT`] — with a
787/// named ready event instead of the ready pipe (the child's exit before it
788/// is set is the pipe's EOF-before-`R`) and a hidden-console, own-group,
789/// job-breakaway spawn instead of `setsid` ([`win::spawn_detached`]).
790#[cfg(windows)]
791pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
792    let exe = std::env::current_exe()?;
793    let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
794    if let Some(p) = &opts.profile {
795        cmd_args.extend(["--profile".into(), p.into()]);
796    }
797    if let Some(s) = &opts.socket {
798        cmd_args.extend(["--socket".into(), s.into()]);
799    }
800    if let Some(i) = opts.idle_exit {
801        cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
802    }
803    if opts.allow_legacy_mcp {
804        cmd_args.push("--allow-legacy-mcp".into());
805    }
806    let mut effective_env = crate::process_scope::inherited_env();
807    crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
808    effective_env.retain(|(k, _)| k != READY_FD_ENV && k != READY_EVENT_ENV);
809    let ready = win::Event::create_unique("ready")?;
810    crate::process_scope::set_env(&mut effective_env, READY_EVENT_ENV, ready.name());
811    let mut child = win::spawn_detached(&exe, &cmd_args, &effective_env)?;
812    let pid = child.id();
813
814    if win::wait_event_or_exit(&ready, &child, SPAWN_READY_TIMEOUT) == win::Wait::Signalled {
815        // Keep draining stderr (the daemon points it at NUL once ready).
816        if let Some(mut err) = child.take_stderr() {
817            std::thread::spawn(move || {
818                let mut sink = Vec::new();
819                let _ = std::io::Read::read_to_end(&mut err, &mut sink);
820            });
821        }
822        return Ok(pid);
823    }
824    // Stop/reap before reading stderr; descendants may retain its write end
825    // (the read is bounded in bytes and time).
826    let exited = child.wait(Duration::ZERO);
827    if exited.is_none() {
828        child.kill();
829        let _ = child.wait(Duration::from_secs(5));
830    }
831    let tail = win::stderr_tail(child.take_stderr(), 64 * 1024, Duration::from_millis(500));
832    let reason = tail
833        .lines()
834        .map(str::trim)
835        .rfind(|l| !l.is_empty())
836        .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
837    match (exited, reason) {
838        (Some(code), Some(r)) => anyhow::bail!("daemon exited (code {code}): {r}"),
839        (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
840        (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
841    }
842}
843
844/// Result of [`ensure_running`].
845#[derive(Debug, Clone, PartialEq, Eq)]
846pub enum Ensured {
847    /// A daemon already held the flock.
848    Running,
849    /// We spawned one (pid) and it signalled ready.
850    Spawned(u32),
851}
852
853/// Why [`ensure_running`] could not hand back a live daemon.
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub enum EnsureError {
856    /// `SYNAPS_DAEMON_AUTOSPAWN=0` and nobody is running.
857    AutospawnDisabled,
858    /// The spawn failed or the child refused/died: one-line reason.
859    Spawn(String),
860}
861
862impl std::fmt::Display for EnsureError {
863    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864        match self {
865            Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
866            Self::Spawn(r) => write!(f, "{r}"),
867        }
868    }
869}
870
871/// How long a second first-client waits for the spawn lock (the winner's
872/// `spawn_detached` is bounded by [`SPAWN_READY_TIMEOUT`]).
873const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
874
875/// Attach-path entry: a live daemon or a freshly spawned one (jcode model —
876/// first client spawns via `setsid` + ready-fd; the daemon holds the flock).
877/// The **spawn lock** (`<stem>.spawn.lock` next to the flock) serialises
878/// concurrent first-clients: the loser blocks on it, then re-probes the
879/// flock and finds the winner's daemon. Enabled warm attaches also acquire
880/// this lock (extra open/flock overhead, and the lock must be writable).
881/// Never touches the socket on ECONNREFUSED alone — `reap_stale` + the
882/// flock decide. Daemon startup must NOT acquire the spawn lock: its parent
883/// holds it until the child's ready-fd acknowledgement.
884pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
885    ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
886}
887
888// Keep the real cleanup/probe/locking path testable without spawning the unit
889// test executable. The hook marks entry to the lock attempt, not readiness.
890fn ensure_running_with(
891    opts: &DaemonOpts,
892    auto: bool,
893    spawn: impl FnOnce(&DaemonOpts) -> anyhow::Result<u32>,
894    before_spawn_lock: impl FnOnce(),
895) -> Result<Ensured, EnsureError> {
896    let paths = opts.paths();
897    if !auto {
898        // Preserve the disabled path: no spawn-lock dependency, even warm.
899        registry::reap_stale(&paths);
900        return if registry::is_alive(&paths) {
901            Ok(Ensured::Running)
902        } else {
903            Err(EnsureError::AutospawnDisabled)
904        };
905    }
906    let _ = std::fs::create_dir_all(&paths.dir);
907    before_spawn_lock();
908    let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
909        .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
910    // No enabled caller may reap/probe before this lock: another client's
911    // transient cleanup/probe DaemonLock is not evidence of a ready daemon.
912    // Somebody may have spawned while we waited.
913    registry::reap_stale(&paths);
914    if registry::is_alive(&paths) {
915        return Ok(Ensured::Running);
916    }
917    // An auto-spawned daemon exits on its own once the last client is gone
918    // and no turn is running (parked sessions are journaled; `--continue`
919    // brings them back). A daemon started by hand (`synaps daemon`) keeps
920    // whatever its caller asked for.
921    let mut opts = opts.clone();
922    if opts.idle_exit.is_none() {
923        opts.idle_exit = autospawn_idle_exit();
924    }
925    spawn(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
926}
927
928/// `SYNAPS_DAEMON_IDLE_EXIT_SECS` for auto-spawned daemons: default 10 s —
929/// when the last client disconnects and no turn is running, the daemon is
930/// holding ~100 MB for one reason only: a re-launch inside the window skips
931/// extension boot. Ten seconds covers "oops, wrong window"; `0`/`never` →
932/// never exit on idle.
933pub fn autospawn_idle_exit() -> Option<Duration> {
934    // Precedence: env > config (`daemon.idle_exit_secs`) > built-in default.
935    let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
936    autospawn_idle_exit_from(
937        std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
938        cfg_secs,
939    )
940}
941
942fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
943    // `0` (env or config) disables idle-exit; otherwise config is the default
944    // when the env var is absent or unparseable.
945    let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
946    match v.map(str::trim) {
947        Some("0" | "never" | "off") => None,
948        Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
949        None => from_cfg(),
950    }
951}
952
953/// Default idle grace for auto-spawned daemons.
954pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
955
956
957/// `synaps daemon --foreground` body: gate → boot host (cached MCP
958/// descriptors only) → extension discovery once → start → background MCP
959/// discovery → wait for SIGTERM/SIGINT/Shutdown frame/idle.
960pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
961    if !enabled() {
962        anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
963    }
964    // Windows reload: take `daemon.lock` over from the previous image before
965    // booting anything. It exits on our signal, as `execv` would have
966    // replaced it, so nothing below ever runs beside it.
967    #[cfg(windows)]
968    reload::take_over_from_env(&opts.paths())?;
969    // Background MCP discovery: a hung/dead MCP server must never delay the
970    // ready byte (the spawner waits only SPAWN_READY_TIMEOUT).
971    let host = EngineHost::boot_and_install(crate::HostOpts {
972        profile: opts.profile.clone(),
973        no_extensions: false,
974        mcp_discovery: crate::McpDiscovery::Background,
975    })
976    .await?;
977    host.report_config_warnings();
978    // Sidecars spawn once per daemon: discover before accepting (bounded by
979    // EXTENSION_DISCOVERY_WAIT, which SPAWN_READY_TIMEOUT outwaits).
980    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
981    let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
982    let wait = async {
983        while let Some(ev) = rx.recv().await {
984            if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
985                tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
986                break;
987            }
988        }
989    };
990    if tokio::time::timeout(EXTENSION_DISCOVERY_WAIT, wait).await.is_err() {
991        tracing::warn!(
992            wait = ?EXTENSION_DISCOVERY_WAIT,
993            "daemon: extension discovery still running; accepting anyway"
994        );
995    }
996    // C3 router: widget.* frames from every sidecar fan out to every live
997    // session (daemon-global widgets today — frames carry no session id).
998    let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
999
1000    let mut opts = opts;
1001    if opts.factory.is_none() {
1002        opts.factory = Some(host_factory(&host));
1003    }
1004    let mcp_host = Arc::clone(&host);
1005    let daemon = Daemon::start(host, opts).await?;
1006    // Accepting and ready: now discover uncached MCP servers. New servers'
1007    // tools join the shared registry when it finishes (see
1008    // `EngineHost::spawn_background_mcp_discovery` for live-session effects).
1009    let _mcp_discovery = mcp_host.spawn_background_mcp_discovery();
1010    drop(mcp_host);
1011    let state = Arc::clone(&daemon.state);
1012    tokio::spawn(async move {
1013        #[cfg(unix)]
1014        {
1015            let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
1016            tokio::select! {
1017                _ = tokio::signal::ctrl_c() => {}
1018                _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
1019                _ = state.shutdown.cancelled() => return,
1020            }
1021        }
1022        #[cfg(not(unix))]
1023        {
1024            tokio::select! {
1025                _ = tokio::signal::ctrl_c() => {}
1026                _ = state.shutdown.cancelled() => return,
1027            }
1028        }
1029        tracing::info!("daemon: signal received, shutting down");
1030        state.request_shutdown(false);
1031    });
1032    daemon.wait().await;
1033    Ok(())
1034}
1035
1036#[cfg(test)]
1037mod flag_tests {
1038    use super::*;
1039
1040    #[test]
1041    fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
1042        // cfg_secs = 10 mirrors the DaemonConfig default.
1043        let d = AUTOSPAWN_IDLE_EXIT.as_secs();
1044        assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
1045        assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
1046        assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
1047        assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
1048        for never in ["0", "never", "off"] {
1049            assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
1050        }
1051        // garbage env → the config default, never "no exit"
1052        assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
1053        // env absent → config value is the default (env > config > builtin).
1054        assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
1055        // config 0 disables idle-exit when env is absent.
1056        assert_eq!(autospawn_idle_exit_from(None, 0), None);
1057        // env still wins over config.
1058        assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
1059    }
1060
1061    #[test]
1062    fn enabled_env_matrix() {
1063        assert!(enabled_from(None), "unset → on");
1064        for on in ["1", "true", "yes", "on", "", "anything"] {
1065            assert!(enabled_from(Some(on)), "{on:?} → on");
1066        }
1067        for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
1068            assert!(!enabled_from(Some(off)), "{off:?} → off");
1069        }
1070    }
1071
1072    #[test]
1073    fn profile_mismatch_refuses_a_daemon_of_another_profile() {
1074        assert_eq!(profile_mismatch(None, None), None);
1075        assert_eq!(profile_mismatch(Some("axon"), Some("axon")), None);
1076        // empty == default
1077        assert_eq!(profile_mismatch(Some(""), None), None);
1078        assert_eq!(profile_mismatch(None, Some("")), None);
1079        // a profile client on the default daemon (the 0.10.1 clobber) and back
1080        let m = profile_mismatch(None, Some("axon")).expect("mismatch");
1081        assert!(m.contains("the default profile") && m.contains("profile `axon`"), "{m}");
1082        assert!(m.contains("synaps daemon stop"), "{m}");
1083        let m = profile_mismatch(Some("axon"), None).expect("mismatch");
1084        assert!(m.contains("synaps --profile axon daemon stop"), "{m}");
1085        // sanitised socket stems collide; the profile names do not
1086        assert!(profile_mismatch(Some("a.b"), Some("a_b")).is_some());
1087    }
1088
1089    #[test]
1090    fn env_is_off_is_case_and_space_insensitive() {
1091        assert!(env_is_off(Some("NO")));
1092        assert!(!env_is_off(Some("1")));
1093        assert!(!env_is_off(None));
1094    }
1095}
1096
1097#[cfg(all(test, target_os = "linux"))]
1098mod scope_tests {
1099    use super::*;
1100    use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
1101
1102    fn launch(env: &[(String, String)]) -> (std::ffi::OsString, Vec<String>) {
1103        let env: Vec<(std::ffi::OsString, std::ffi::OsString)> =
1104            env.iter().map(|(k, v)| (k.into(), v.into())).collect();
1105        let exe = PathBuf::from("/opt/synaps/bin/synaps");
1106        let (program, args) = daemon_launch(&env, exe, vec!["daemon".into(), "--foreground".into()]);
1107        (program, args.iter().map(|a| a.to_string_lossy().into_owned()).collect())
1108    }
1109
1110    /// The daemon's scope goes in `daemon_scope_slice`, never in the tools'
1111    /// `tool_scope_slice`; with neither key set its argv is today's.
1112    #[test]
1113    #[serial_test::serial(synaps_base_dir)]
1114    fn the_daemon_scope_goes_in_the_daemon_slice_only() {
1115        let base = crate::test_env::BaseDirGuard::new();
1116        let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
1117        let dir = tempfile::tempdir().unwrap();
1118        let fake = fake_systemd_run(dir.path());
1119        let env = fake_env(&fake);
1120        let today = [
1121            "--user",
1122            "--scope",
1123            "--quiet",
1124            "--collect",
1125            "--description=synaps daemon",
1126            "--",
1127            "/opt/synaps/bin/synaps",
1128            "daemon",
1129            "--foreground",
1130        ];
1131        let (program, args) = launch(&env);
1132        assert_eq!(program, fake.clone().into_os_string());
1133        assert_eq!(args, today);
1134        let config = base.path().join("config");
1135        std::fs::write(&config, "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1136        let (program, args) = launch(&env);
1137        assert_eq!(program, fake.clone().into_os_string());
1138        assert_eq!(
1139            args,
1140            [
1141                "--user",
1142                "--scope",
1143                "--quiet",
1144                "--collect",
1145                "--slice=d_daemon.slice",
1146                "--description=synaps daemon",
1147                "--",
1148                "/opt/synaps/bin/synaps",
1149                "daemon",
1150                "--foreground",
1151            ]
1152        );
1153        assert!(recorded(dir.path(), "probe.args").contains(&"--slice=d_daemon.slice".to_string()));
1154        // Only the tools' key: the daemon's scope stays where it was.
1155        std::fs::write(&config, "tool_scope_slice = t_tools.slice\n").unwrap();
1156        assert_eq!(launch(&env).1, today);
1157        // Scopes off: the daemon starts directly.
1158        std::fs::write(&config, "process_scopes = off\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1159        let (program, args) = launch(&env);
1160        assert_eq!(program, std::ffi::OsString::from("/opt/synaps/bin/synaps"));
1161        assert_eq!(args, ["daemon", "--foreground"]);
1162    }
1163}
1164
1165#[cfg(test)]
1166mod ensure_tests {
1167    use super::*;
1168    use std::sync::mpsc;
1169
1170    const WAIT: Duration = Duration::from_secs(3);
1171    const BLOCKED: Duration = Duration::from_millis(100);
1172
1173    fn serialization_case(stale_metadata: bool) {
1174        let dir = tempfile::tempdir().unwrap();
1175        let opts = DaemonOpts {
1176            runtime_dir: Some(dir.path().to_path_buf()),
1177            profile: Some("ensure-serialization".into()),
1178            // Avoid reading ambient idle-exit configuration in this test.
1179            idle_exit: Some(Duration::from_secs(1)),
1180            ..Default::default()
1181        };
1182        let paths = opts.paths();
1183        let spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1184        let cleanup_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1185        if stale_metadata {
1186            std::fs::write(&paths.json, b"stale metadata").unwrap();
1187            std::fs::write(&paths.pid, b"0\n").unwrap();
1188        }
1189        let count = Arc::new(AtomicUsize::new(0));
1190        let ready = Arc::new(AtomicBool::new(false));
1191        let (attempt_tx, attempt_rx) = mpsc::channel();
1192        let (daemon_tx, daemon_rx) = mpsc::channel();
1193        let (ready_tx, ready_rx) = mpsc::channel();
1194        let (done_tx, done_rx) = mpsc::channel();
1195        let first_opts = opts.clone();
1196        let first_count = Arc::clone(&count);
1197        let first_ready = Arc::clone(&ready);
1198        let first = std::thread::spawn(move || {
1199            let result = ensure_running_with(&first_opts, true, |opts| {
1200                first_count.fetch_add(1, Ordering::SeqCst);
1201                let paths = opts.paths();
1202                // The real ensure path must have reaped the old metadata.
1203                assert!(!paths.json.exists());
1204                assert!(!paths.pid.exists());
1205                let lock = DaemonLock::try_acquire(&paths)?.expect("daemon lock free after reap");
1206                let pid = std::process::id();
1207                registry::write_daemon_json(&paths, &DaemonInfo {
1208                    pid,
1209                    protocol_version: 1,
1210                    daemon_version: "test".into(),
1211                    profile: opts.profile.clone(),
1212                    started_at: chrono::Utc::now(),
1213                    socket: paths.sock.display().to_string(),
1214                    exe: None,
1215                    generation: 1,
1216                })?;
1217                // Main retains daemon ownership past both callers' returns.
1218                daemon_tx.send(lock).unwrap();
1219                ready_rx.recv_timeout(WAIT).unwrap();
1220                first_ready.store(true, Ordering::SeqCst);
1221                Ok(pid)
1222            }, || attempt_tx.send(()).unwrap());
1223            done_tx.send(result).unwrap();
1224        });
1225        // Trace entry to the actual lock attempt before asserting absence.
1226        attempt_rx.recv_timeout(WAIT).unwrap();
1227        assert_eq!(done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1228        assert_eq!(count.load(Ordering::SeqCst), 0);
1229        assert_eq!(paths.json.exists(), stale_metadata);
1230        assert_eq!(paths.pid.exists(), stale_metadata);
1231        drop(cleanup_lock);
1232        drop(spawn_lock);
1233        let daemon_lock = daemon_rx.recv_timeout(WAIT).unwrap();
1234
1235        // A second real ensure caller sees daemon ownership and metadata, but
1236        // must still wait for the spawning caller's ready acknowledgement.
1237        let (second_attempt_tx, second_attempt_rx) = mpsc::channel();
1238        let (second_done_tx, second_done_rx) = mpsc::channel();
1239        let second_count = Arc::clone(&count);
1240        let second_ready = Arc::clone(&ready);
1241        let second = std::thread::spawn(move || {
1242            let result = ensure_running_with(&opts, true, |_| {
1243                second_count.fetch_add(1, Ordering::SeqCst);
1244                panic!("warm attach must not spawn");
1245            }, || second_attempt_tx.send(()).unwrap());
1246            second_done_tx.send((result, second_ready.load(Ordering::SeqCst))).unwrap();
1247        });
1248        second_attempt_rx.recv_timeout(WAIT).unwrap();
1249        assert_eq!(second_done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1250        assert!(!ready.load(Ordering::SeqCst));
1251        ready_tx.send(()).unwrap();
1252        assert_eq!(done_rx.recv_timeout(WAIT).unwrap(), Ok(Ensured::Spawned(std::process::id())));
1253        assert_eq!(second_done_rx.recv_timeout(WAIT).unwrap(), (Ok(Ensured::Running), true));
1254        first.join().unwrap();
1255        second.join().unwrap();
1256        assert_eq!(count.load(Ordering::SeqCst), 1);
1257        assert_eq!(registry::read_daemon_json(&paths).unwrap().pid, std::process::id());
1258        drop(daemon_lock);
1259    }
1260
1261    #[test]
1262    fn ensure_serializes_before_probe_with_missing_metadata() {
1263        serialization_case(false);
1264    }
1265
1266    #[test]
1267    fn ensure_serializes_before_probe_with_stale_metadata() {
1268        serialization_case(true);
1269    }
1270
1271    #[test]
1272    fn disabled_ensure_keeps_warm_attach_without_spawn_lock() {
1273        let dir = tempfile::tempdir().unwrap();
1274        let opts = DaemonOpts {
1275            runtime_dir: Some(dir.path().to_path_buf()),
1276            ..Default::default()
1277        };
1278        let paths = opts.paths();
1279        let _spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1280        let daemon_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1281        let ensure = || ensure_running_with(&opts, false,
1282            |_| panic!("disabled must not spawn"),
1283            || panic!("disabled must not attempt spawn lock"));
1284        assert_eq!(ensure(), Ok(Ensured::Running));
1285        drop(daemon_lock);
1286        std::fs::write(&paths.json, b"stale").unwrap();
1287        std::fs::write(&paths.pid, b"0\n").unwrap();
1288        assert_eq!(ensure(), Err(EnsureError::AutospawnDisabled));
1289        assert!(!paths.json.exists());
1290        assert!(!paths.pid.exists());
1291    }
1292}