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                // A restored owned session: the daemon accepts connections.
350                if real.is_owned() {
351                    arm_owner_rebind(&real).await;
352                }
353                real
354            }
355            Ok(other) => {
356                // Journal resolved somewhere else (e.g. compacted_into follow): keep it, drop the placeholder.
357                tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
358                self.insert(other.clone());
359                other
360            }
361            Err(e) => {
362                tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
363                self.insert(placeholder.clone());
364                placeholder
365            }
366        }
367    }
368
369    pub fn request_shutdown(&self, force: bool) {
370        if force {
371            self.force_shutdown.store(true, Ordering::SeqCst);
372        }
373        self.shutdown.cancel();
374    }
375}
376
377/// A running daemon (bound socket, lock held, accept loop spawned).
378pub struct Daemon {
379    pub paths: DaemonPaths,
380    pub state: Arc<DaemonState>,
381    accept: tokio::task::JoinHandle<()>,
382}
383
384impl Daemon {
385    /// Reap stale files, take the flock, bind (dir 0700 / sock 0600), write
386    /// `daemon.json`, spawn the accept loop, signal the ready fd if any.
387    pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
388        let paths = opts.paths();
389        std::fs::create_dir_all(&paths.dir)?;
390        #[cfg(unix)]
391        {
392            use std::os::unix::fs::PermissionsExt;
393            std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
394        }
395        // C3: after `reload` the new image ADOPTS the inherited flock and
396        // skips `reap_stale` (the old image's files are ours).
397        let adopted = reload::adopt_from_env(&paths)?;
398        let (lock, reload_state) = match adopted {
399            Some((lock, rs, rs_path)) => {
400                let _ = std::fs::remove_file(&rs_path);
401                (lock, Some(rs))
402            }
403            None => {
404                registry::reap_stale(&paths);
405                let lock = match DaemonLock::try_acquire(&paths)? {
406                    Some(l) => l,
407                    None => {
408                        let who = registry::read_daemon_json(&paths).map(|i| i.pid);
409                        anyhow::bail!(
410                            "another daemon holds {} (pid {})",
411                            paths.lock.display(),
412                            who.map_or("unknown".to_string(), |p| p.to_string())
413                        );
414                    }
415                };
416                (lock, None)
417            }
418        };
419        let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
420        let listener = listener::bind(&paths.sock)?;
421
422        let info = DaemonInfo {
423            pid: std::process::id(),
424            protocol_version: crate::session::wire::PROTOCOL_VERSION,
425            daemon_version: crate::session::wire::binary_version(),
426            profile: opts.profile.clone(),
427            started_at: chrono::Utc::now(),
428            socket: paths.sock.to_string_lossy().into_owned(),
429            exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
430            generation,
431        };
432        let meta = registry::Metadata::start(paths.clone(), info.clone())?;
433
434        let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
435        let state = Arc::new(DaemonState {
436            host,
437            paths: paths.clone(),
438            profile: opts.profile.clone(),
439            started: Instant::now(),
440            factory,
441            connections: AtomicUsize::new(0),
442            shutdown: CancellationToken::new(),
443            force_shutdown: AtomicBool::new(false),
444            generation,
445            reloading: AtomicBool::new(false),
446            reload_generation: std::sync::atomic::AtomicU64::new(generation),
447            reload_announce: Mutex::new(CancellationToken::new()),
448            lock_fd: Mutex::new(Some(lock)),
449            reload_aliases: Mutex::new(HashMap::new()),
450            meta,
451            pins: Arc::new(pins::PinBook::for_active_profile()),
452            restoring: AtomicBool::new(false),
453        });
454        state.host.set_pin_sink(Arc::clone(&state.pins) as Arc<dyn crate::session::PinSink>);
455
456        // Before any session of this image registers: drop registrations no
457        // running session owns (dead owner pid). After a reload, also every
458        // one naming our own pid: the old image exec'd without ending its
459        // sessions, and the ones still live re-register when they rehydrate
460        // below, under their current (e.g. post-compaction) id. On a fresh
461        // start our pid may already own live registrations (a daemon hosted
462        // in a process that runs other sessions), so it is left alone.
463        let own_pid = reload_state.as_ref().map(|_| info.pid);
464        let swept = crate::events::registry::sweep_stale_registrations(own_pid);
465        if swept > 0 {
466            tracing::info!(swept, "daemon: removed stale session registrations");
467        }
468
469        // C3: rehydrate BEFORE accepting so a reconnecting client finds its session.
470        if let Some(rs) = &reload_state {
471            reload::rehydrate(&state, rs).await;
472        }
473
474        let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
475        // Owned sessions the rehydrate restored start their owner's re-bind
476        // grace now that the owner can actually connect (never during the
477        // restore, which can take longer than the grace).
478        if reload_state.is_some() {
479            for h in state.live_sessions().into_iter().filter(|h| h.is_owned()) {
480                arm_owner_rebind(&h).await;
481            }
482        }
483        if let Some(idle) = opts.idle_exit {
484            tokio::spawn(idle_monitor(Arc::clone(&state), idle));
485        }
486        signal_ready();
487        // Cold start only: a reload hands its live sessions over already.
488        if reload_state.is_none() && pins::restore_enabled() {
489            tokio::spawn(pins::restore_all(Arc::clone(&state)));
490        }
491        tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
492        Ok(Self { paths, state, accept })
493    }
494
495    pub fn shutdown_token(&self) -> CancellationToken {
496        self.state.shutdown.clone()
497    }
498
499    /// Block until shutdown is requested (frame, signal, idle), then end
500    /// every session inside one budget and unlink the files.
501    pub async fn wait(self) {
502        self.state.shutdown.cancelled().await;
503        let force = self.state.force_shutdown.load(Ordering::SeqCst);
504        lifecycle::shutdown_all(&self.state, force).await;
505        self.accept.abort();
506        let _ = self.accept.await;
507        crate::events::socket::cleanup_socket(&self.paths.sock.to_string_lossy());
508        {
509            let lock = self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner());
510            if let Some(lock) = lock.as_ref() {
511                self.state.meta.cleanup(lock);
512            }
513        }
514        tracing::info!("daemon: stopped");
515        // Release the flock last.
516        drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
517    }
518}
519
520/// How long a `Status` probe may take before the session counts as busy.
521const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
522
523/// Whether `handle`'s actor is between turns. Asks `Query{Status}` under the
524/// reserved [`crate::session::wire::IDLE_PROBE_QUERY_ID`] (transports swallow
525/// it) and reads `streaming`. A no-answer inside [`IDLE_PROBE_TIMEOUT`] —
526/// the actor is inside `compact()`/preflight, or its queue is full — counts
527/// as busy: the daemon never exits under a running turn (jcode mistake #1).
528pub async fn session_is_idle(handle: &SessionHandle) -> bool {
529    use crate::session::wire::IDLE_PROBE_QUERY_ID;
530    use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
531    // B3: a parked session is idle by construction — no probe, no wake.
532    if handle.lifecycle() == SessionLifecycle::Parked {
533        return true;
534    }
535    let mut rx = handle.subscribe();
536    if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
537        return false;
538    }
539    let probe = async {
540        loop {
541            match rx.recv().await {
542                Ok(env) => {
543                    if let SessionEventWire::QueryResult { id, value } = env.event {
544                        if id == IDLE_PROBE_QUERY_ID {
545                            // Real actor: {"streaming": bool, "pending_prompts": n}. A backend that
546                            // does not implement Status (echo) has no turns to protect.
547                            let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
548                            let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
549                            return !streaming && prompts == 0;
550                        }
551                    }
552                }
553                Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
554                Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
555            }
556        }
557    };
558    tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
559}
560
561/// Idle = zero connections, no pinned session AND every live session
562/// between turns, held for `idle` continuously. Clients and pins count
563/// first so a probe is never sent while someone is attached. A pin holds
564/// the exit because exiting would end the session the user asked to keep.
565pub async fn daemon_is_idle(state: &DaemonState) -> bool {
566    if state.connections.load(Ordering::SeqCst) > 0 {
567        return false;
568    }
569    if state.restoring.load(Ordering::SeqCst) {
570        return false;
571    }
572    let sessions = state.live_sessions();
573    if sessions.iter().any(|h| h.pinned() && h.is_alive()) {
574        return false;
575    }
576    for h in sessions {
577        if !session_is_idle(&h).await {
578            return false;
579        }
580    }
581    true
582}
583
584/// `ArmOwnerRebind` to a restored owned session, retried while its command
585/// queue is full (as `close` retries). Should it still not arrive, the
586/// session's fallback deadline ends it (`owned::owner_restore_cap`).
587async fn arm_owner_rebind(h: &crate::session::SessionHandle) {
588    let deadline = tokio::time::Instant::now() + close::CLOSE_SEND_RETRY;
589    loop {
590        match h.send(crate::session::SessionCommand::ArmOwnerRebind).await {
591            Err(crate::session::TransportError::Backpressure) if tokio::time::Instant::now() < deadline => {
592                tokio::time::sleep(Duration::from_millis(20)).await;
593            }
594            Err(crate::session::TransportError::Backpressure) => {
595                tracing::warn!(session = %h.id, "daemon: owned session's queue stayed full; its fallback deadline applies");
596                return;
597            }
598            _ => return,
599        }
600    }
601}
602
603async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
604    let mut idle_since: Option<Instant> = None;
605    let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
606    loop {
607        tokio::select! {
608            _ = state.shutdown.cancelled() => return,
609            _ = tokio::time::sleep(tick) => {}
610        }
611        if !daemon_is_idle(&state).await {
612            idle_since = None;
613            continue;
614        }
615        let since = *idle_since.get_or_insert_with(Instant::now);
616        if since.elapsed() >= idle {
617            tracing::info!("daemon: idle for {:?}, exiting", idle);
618            state.request_shutdown(false);
619            return;
620        }
621    }
622}
623
624/// Child side of the ready pipe: write `R` to `SYNAPS_DAEMON_READY_FD` (if
625/// set), close it, and scrub the env so tool subprocesses do not inherit it.
626pub fn signal_ready() {
627    #[cfg(unix)]
628    {
629        let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
630            return;
631        };
632        std::env::remove_var(READY_FD_ENV);
633        // SAFETY: fd is the write end handed to us by the parent; we own it.
634        unsafe {
635            let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
636            libc::close(fd);
637        }
638    }
639    #[cfg(windows)]
640    {
641        let Ok(name) = std::env::var(READY_EVENT_ENV) else {
642            return;
643        };
644        std::env::remove_var(READY_EVENT_ENV);
645        if let Err(e) = win::signal_event(&name) {
646            tracing::warn!(error = %e, "daemon: could not signal the spawner's ready event");
647        }
648        // The spawner stops reading our stderr now.
649        win::detach_stdio();
650    }
651}
652
653/// The detached daemon's program and argv: `exe cmd_args`, wrapped in the
654/// daemon's own scope (in `daemon_scope_slice` when that is set) when scopes
655/// are available for `effective_env`.
656#[cfg(unix)]
657fn daemon_launch(
658    effective_env: &[(std::ffi::OsString, std::ffi::OsString)],
659    exe: PathBuf,
660    cmd_args: Vec<std::ffi::OsString>,
661) -> (std::ffi::OsString, Vec<std::ffi::OsString>) {
662    let kind = crate::process_scope::ScopeKind::Daemon;
663    match crate::process_scope::launcher_for_effective_env(effective_env, None, kind) {
664        Some(launcher) => launcher.wrap("synaps daemon", exe, cmd_args),
665        None => (exe.into_os_string(), cmd_args),
666    }
667}
668
669/// Parent side: `current_exe daemon --foreground …` in its own session
670/// (`setsid`), stdout → null, stderr → pipe; waits ≤ [`SPAWN_READY_TIMEOUT`]
671/// (15 s: the daemon's ≤ 10 s extension wait plus margin) for `R` on the
672/// ready pipe. EOF before `R` = child died → error with its stderr tail.
673#[cfg(unix)]
674pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
675    use std::io::Read;
676    use std::os::unix::process::CommandExt;
677
678    let exe = std::env::current_exe()?;
679
680
681    let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
682    if let Some(p) = &opts.profile {
683        cmd_args.extend(["--profile".into(), p.into()]);
684    }
685    if let Some(s) = &opts.socket {
686        cmd_args.extend(["--socket".into(), s.into()]);
687    }
688    if let Some(i) = opts.idle_exit {
689        cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
690    }
691    if opts.allow_legacy_mcp {
692        cmd_args.push("--allow-legacy-mcp".into());
693    }
694    // Scope wrapping execs in place: PID, ready FD and stdio remain intact.
695    // Separate scopes do not protect against ancestor/global OOM.
696    let mut effective_env = crate::process_scope::inherited_env();
697    crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
698    effective_env.retain(|(k, _)| k != READY_FD_ENV);
699    let (program, cmd_args) = daemon_launch(&effective_env, exe, cmd_args);
700    let mut fds = [0i32; 2];
701    // SAFETY: plain pipe(2) on a valid 2-slot array.
702    if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
703        return Err(std::io::Error::last_os_error().into());
704    }
705    let (read_fd, write_fd) = (fds[0], fds[1]);
706    // Probe has finished before readiness capabilities exist.
707    for fd in [read_fd, write_fd] {
708        if unsafe { libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC) } < 0 {
709            let error = std::io::Error::last_os_error();
710            unsafe { libc::close(read_fd); libc::close(write_fd); }
711            return Err(error.into());
712        }
713    }
714    crate::process_scope::set_env(&mut effective_env, READY_FD_ENV, write_fd.to_string());
715    let mut cmd = std::process::Command::new(program);
716    cmd.args(cmd_args);
717    cmd.env_clear().envs(effective_env)
718        .stdin(std::process::Stdio::null())
719        .stdout(std::process::Stdio::null())
720        .stderr(std::process::Stdio::piped());
721    // SAFETY: pre_exec runs in the forked child before exec; only async-signal-safe calls.
722    unsafe {
723        cmd.pre_exec(move || {
724            libc::close(read_fd);
725            // Clear CLOEXEC on the write end so it survives exec.
726            let flags = libc::fcntl(write_fd, libc::F_GETFD);
727            if flags >= 0 {
728                libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
729            }
730            libc::setsid();
731            Ok(())
732        });
733    }
734    let mut child = match cmd.spawn() {
735        Ok(child) => child,
736        Err(error) => {
737            unsafe { libc::close(read_fd); libc::close(write_fd); }
738            return Err(error.into());
739        }
740    };
741    // SAFETY: parent no longer needs the write end; closing it makes EOF mean "child died".
742    unsafe { libc::close(write_fd) };
743    let pid = child.id();
744
745    // Wait for 'R' with a deadline (poll(2) on the read end).
746    let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
747    let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
748    let ready = loop {
749        let left = deadline.saturating_duration_since(Instant::now());
750        if left.is_zero() {
751            break false;
752        }
753        // SAFETY: one valid pollfd.
754        let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
755        if n < 0 {
756            let e = std::io::Error::last_os_error();
757            if e.kind() == std::io::ErrorKind::Interrupted {
758                continue;
759            }
760            break false;
761        }
762        if n == 0 {
763            break false;
764        }
765        let mut byte = [0u8; 1];
766        // SAFETY: reading one byte into a 1-byte buffer.
767        let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
768        break r == 1 && byte[0] == b'R';
769    };
770    // SAFETY: done with the read end.
771    unsafe { libc::close(read_fd) };
772
773    if ready {
774        // Keep draining stderr so the child never blocks on a full pipe.
775        if let Some(mut err) = child.stderr.take() {
776            std::thread::spawn(move || {
777                let mut sink = Vec::new();
778                let _ = err.read_to_end(&mut sink);
779            });
780        }
781        drop(child);
782        return Ok(pid);
783    }
784    // Stop/reap before reading stderr; descendants may retain its write end.
785    let status = child.try_wait().ok().flatten().or_else(|| {
786        let _ = child.kill();
787        child.wait().ok()
788    });
789    let mut tail = String::new();
790    if let Some(mut err) = child.stderr.take() {
791        use std::os::fd::AsRawFd;
792        let fd = err.as_raw_fd();
793        let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
794        if flags >= 0 && unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } >= 0 {
795            // Bounded bytes and nonblocking reads, even if a descendant holds
796            // the pipe open or keeps writing after the wrapper exits.
797            let mut bytes = Vec::new();
798            let _ = err.by_ref().take(64 * 1024).read_to_end(&mut bytes);
799            tail = String::from_utf8_lossy(&bytes).into_owned();
800        }
801    }
802    let reason = tail
803        .lines()
804        .map(str::trim)
805        .rfind(|l| !l.is_empty())
806        .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
807    match (status, reason) {
808        (Some(st), Some(r)) if st.code().is_some() => {
809            anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
810        }
811        (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
812        (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
813    }
814}
815
816/// Windows: as the Unix version — `current_exe daemon --foreground …`,
817/// stdout → null, stderr → pipe, wait ≤ [`SPAWN_READY_TIMEOUT`] — with a
818/// named ready event instead of the ready pipe (the child's exit before it
819/// is set is the pipe's EOF-before-`R`) and a hidden-console, own-group,
820/// job-breakaway spawn instead of `setsid` ([`win::spawn_detached`]).
821#[cfg(windows)]
822pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
823    let exe = std::env::current_exe()?;
824    let mut cmd_args: Vec<std::ffi::OsString> = vec!["daemon".into(), "--foreground".into()];
825    if let Some(p) = &opts.profile {
826        cmd_args.extend(["--profile".into(), p.into()]);
827    }
828    if let Some(s) = &opts.socket {
829        cmd_args.extend(["--socket".into(), s.into()]);
830    }
831    if let Some(i) = opts.idle_exit {
832        cmd_args.extend(["--idle-exit".into(), i.as_secs().to_string().into()]);
833    }
834    if opts.allow_legacy_mcp {
835        cmd_args.push("--allow-legacy-mcp".into());
836    }
837    let mut effective_env = crate::process_scope::inherited_env();
838    crate::process_scope::set_env(&mut effective_env, "SYNAPS_DAEMON", "1");
839    effective_env.retain(|(k, _)| k != READY_FD_ENV && k != READY_EVENT_ENV);
840    let ready = win::Event::create_unique("ready")?;
841    crate::process_scope::set_env(&mut effective_env, READY_EVENT_ENV, ready.name());
842    let mut child = win::spawn_detached(&exe, &cmd_args, &effective_env)?;
843    let pid = child.id();
844
845    if win::wait_event_or_exit(&ready, &child, SPAWN_READY_TIMEOUT) == win::Wait::Signalled {
846        // Keep draining stderr (the daemon points it at NUL once ready).
847        if let Some(mut err) = child.take_stderr() {
848            std::thread::spawn(move || {
849                let mut sink = Vec::new();
850                let _ = std::io::Read::read_to_end(&mut err, &mut sink);
851            });
852        }
853        return Ok(pid);
854    }
855    // Stop/reap before reading stderr; descendants may retain its write end
856    // (the read is bounded in bytes and time).
857    let exited = child.wait(Duration::ZERO);
858    if exited.is_none() {
859        child.kill();
860        let _ = child.wait(Duration::from_secs(5));
861    }
862    let tail = win::stderr_tail(child.take_stderr(), 64 * 1024, Duration::from_millis(500));
863    let reason = tail
864        .lines()
865        .map(str::trim)
866        .rfind(|l| !l.is_empty())
867        .map(|l| l.trim_start_matches("synaps daemon: ").to_string());
868    match (exited, reason) {
869        (Some(code), Some(r)) => anyhow::bail!("daemon exited (code {code}): {r}"),
870        (_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
871        (_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
872    }
873}
874
875/// Result of [`ensure_running`].
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub enum Ensured {
878    /// A daemon already held the flock.
879    Running,
880    /// We spawned one (pid) and it signalled ready.
881    Spawned(u32),
882}
883
884/// Why [`ensure_running`] could not hand back a live daemon.
885#[derive(Debug, Clone, PartialEq, Eq)]
886pub enum EnsureError {
887    /// `SYNAPS_DAEMON_AUTOSPAWN=0` and nobody is running.
888    AutospawnDisabled,
889    /// The spawn failed or the child refused/died: one-line reason.
890    Spawn(String),
891}
892
893impl std::fmt::Display for EnsureError {
894    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
895        match self {
896            Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
897            Self::Spawn(r) => write!(f, "{r}"),
898        }
899    }
900}
901
902/// How long a second first-client waits for the spawn lock (the winner's
903/// `spawn_detached` is bounded by [`SPAWN_READY_TIMEOUT`]).
904const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
905
906/// Attach-path entry: a live daemon or a freshly spawned one (jcode model —
907/// first client spawns via `setsid` + ready-fd; the daemon holds the flock).
908/// The **spawn lock** (`<stem>.spawn.lock` next to the flock) serialises
909/// concurrent first-clients: the loser blocks on it, then re-probes the
910/// flock and finds the winner's daemon. Enabled warm attaches also acquire
911/// this lock (extra open/flock overhead, and the lock must be writable).
912/// Never touches the socket on ECONNREFUSED alone — `reap_stale` + the
913/// flock decide. Daemon startup must NOT acquire the spawn lock: its parent
914/// holds it until the child's ready-fd acknowledgement.
915pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
916    ensure_running_with(opts, autospawn_enabled(), spawn_detached, || {})
917}
918
919// Keep the real cleanup/probe/locking path testable without spawning the unit
920// test executable. The hook marks entry to the lock attempt, not readiness.
921fn ensure_running_with(
922    opts: &DaemonOpts,
923    auto: bool,
924    spawn: impl FnOnce(&DaemonOpts) -> anyhow::Result<u32>,
925    before_spawn_lock: impl FnOnce(),
926) -> Result<Ensured, EnsureError> {
927    let paths = opts.paths();
928    if !auto {
929        // Preserve the disabled path: no spawn-lock dependency, even warm.
930        registry::reap_stale(&paths);
931        return if registry::is_alive(&paths) {
932            Ok(Ensured::Running)
933        } else {
934            Err(EnsureError::AutospawnDisabled)
935        };
936    }
937    let _ = std::fs::create_dir_all(&paths.dir);
938    before_spawn_lock();
939    let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
940        .map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
941    // No enabled caller may reap/probe before this lock: another client's
942    // transient cleanup/probe DaemonLock is not evidence of a ready daemon.
943    // Somebody may have spawned while we waited.
944    registry::reap_stale(&paths);
945    if registry::is_alive(&paths) {
946        return Ok(Ensured::Running);
947    }
948    // An auto-spawned daemon exits on its own once the last client is gone
949    // and no turn is running (parked sessions are journaled; `--continue`
950    // brings them back). A daemon started by hand (`synaps daemon`) keeps
951    // whatever its caller asked for.
952    let mut opts = opts.clone();
953    if opts.idle_exit.is_none() {
954        opts.idle_exit = autospawn_idle_exit();
955    }
956    spawn(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
957}
958
959/// `SYNAPS_DAEMON_IDLE_EXIT_SECS` for auto-spawned daemons: default 10 s —
960/// when the last client disconnects and no turn is running, the daemon is
961/// holding ~100 MB for one reason only: a re-launch inside the window skips
962/// extension boot. Ten seconds covers "oops, wrong window"; `0`/`never` →
963/// never exit on idle.
964pub fn autospawn_idle_exit() -> Option<Duration> {
965    // Precedence: env > config (`daemon.idle_exit_secs`) > built-in default.
966    let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
967    autospawn_idle_exit_from(
968        std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
969        cfg_secs,
970    )
971}
972
973fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
974    // `0` (env or config) disables idle-exit; otherwise config is the default
975    // when the env var is absent or unparseable.
976    let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
977    match v.map(str::trim) {
978        Some("0" | "never" | "off") => None,
979        Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
980        None => from_cfg(),
981    }
982}
983
984/// Default idle grace for auto-spawned daemons.
985pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
986
987
988/// `synaps daemon --foreground` body: gate → boot host (cached MCP
989/// descriptors only) → extension discovery once → start → background MCP
990/// discovery → wait for SIGTERM/SIGINT/Shutdown frame/idle.
991pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
992    if !enabled() {
993        anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
994    }
995    // Windows reload: take `daemon.lock` over from the previous image before
996    // booting anything. It exits on our signal, as `execv` would have
997    // replaced it, so nothing below ever runs beside it.
998    #[cfg(windows)]
999    reload::take_over_from_env(&opts.paths())?;
1000    // Background MCP discovery: a hung/dead MCP server must never delay the
1001    // ready byte (the spawner waits only SPAWN_READY_TIMEOUT).
1002    let host = EngineHost::boot_and_install(crate::HostOpts {
1003        profile: opts.profile.clone(),
1004        no_extensions: false,
1005        mcp_discovery: crate::McpDiscovery::Background,
1006    })
1007    .await?;
1008    host.report_config_warnings();
1009    // Sidecars spawn once per daemon: discover before accepting (bounded by
1010    // EXTENSION_DISCOVERY_WAIT, which SPAWN_READY_TIMEOUT outwaits).
1011    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1012    let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
1013    let wait = async {
1014        while let Some(ev) = rx.recv().await {
1015            if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
1016                tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
1017                break;
1018            }
1019        }
1020    };
1021    if tokio::time::timeout(EXTENSION_DISCOVERY_WAIT, wait).await.is_err() {
1022        tracing::warn!(
1023            wait = ?EXTENSION_DISCOVERY_WAIT,
1024            "daemon: extension discovery still running; accepting anyway"
1025        );
1026    }
1027    // C3 router: widget.* frames from every sidecar fan out to every live
1028    // session (daemon-global widgets today — frames carry no session id).
1029    let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
1030
1031    let mut opts = opts;
1032    if opts.factory.is_none() {
1033        opts.factory = Some(host_factory(&host));
1034    }
1035    let mcp_host = Arc::clone(&host);
1036    let daemon = Daemon::start(host, opts).await?;
1037    // Accepting and ready: now discover uncached MCP servers. New servers'
1038    // tools join the shared registry when it finishes (see
1039    // `EngineHost::spawn_background_mcp_discovery` for live-session effects).
1040    let _mcp_discovery = mcp_host.spawn_background_mcp_discovery();
1041    drop(mcp_host);
1042    let state = Arc::clone(&daemon.state);
1043    tokio::spawn(async move {
1044        #[cfg(unix)]
1045        {
1046            let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
1047            tokio::select! {
1048                _ = tokio::signal::ctrl_c() => {}
1049                _ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
1050                _ = state.shutdown.cancelled() => return,
1051            }
1052        }
1053        #[cfg(not(unix))]
1054        {
1055            tokio::select! {
1056                _ = tokio::signal::ctrl_c() => {}
1057                _ = state.shutdown.cancelled() => return,
1058            }
1059        }
1060        tracing::info!("daemon: signal received, shutting down");
1061        state.request_shutdown(false);
1062    });
1063    daemon.wait().await;
1064    Ok(())
1065}
1066
1067#[cfg(test)]
1068mod flag_tests {
1069    use super::*;
1070
1071    #[test]
1072    fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
1073        // cfg_secs = 10 mirrors the DaemonConfig default.
1074        let d = AUTOSPAWN_IDLE_EXIT.as_secs();
1075        assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
1076        assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
1077        assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
1078        assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
1079        for never in ["0", "never", "off"] {
1080            assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
1081        }
1082        // garbage env → the config default, never "no exit"
1083        assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
1084        // env absent → config value is the default (env > config > builtin).
1085        assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
1086        // config 0 disables idle-exit when env is absent.
1087        assert_eq!(autospawn_idle_exit_from(None, 0), None);
1088        // env still wins over config.
1089        assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
1090    }
1091
1092    #[test]
1093    fn enabled_env_matrix() {
1094        assert!(enabled_from(None), "unset → on");
1095        for on in ["1", "true", "yes", "on", "", "anything"] {
1096            assert!(enabled_from(Some(on)), "{on:?} → on");
1097        }
1098        for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
1099            assert!(!enabled_from(Some(off)), "{off:?} → off");
1100        }
1101    }
1102
1103    #[test]
1104    fn profile_mismatch_refuses_a_daemon_of_another_profile() {
1105        assert_eq!(profile_mismatch(None, None), None);
1106        assert_eq!(profile_mismatch(Some("axon"), Some("axon")), None);
1107        // empty == default
1108        assert_eq!(profile_mismatch(Some(""), None), None);
1109        assert_eq!(profile_mismatch(None, Some("")), None);
1110        // a profile client on the default daemon (the 0.10.1 clobber) and back
1111        let m = profile_mismatch(None, Some("axon")).expect("mismatch");
1112        assert!(m.contains("the default profile") && m.contains("profile `axon`"), "{m}");
1113        assert!(m.contains("synaps daemon stop"), "{m}");
1114        let m = profile_mismatch(Some("axon"), None).expect("mismatch");
1115        assert!(m.contains("synaps --profile axon daemon stop"), "{m}");
1116        // sanitised socket stems collide; the profile names do not
1117        assert!(profile_mismatch(Some("a.b"), Some("a_b")).is_some());
1118    }
1119
1120    #[test]
1121    fn env_is_off_is_case_and_space_insensitive() {
1122        assert!(env_is_off(Some("NO")));
1123        assert!(!env_is_off(Some("1")));
1124        assert!(!env_is_off(None));
1125    }
1126}
1127
1128#[cfg(all(test, target_os = "linux"))]
1129mod scope_tests {
1130    use super::*;
1131    use crate::process_scope::test_support::{fake_env, fake_systemd_run, recorded};
1132
1133    fn launch(env: &[(String, String)]) -> (std::ffi::OsString, Vec<String>) {
1134        let env: Vec<(std::ffi::OsString, std::ffi::OsString)> =
1135            env.iter().map(|(k, v)| (k.into(), v.into())).collect();
1136        let exe = PathBuf::from("/opt/synaps/bin/synaps");
1137        let (program, args) = daemon_launch(&env, exe, vec!["daemon".into(), "--foreground".into()]);
1138        (program, args.iter().map(|a| a.to_string_lossy().into_owned()).collect())
1139    }
1140
1141    /// The daemon's scope goes in `daemon_scope_slice`, never in the tools'
1142    /// `tool_scope_slice`; with neither key set its argv is today's.
1143    #[test]
1144    #[serial_test::serial(synaps_base_dir)]
1145    fn the_daemon_scope_goes_in_the_daemon_slice_only() {
1146        let base = crate::test_env::BaseDirGuard::new();
1147        let _scopes = crate::test_env::EnvVarGuard::remove("SYNAPS_PROCESS_SCOPES");
1148        let dir = tempfile::tempdir().unwrap();
1149        let fake = fake_systemd_run(dir.path());
1150        let env = fake_env(&fake);
1151        let today = [
1152            "--user",
1153            "--scope",
1154            "--quiet",
1155            "--collect",
1156            "--description=synaps daemon",
1157            "--",
1158            "/opt/synaps/bin/synaps",
1159            "daemon",
1160            "--foreground",
1161        ];
1162        let (program, args) = launch(&env);
1163        assert_eq!(program, fake.clone().into_os_string());
1164        assert_eq!(args, today);
1165        let config = base.path().join("config");
1166        std::fs::write(&config, "tool_scope_slice = t_tools.slice\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1167        let (program, args) = launch(&env);
1168        assert_eq!(program, fake.clone().into_os_string());
1169        assert_eq!(
1170            args,
1171            [
1172                "--user",
1173                "--scope",
1174                "--quiet",
1175                "--collect",
1176                "--slice=d_daemon.slice",
1177                "--description=synaps daemon",
1178                "--",
1179                "/opt/synaps/bin/synaps",
1180                "daemon",
1181                "--foreground",
1182            ]
1183        );
1184        assert!(recorded(dir.path(), "probe.args").contains(&"--slice=d_daemon.slice".to_string()));
1185        // Only the tools' key: the daemon's scope stays where it was.
1186        std::fs::write(&config, "tool_scope_slice = t_tools.slice\n").unwrap();
1187        assert_eq!(launch(&env).1, today);
1188        // Scopes off: the daemon starts directly.
1189        std::fs::write(&config, "process_scopes = off\ndaemon_scope_slice = d_daemon.slice\n").unwrap();
1190        let (program, args) = launch(&env);
1191        assert_eq!(program, std::ffi::OsString::from("/opt/synaps/bin/synaps"));
1192        assert_eq!(args, ["daemon", "--foreground"]);
1193    }
1194}
1195
1196#[cfg(test)]
1197mod ensure_tests {
1198    use super::*;
1199    use std::sync::mpsc;
1200
1201    const WAIT: Duration = Duration::from_secs(3);
1202    const BLOCKED: Duration = Duration::from_millis(100);
1203
1204    fn serialization_case(stale_metadata: bool) {
1205        let dir = tempfile::tempdir().unwrap();
1206        let opts = DaemonOpts {
1207            runtime_dir: Some(dir.path().to_path_buf()),
1208            profile: Some("ensure-serialization".into()),
1209            // Avoid reading ambient idle-exit configuration in this test.
1210            idle_exit: Some(Duration::from_secs(1)),
1211            ..Default::default()
1212        };
1213        let paths = opts.paths();
1214        let spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1215        let cleanup_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1216        if stale_metadata {
1217            std::fs::write(&paths.json, b"stale metadata").unwrap();
1218            std::fs::write(&paths.pid, b"0\n").unwrap();
1219        }
1220        let count = Arc::new(AtomicUsize::new(0));
1221        let ready = Arc::new(AtomicBool::new(false));
1222        let (attempt_tx, attempt_rx) = mpsc::channel();
1223        let (daemon_tx, daemon_rx) = mpsc::channel();
1224        let (ready_tx, ready_rx) = mpsc::channel();
1225        let (done_tx, done_rx) = mpsc::channel();
1226        let first_opts = opts.clone();
1227        let first_count = Arc::clone(&count);
1228        let first_ready = Arc::clone(&ready);
1229        let first = std::thread::spawn(move || {
1230            let result = ensure_running_with(&first_opts, true, |opts| {
1231                first_count.fetch_add(1, Ordering::SeqCst);
1232                let paths = opts.paths();
1233                // The real ensure path must have reaped the old metadata.
1234                assert!(!paths.json.exists());
1235                assert!(!paths.pid.exists());
1236                let lock = DaemonLock::try_acquire(&paths)?.expect("daemon lock free after reap");
1237                let pid = std::process::id();
1238                registry::write_daemon_json(&paths, &DaemonInfo {
1239                    pid,
1240                    protocol_version: 1,
1241                    daemon_version: "test".into(),
1242                    profile: opts.profile.clone(),
1243                    started_at: chrono::Utc::now(),
1244                    socket: paths.sock.display().to_string(),
1245                    exe: None,
1246                    generation: 1,
1247                })?;
1248                // Main retains daemon ownership past both callers' returns.
1249                daemon_tx.send(lock).unwrap();
1250                ready_rx.recv_timeout(WAIT).unwrap();
1251                first_ready.store(true, Ordering::SeqCst);
1252                Ok(pid)
1253            }, || attempt_tx.send(()).unwrap());
1254            done_tx.send(result).unwrap();
1255        });
1256        // Trace entry to the actual lock attempt before asserting absence.
1257        attempt_rx.recv_timeout(WAIT).unwrap();
1258        assert_eq!(done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1259        assert_eq!(count.load(Ordering::SeqCst), 0);
1260        assert_eq!(paths.json.exists(), stale_metadata);
1261        assert_eq!(paths.pid.exists(), stale_metadata);
1262        drop(cleanup_lock);
1263        drop(spawn_lock);
1264        let daemon_lock = daemon_rx.recv_timeout(WAIT).unwrap();
1265
1266        // A second real ensure caller sees daemon ownership and metadata, but
1267        // must still wait for the spawning caller's ready acknowledgement.
1268        let (second_attempt_tx, second_attempt_rx) = mpsc::channel();
1269        let (second_done_tx, second_done_rx) = mpsc::channel();
1270        let second_count = Arc::clone(&count);
1271        let second_ready = Arc::clone(&ready);
1272        let second = std::thread::spawn(move || {
1273            let result = ensure_running_with(&opts, true, |_| {
1274                second_count.fetch_add(1, Ordering::SeqCst);
1275                panic!("warm attach must not spawn");
1276            }, || second_attempt_tx.send(()).unwrap());
1277            second_done_tx.send((result, second_ready.load(Ordering::SeqCst))).unwrap();
1278        });
1279        second_attempt_rx.recv_timeout(WAIT).unwrap();
1280        assert_eq!(second_done_rx.recv_timeout(BLOCKED), Err(mpsc::RecvTimeoutError::Timeout));
1281        assert!(!ready.load(Ordering::SeqCst));
1282        ready_tx.send(()).unwrap();
1283        assert_eq!(done_rx.recv_timeout(WAIT).unwrap(), Ok(Ensured::Spawned(std::process::id())));
1284        assert_eq!(second_done_rx.recv_timeout(WAIT).unwrap(), (Ok(Ensured::Running), true));
1285        first.join().unwrap();
1286        second.join().unwrap();
1287        assert_eq!(count.load(Ordering::SeqCst), 1);
1288        assert_eq!(registry::read_daemon_json(&paths).unwrap().pid, std::process::id());
1289        drop(daemon_lock);
1290    }
1291
1292    #[test]
1293    fn ensure_serializes_before_probe_with_missing_metadata() {
1294        serialization_case(false);
1295    }
1296
1297    #[test]
1298    fn ensure_serializes_before_probe_with_stale_metadata() {
1299        serialization_case(true);
1300    }
1301
1302    #[test]
1303    fn disabled_ensure_keeps_warm_attach_without_spawn_lock() {
1304        let dir = tempfile::tempdir().unwrap();
1305        let opts = DaemonOpts {
1306            runtime_dir: Some(dir.path().to_path_buf()),
1307            ..Default::default()
1308        };
1309        let paths = opts.paths();
1310        let _spawn_lock = registry::SpawnLock::acquire(&paths, WAIT).unwrap();
1311        let daemon_lock = DaemonLock::try_acquire(&paths).unwrap().unwrap();
1312        let ensure = || ensure_running_with(&opts, false,
1313            |_| panic!("disabled must not spawn"),
1314            || panic!("disabled must not attempt spawn lock"));
1315        assert_eq!(ensure(), Ok(Ensured::Running));
1316        drop(daemon_lock);
1317        std::fs::write(&paths.json, b"stale").unwrap();
1318        std::fs::write(&paths.pid, b"0\n").unwrap();
1319        assert_eq!(ensure(), Err(EnsureError::AutospawnDisabled));
1320        assert!(!paths.json.exists());
1321        assert!(!paths.pid.exists());
1322    }
1323}