pub mod conn;
pub mod lifecycle;
pub mod listener;
pub mod registry;
pub mod reload;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
use crate::session::{SessionConfig, SessionHandle, SessionId, SessionMeta};
use crate::EngineHost;
use registry::{DaemonInfo, DaemonLock, DaemonPaths};
pub const EXIT_REFUSED: i32 = 3;
pub const EXIT_VERSION: i32 = 2;
pub const EXIT_DAEMON_LOST: i32 = 4;
pub const READY_FD_ENV: &str = "SYNAPS_DAEMON_READY_FD";
pub const SPAWN_READY_TIMEOUT: Duration = Duration::from_secs(5);
pub fn enabled() -> bool {
enabled_from(std::env::var("SYNAPS_DAEMON").ok().as_deref())
}
fn env_is_off(v: Option<&str>) -> bool {
matches!(v.map(|s| s.trim().to_ascii_lowercase()).as_deref(), Some("0" | "false" | "off" | "no"))
}
fn enabled_from(v: Option<&str>) -> bool {
!env_is_off(v)
}
pub fn autospawn_enabled() -> bool {
!env_is_off(std::env::var("SYNAPS_DAEMON_AUTOSPAWN").ok().as_deref())
}
pub const DISABLED_NOTICE: &str = "daemon disabled by SYNAPS_DAEMON=0";
pub fn allow_legacy_mcp_env() -> bool {
matches!(std::env::var("SYNAPS_DAEMON_ALLOW_LEGACY_MCP").as_deref(), Ok("1") | Ok("true"))
}
pub type SessionFactory = Arc<
dyn Fn(SessionConfig) -> Pin<Box<dyn Future<Output = Result<SessionHandle, String>> + Send>>
+ Send
+ Sync,
>;
#[cfg(any(test, feature = "testing"))]
pub fn echo_factory() -> SessionFactory {
Arc::new(|cfg: SessionConfig| {
Box::pin(async move {
let id = SessionId(format!(
"echo-{}-{}",
chrono::Utc::now().format("%Y%m%d-%H%M%S%3f"),
uuid::Uuid::new_v4().simple()
));
let (handle, _task) = SessionHandle::echo_for_test(id);
let _ = cfg;
Ok(handle)
})
})
}
pub fn host_factory(host: &Arc<EngineHost>) -> SessionFactory {
let host = Arc::clone(host);
Arc::new(move |cfg: SessionConfig| {
let host = Arc::clone(&host);
Box::pin(async move { host.create_session(cfg).await.map_err(|e| e.to_string()) })
})
}
#[derive(Clone, Default)]
pub struct DaemonOpts {
pub socket: Option<PathBuf>,
pub profile: Option<String>,
pub idle_exit: Option<Duration>,
pub allow_legacy_mcp: bool,
pub runtime_dir: Option<PathBuf>,
pub factory: Option<SessionFactory>,
}
impl DaemonOpts {
pub fn paths(&self) -> DaemonPaths {
let mut p = match &self.runtime_dir {
Some(d) => registry::daemon_paths_in(d, self.profile.as_deref()),
None => registry::daemon_paths(self.profile.as_deref()),
};
if let Some(s) = &self.socket {
p.sock = s.clone();
}
p
}
}
pub struct DaemonState {
pub host: Arc<EngineHost>,
pub paths: DaemonPaths,
pub profile: Option<String>,
pub started: Instant,
pub factory: SessionFactory,
pub connections: AtomicUsize,
pub shutdown: CancellationToken,
pub force_shutdown: AtomicBool,
pub generation: u64,
pub reloading: AtomicBool,
pub reload_generation: std::sync::atomic::AtomicU64,
pub reload_announce: Mutex<CancellationToken>,
pub lock_fd: Mutex<Option<DaemonLock>>,
pub reload_aliases: Mutex<HashMap<String, String>>,
}
impl DaemonState {
pub fn uptime_s(&self) -> u64 {
self.started.elapsed().as_secs()
}
pub fn announce(&self) -> CancellationToken {
self.reload_announce
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
pub fn fire_announce(&self) {
self.announce().cancel();
}
pub fn reset_announce(&self) {
*self.reload_announce.lock().unwrap_or_else(|e| e.into_inner()) = CancellationToken::new();
}
pub fn live_sessions(&self) -> Vec<SessionHandle> {
self.host.session_handles()
}
pub fn session_metas(&self) -> Vec<SessionMeta> {
self.host.sessions()
}
pub fn attach(&self, id: &SessionId) -> Option<SessionHandle> {
self.host.attach(id).filter(|h| h.is_alive()).or_else(|| {
let alias = self
.reload_aliases
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(id.as_str())
.cloned()?;
self.host
.attach(&SessionId::from(alias.as_str()))
.filter(|h| h.is_alive())
})
}
pub fn insert(&self, handle: SessionHandle) {
self.host.adopt_session(handle);
}
pub fn remove(&self, id: &SessionId) {
self.host.remove_session(id);
}
pub async fn create(&self, cfg: SessionConfig) -> Result<SessionHandle, String> {
let handle = (self.factory)(cfg).await?;
self.insert(handle.clone());
Ok(handle)
}
pub async fn retry_locked_placeholder(&self, placeholder: SessionHandle) -> SessionHandle {
let cfg = SessionConfig {
continue_session: Some(Some(placeholder.id.as_str().to_string())),
..placeholder.placeholder_config().cloned().unwrap_or_default()
};
self.remove(&placeholder.id);
match (self.factory)(cfg).await {
Ok(real) if real.id == placeholder.id || real.journal_id() == placeholder.id.as_str() => {
tracing::info!(session = %placeholder.id, "daemon: locked journal released — placeholder replaced by the real session");
self.insert(real.clone());
real
}
Ok(other) => {
tracing::warn!(session = %placeholder.id, resolved = %other.id, "daemon: placeholder retry resolved to a different id");
self.insert(other.clone());
other
}
Err(e) => {
tracing::debug!(session = %placeholder.id, error = %e, "daemon: journal still locked; keeping placeholder");
self.insert(placeholder.clone());
placeholder
}
}
}
pub fn request_shutdown(&self, force: bool) {
if force {
self.force_shutdown.store(true, Ordering::SeqCst);
}
self.shutdown.cancel();
}
}
pub struct Daemon {
pub paths: DaemonPaths,
pub state: Arc<DaemonState>,
accept: tokio::task::JoinHandle<()>,
}
pub fn legacy_mcp_conflict(host: &EngineHost, allow: bool) -> Option<String> {
if allow || allow_legacy_mcp_env() {
return None;
}
let cfg = host.config();
if !cfg.progressive_tool_disclosure && host.mcp_server_count() > 0 {
return Some(format!(
"progressive_tool_disclosure=false with {} MCP server(s) configured: legacy McpTool connections would be shared across sessions. Set progressive_tool_disclosure=true, or pass --allow-legacy-mcp / SYNAPS_DAEMON_ALLOW_LEGACY_MCP=1",
host.mcp_server_count()
));
}
None
}
impl Daemon {
pub async fn start(host: Arc<EngineHost>, opts: DaemonOpts) -> anyhow::Result<Self> {
let paths = opts.paths();
std::fs::create_dir_all(&paths.dir)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&paths.dir, std::fs::Permissions::from_mode(0o700))?;
}
if let Some(msg) = legacy_mcp_conflict(&host, opts.allow_legacy_mcp) {
anyhow::bail!("refusing to start: {msg}");
}
let adopted = reload::adopt_from_env()?;
let (lock, reload_state) = match adopted {
Some((lock, rs, rs_path)) => {
let _ = std::fs::remove_file(&rs_path);
(lock, Some(rs))
}
None => {
registry::reap_stale(&paths);
let lock = match DaemonLock::try_acquire(&paths)? {
Some(l) => l,
None => {
let who = registry::read_daemon_json(&paths).map(|i| i.pid);
anyhow::bail!(
"another daemon holds {} (pid {})",
paths.lock.display(),
who.map_or("unknown".to_string(), |p| p.to_string())
);
}
};
(lock, None)
}
};
let generation = reload_state.as_ref().map_or(1, |rs| rs.generation);
let listener = listener::bind(&paths.sock)?;
let factory = opts.factory.clone().unwrap_or_else(|| host_factory(&host));
let state = Arc::new(DaemonState {
host,
paths: paths.clone(),
profile: opts.profile.clone(),
started: Instant::now(),
factory,
connections: AtomicUsize::new(0),
shutdown: CancellationToken::new(),
force_shutdown: AtomicBool::new(false),
generation,
reloading: AtomicBool::new(false),
reload_generation: std::sync::atomic::AtomicU64::new(generation),
reload_announce: Mutex::new(CancellationToken::new()),
lock_fd: Mutex::new(Some(lock)),
reload_aliases: Mutex::new(HashMap::new()),
});
let info = DaemonInfo {
pid: std::process::id(),
protocol_version: crate::session::wire::PROTOCOL_VERSION,
daemon_version: crate::session::wire::binary_version(),
profile: opts.profile.clone(),
started_at: chrono::Utc::now(),
socket: paths.sock.to_string_lossy().into_owned(),
exe: registry::read_daemon_json(&paths).and_then(|i| i.exe).or_else(|| Some(reload::resolve_exe())),
generation,
};
registry::write_daemon_json(&paths, &info)?;
if let Some(rs) = &reload_state {
reload::rehydrate(&state, rs).await;
}
let accept = tokio::spawn(listener::accept_loop(Arc::clone(&state), listener, state.shutdown.clone()));
if let Some(idle) = opts.idle_exit {
tokio::spawn(idle_monitor(Arc::clone(&state), idle));
}
signal_ready();
tracing::info!(sock = %paths.sock.display(), pid = info.pid, generation, "daemon: listening");
Ok(Self { paths, state, accept })
}
pub fn shutdown_token(&self) -> CancellationToken {
self.state.shutdown.clone()
}
pub async fn wait(self) {
self.state.shutdown.cancelled().await;
let force = self.state.force_shutdown.load(Ordering::SeqCst);
lifecycle::shutdown_all(&self.state, force).await;
self.accept.abort();
let _ = self.accept.await;
for p in [&self.paths.sock, &self.paths.json, &self.paths.pid] {
crate::events::socket::cleanup_socket(&p.to_string_lossy());
}
tracing::info!("daemon: stopped");
drop(self.state.lock_fd.lock().unwrap_or_else(|e| e.into_inner()).take());
}
}
const IDLE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
pub async fn session_is_idle(handle: &SessionHandle) -> bool {
use crate::session::wire::IDLE_PROBE_QUERY_ID;
use crate::session::{SessionCommand, SessionEventWire, SessionLifecycle, SessionQuery};
if handle.lifecycle() == SessionLifecycle::Parked {
return true;
}
let mut rx = handle.subscribe();
if handle.send(SessionCommand::Query { id: IDLE_PROBE_QUERY_ID, query: SessionQuery::Status }).await.is_err() {
return false;
}
let probe = async {
loop {
match rx.recv().await {
Ok(env) => {
if let SessionEventWire::QueryResult { id, value } = env.event {
if id == IDLE_PROBE_QUERY_ID {
let streaming = value.get("streaming").and_then(|v| v.as_bool()).unwrap_or(false);
let prompts = value.get("pending_prompts").and_then(|v| v.as_u64()).unwrap_or(0);
return !streaming && prompts == 0;
}
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return true,
}
}
};
tokio::time::timeout(IDLE_PROBE_TIMEOUT, probe).await.unwrap_or(false)
}
pub async fn daemon_is_idle(state: &DaemonState) -> bool {
if state.connections.load(Ordering::SeqCst) > 0 {
return false;
}
for h in state.live_sessions() {
if !session_is_idle(&h).await {
return false;
}
}
true
}
async fn idle_monitor(state: Arc<DaemonState>, idle: Duration) {
let mut idle_since: Option<Instant> = None;
let tick = Duration::from_secs(5).min(idle.max(Duration::from_millis(100)));
loop {
tokio::select! {
_ = state.shutdown.cancelled() => return,
_ = tokio::time::sleep(tick) => {}
}
if !daemon_is_idle(&state).await {
idle_since = None;
continue;
}
let since = *idle_since.get_or_insert_with(Instant::now);
if since.elapsed() >= idle {
tracing::info!("daemon: idle for {:?}, exiting", idle);
state.request_shutdown(false);
return;
}
}
}
pub fn signal_ready() {
#[cfg(unix)]
{
let Some(fd) = std::env::var(READY_FD_ENV).ok().and_then(|s| s.parse::<i32>().ok()) else {
return;
};
std::env::remove_var(READY_FD_ENV);
unsafe {
let _ = libc::write(fd, b"R".as_ptr() as *const libc::c_void, 1);
libc::close(fd);
}
}
}
#[cfg(unix)]
pub fn spawn_detached(opts: &DaemonOpts) -> anyhow::Result<u32> {
use std::io::Read;
use std::os::unix::process::CommandExt;
let exe = std::env::current_exe()?;
let mut fds = [0i32; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 {
return Err(std::io::Error::last_os_error().into());
}
let (read_fd, write_fd) = (fds[0], fds[1]);
let mut cmd = std::process::Command::new(exe);
cmd.arg("daemon").arg("--foreground");
if let Some(p) = &opts.profile {
cmd.arg("--profile").arg(p);
}
if let Some(s) = &opts.socket {
cmd.arg("--socket").arg(s);
}
if let Some(i) = opts.idle_exit {
cmd.arg("--idle-exit").arg(i.as_secs().to_string());
}
if opts.allow_legacy_mcp {
cmd.arg("--allow-legacy-mcp");
}
cmd.env("SYNAPS_DAEMON", "1")
.env(READY_FD_ENV, write_fd.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped());
unsafe {
cmd.pre_exec(move || {
libc::close(read_fd);
let flags = libc::fcntl(write_fd, libc::F_GETFD);
if flags >= 0 {
libc::fcntl(write_fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC);
}
libc::setsid();
Ok(())
});
}
let mut child = cmd.spawn()?;
unsafe { libc::close(write_fd) };
let pid = child.id();
let mut pfd = libc::pollfd { fd: read_fd, events: libc::POLLIN, revents: 0 };
let deadline = Instant::now() + SPAWN_READY_TIMEOUT;
let ready = loop {
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
break false;
}
let n = unsafe { libc::poll(&mut pfd, 1, left.as_millis() as i32) };
if n < 0 {
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
continue;
}
break false;
}
if n == 0 {
break false;
}
let mut byte = [0u8; 1];
let r = unsafe { libc::read(read_fd, byte.as_mut_ptr() as *mut libc::c_void, 1) };
break r == 1 && byte[0] == b'R';
};
unsafe { libc::close(read_fd) };
if ready {
if let Some(mut err) = child.stderr.take() {
std::thread::spawn(move || {
let mut sink = Vec::new();
let _ = err.read_to_end(&mut sink);
});
}
drop(child);
return Ok(pid);
}
let mut tail = String::new();
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut tail);
}
let status = child.try_wait().ok().flatten().or_else(|| {
let _ = child.kill();
child.wait().ok()
});
let reason = tail
.lines()
.map(str::trim)
.rfind(|l| !l.is_empty())
.map(|l| l.trim_start_matches("synaps daemon: ").to_string());
match (status, reason) {
(Some(st), Some(r)) if st.code().is_some() => {
anyhow::bail!("daemon exited (code {}): {r}", st.code().unwrap_or(-1))
}
(_, Some(r)) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}: {r}"),
(_, None) => anyhow::bail!("daemon did not become ready within {SPAWN_READY_TIMEOUT:?}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Ensured {
Running,
Spawned(u32),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnsureError {
AutospawnDisabled,
Spawn(String),
}
impl std::fmt::Display for EnsureError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AutospawnDisabled => write!(f, "no daemon running (SYNAPS_DAEMON_AUTOSPAWN=0)"),
Self::Spawn(r) => write!(f, "{r}"),
}
}
}
const SPAWN_LOCK_WAIT: Duration = SPAWN_READY_TIMEOUT.saturating_add(Duration::from_secs(5));
#[cfg(unix)]
pub fn ensure_running(opts: &DaemonOpts) -> Result<Ensured, EnsureError> {
let paths = opts.paths();
registry::reap_stale(&paths);
if registry::is_alive(&paths) {
return Ok(Ensured::Running);
}
if !autospawn_enabled() {
return Err(EnsureError::AutospawnDisabled);
}
let _ = std::fs::create_dir_all(&paths.dir);
let _guard = registry::SpawnLock::acquire(&paths, SPAWN_LOCK_WAIT)
.map_err(|e| EnsureError::Spawn(format!("spawn lock {}: {e}", paths.spawn_lock().display())))?;
registry::reap_stale(&paths);
if registry::is_alive(&paths) {
return Ok(Ensured::Running);
}
let mut opts = opts.clone();
if opts.idle_exit.is_none() {
opts.idle_exit = autospawn_idle_exit();
}
spawn_detached(&opts).map(Ensured::Spawned).map_err(|e| EnsureError::Spawn(e.to_string()))
}
pub fn autospawn_idle_exit() -> Option<Duration> {
let cfg_secs = crate::config::load_config().daemon.idle_exit_secs;
autospawn_idle_exit_from(
std::env::var("SYNAPS_DAEMON_IDLE_EXIT_SECS").ok().as_deref(),
cfg_secs,
)
}
fn autospawn_idle_exit_from(v: Option<&str>, cfg_secs: u64) -> Option<Duration> {
let from_cfg = || (cfg_secs != 0).then(|| Duration::from_secs(cfg_secs));
match v.map(str::trim) {
Some("0" | "never" | "off") => None,
Some(n) => n.parse::<u64>().ok().map(Duration::from_secs).or_else(from_cfg),
None => from_cfg(),
}
}
pub const AUTOSPAWN_IDLE_EXIT: Duration = Duration::from_secs(10);
pub async fn run_foreground(opts: DaemonOpts) -> anyhow::Result<()> {
if !enabled() {
anyhow::bail!("{DISABLED_NOTICE} (exit {EXIT_REFUSED})");
}
let host = EngineHost::boot_and_install(crate::HostOpts { profile: opts.profile.clone(), no_extensions: false }).await?;
if let Some(msg) = legacy_mcp_conflict(&host, opts.allow_legacy_mcp) {
anyhow::bail!("refusing to start: {msg} (exit {EXIT_REFUSED})");
}
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let _loader = crate::extensions::loader::spawn_discover_and_load(Arc::clone(host.ext_manager()), tx, None);
let wait = async {
while let Some(ev) = rx.recv().await {
if let crate::extensions::loader::ExtensionLoaderEvent::Finished { loaded, failed } = ev {
tracing::info!(loaded = loaded.len(), failed = failed.len(), "daemon: extensions discovered");
break;
}
}
};
if tokio::time::timeout(Duration::from_secs(10), wait).await.is_err() {
tracing::warn!("daemon: extension discovery still running after 10 s; accepting anyway");
}
let _router = crate::extensions::notify_router::spawn_notification_router(Arc::clone(&host));
let mut opts = opts;
if opts.factory.is_none() {
opts.factory = Some(host_factory(&host));
}
let daemon = Daemon::start(host, opts).await?;
let state = Arc::clone(&daemon.state);
tokio::spawn(async move {
#[cfg(unix)]
{
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok();
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = async { match term.as_mut() { Some(t) => { t.recv().await; } None => std::future::pending::<()>().await } } => {}
_ = state.shutdown.cancelled() => return,
}
}
#[cfg(not(unix))]
{
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = state.shutdown.cancelled() => return,
}
}
tracing::info!("daemon: signal received, shutting down");
state.request_shutdown(false);
});
daemon.wait().await;
Ok(())
}
#[cfg(test)]
mod flag_tests {
use super::*;
#[test]
fn autospawn_idle_exit_defaults_to_10s_and_honours_never() {
let d = AUTOSPAWN_IDLE_EXIT.as_secs();
assert_eq!(autospawn_idle_exit_from(None, d), Some(AUTOSPAWN_IDLE_EXIT));
assert_eq!(AUTOSPAWN_IDLE_EXIT, Duration::from_secs(10));
assert_eq!(autospawn_idle_exit_from(Some("15"), d), Some(Duration::from_secs(15)));
assert_eq!(autospawn_idle_exit_from(Some(" 300 "), d), Some(Duration::from_secs(300)));
for never in ["0", "never", "off"] {
assert_eq!(autospawn_idle_exit_from(Some(never), d), None, "{never:?}");
}
assert_eq!(autospawn_idle_exit_from(Some("soon"), d), Some(AUTOSPAWN_IDLE_EXIT));
assert_eq!(autospawn_idle_exit_from(None, 42), Some(Duration::from_secs(42)));
assert_eq!(autospawn_idle_exit_from(None, 0), None);
assert_eq!(autospawn_idle_exit_from(Some("5"), 42), Some(Duration::from_secs(5)));
}
#[test]
fn enabled_env_matrix() {
assert!(enabled_from(None), "unset → on");
for on in ["1", "true", "yes", "on", "", "anything"] {
assert!(enabled_from(Some(on)), "{on:?} → on");
}
for off in ["0", "false", "off", "no", "FALSE", " 0 ", "Off"] {
assert!(!enabled_from(Some(off)), "{off:?} → off");
}
}
#[test]
fn env_is_off_is_case_and_space_insensitive() {
assert!(env_is_off(Some("NO")));
assert!(!env_is_off(Some("1")));
assert!(!env_is_off(None));
}
}