Skip to main content

agent_engine/daemon/
mod.rs

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