//! Daemon entrypoints (`bsk daemon start|stop|restart`). //! //! The "start" path comes in two flavours: //! * `--foreground` — run the daemon loop in the current process and //! inherit stdio. Used by tests and `--foreground` users. //! * default — fork-and-detach a child copy of the same binary, wait //! for it to write a valid `daemon.json`, then return success. //! //! Detachment uses a hidden `BSK_DAEMONIZED=1` env handoff: when the //! parent spawns the child it sets the env var; the child sees it on //! startup, redirects stdio to `/dev/null`, calls `setsid` (Unix), and //! falls through to `run_foreground`. use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::Path; use std::sync::Arc; use std::sync::Mutex; use std::time::{Duration, Instant}; use anyhow::{Context, Result}; use bsk_protocol::{Method, StatusParams, StatusResult}; use tracing::{debug, info, warn}; use crate::cli::daemon::StartArgs; use crate::daemon::{ browsers::{BROWSER_LIVENESS_TICK, BROWSER_LIVENESS_TIMEOUT, EXTENSION_CONNECT_WAIT}, info as daemon_info, ipc, lockfile, paths, sessions::{StopSessionError, forget_session, stop_session}, state::DaemonState, ws, }; /// Internal env-var contract: the parent sets this on the spawned child /// to indicate "you are the daemon, detach yourself and run". pub(crate) const DAEMONIZED_ENV: &str = "BSK_DAEMONIZED"; /// Internal env-var contract for daemon self-restart after an /// auto-update: the outgoing daemon spawns its replacement with this set /// to its own pid; the child waits for that pid to exit — releasing the /// daemon lock, IPC socket, and WS port — before taking over. pub(crate) const DAEMON_REPLACEMENT_WAIT_ENV: &str = "BSK_DAEMON_REPLACES_PID"; /// Concrete daemon configuration resolved from CLI flags / defaults. #[derive(Debug, Clone)] pub struct DaemonConfig { pub ws_port: u16, pub session_idle: Duration, pub daemon_idle: Duration, /// Skip the Origin allow-list (tests / `--insecure-origin`). pub allow_any_origin: bool, /// How long `session.start` polls for an extension handshake before /// returning `no_browser_connected`. pub extension_connect_wait: Duration, /// A heartbeat-capable browser silent for at least this long is /// reaped by the liveness task. Defaults to [`BROWSER_LIVENESS_TIMEOUT`]. pub browser_liveness_timeout: Duration, /// How often the liveness task scans the registry. Defaults to /// [`BROWSER_LIVENESS_TICK`]. pub browser_liveness_tick: Duration, } impl DaemonConfig { /// Test/embed helper: a minimal config locked to `port` with /// generous idle timeouts. Used by integration tests that spin up /// a daemon via [`super::run`]. pub fn new(port: u16) -> Self { Self { ws_port: port, session_idle: Duration::from_secs(60 * 5), daemon_idle: Duration::from_secs(60 * 30), allow_any_origin: false, extension_connect_wait: EXTENSION_CONNECT_WAIT, browser_liveness_timeout: BROWSER_LIVENESS_TIMEOUT, browser_liveness_tick: BROWSER_LIVENESS_TICK, } } pub fn with_extension_connect_wait(mut self, wait: Duration) -> Self { self.extension_connect_wait = wait; self } /// Override the liveness reaper's silence threshold and scan cadence. /// Primarily for tests that need the reaper to act within /// sub-second windows instead of the production 60s/15s defaults. pub fn with_browser_liveness(mut self, timeout: Duration, tick: Duration) -> Self { self.browser_liveness_timeout = timeout; self.browser_liveness_tick = tick; self } } impl From<&StartArgs> for DaemonConfig { fn from(args: &StartArgs) -> Self { Self { ws_port: args.resolved_port(), session_idle: args.resolved_session_idle(), daemon_idle: args.resolved_daemon_idle(), allow_any_origin: false, extension_connect_wait: EXTENSION_CONNECT_WAIT, browser_liveness_timeout: BROWSER_LIVENESS_TIMEOUT, browser_liveness_tick: BROWSER_LIVENESS_TICK, } } } /// `bsk daemon start` entrypoint. pub fn run_start(args: StartArgs) -> Result<()> { let cfg = DaemonConfig::from(&args); if args.foreground { return run_foreground(cfg); } // Detached child mode (set by parent before spawn). if is_daemonized_child() { wait_for_replaced_daemon(); detach_stdio()?; return run_foreground(cfg); } if let Some(status) = verified_existing_daemon()? { validate_existing_start(&args, &status)?; info!( pid = status.pid, ws_port = status.ws_port, "daemon already running" ); return Ok(()); } // Parent: spawn ourselves detached and wait for ready. spawn_detached(&args)?; wait_for_ready(Duration::from_secs(3), args.port.filter(|port| *port != 0))?; Ok(()) } /// `bsk daemon stop` entrypoint. pub fn run_stop() -> Result<()> { let info = match daemon_info::read()? { Some(info) => info, None => { info!("no daemon.json present — nothing to stop"); return Ok(()); } }; if !lockfile::pid_alive(info.pid) { info!( pid = info.pid, "daemon.json points to a dead pid — cleaning up" ); let _ = daemon_info::remove(); return Ok(()); } match confirm_daemon(&info, Duration::from_secs(2)) { Ok(status) if status.pid == info.pid => {} Ok(status) => { warn!( expected_pid = info.pid, actual_pid = status.pid, "daemon.json does not match IPC daemon; refusing to stop" ); return Err(anyhow::anyhow!( "daemon.json pid {} does not match IPC daemon pid {}; refusing to stop", info.pid, status.pid )); } Err(err) => { warn!( pid = info.pid, ?err, "daemon.json pid is alive but IPC validation failed; refusing to stop" ); return Err(anyhow::anyhow!( "could not verify daemon identity for pid {}; refusing to stop: {err:#}", info.pid )); } } send_term(info.pid)?; let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { if !lockfile::pid_alive(info.pid) { let _ = daemon_info::remove(); info!(pid = info.pid, "daemon stopped"); return Ok(()); } std::thread::sleep(Duration::from_millis(50)); } warn!( pid = info.pid, "daemon did not exit within 5s; sending KILL" ); let _ = send_kill(info.pid); let _ = daemon_info::remove(); Ok(()) } /// Run the daemon in the foreground of the current process: acquire /// the lock, bind IPC, publish `daemon.json`, and serve until shutdown. pub fn run_foreground(cfg: DaemonConfig) -> Result<()> { paths::ensure_bsk_home()?; let _log_guard = init_tracing(); let lock = lockfile::acquire().context("acquire daemon lock")?; info!(?lock, "daemon lock acquired"); let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() .context("build tokio runtime")?; runtime.block_on(async move { #[cfg(unix)] let sock_path = paths::sock_path().context("resolve socket path")?; #[cfg(windows)] let sock_path = std::path::PathBuf::from(paths::pipe_name()); let ipc_listener = ipc::bind(&sock_path) .await .with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?; let state = Arc::new(DaemonState::new(cfg.clone())); let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state)); let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state)); // Fired by the update check task after a successful auto-update: // the replacement daemon has already been spawned, so this // process should shut down and let it take over. let restart_notify = Arc::new(tokio::sync::Notify::new()); let update_check_task = spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify)); let ws_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.ws_port); let ws_handle = ws::WsServer::new(Arc::clone(&state)) .bind(ws_addr) .await .with_context(|| format!("bind WS server on {ws_addr}"))?; let ws_port = ws_handle.local_addr.port(); let info = daemon_info::DaemonInfo::now( std::process::id(), sock_path.clone(), ws_port, env!("CARGO_PKG_VERSION"), ); daemon_info::write(&info).context("write daemon.json")?; info!( pid = info.pid, ws_port = info.ws_port, sock = %sock_path.display(), "daemon ready" ); // Best-effort: keep installed agent skills in step with this // daemon's bundled SKILL.md. Spawned so a slow/failing fs // never blocks the daemon ready signal. tokio::spawn(async move { let result = tokio::task::spawn_blocking(|| { let home = match crate::skill_install::harness::home_dir() { Ok(home) => home, Err(err) => { warn!(error = %err, "skill sync skipped: cannot resolve $HOME"); return None; } }; Some(crate::skill_install::sync::sync_installed_skills(&home)) }) .await; match result { Ok(Some(report)) => { for harness in &report.updated { info!(harness = harness.cli_name(), "skill synced"); } for (harness, msg) in &report.errors { warn!(harness = harness.cli_name(), error = %msg, "skill sync failed"); } if !report.up_to_date.is_empty() { debug!(count = report.up_to_date.len(), "skill already up to date"); } } Ok(None) => { /* home_dir() already warned inside the closure */ } Err(join_err) => { warn!(error = %join_err, "skill sync task panicked"); } } }); let started_at = Instant::now(); let activity = Arc::new(Mutex::new(IpcActivityState::new(started_at))); let (ipc_shutdown_tx, ipc_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); let status = ipc::DaemonStatus { started_at, ws_port, sock_path: sock_path.clone(), daemon_version: env!("CARGO_PKG_VERSION"), protocol_version: "1.0", }; let handler = ipc::full_handler(status, Arc::clone(&state)); let ipc_activity = { let activity = activity.clone(); move || { record_activity(&activity); } }; let ipc_task = { let handler = handler.clone(); let ipc_open = { let activity = activity.clone(); move || { record_ipc_open(&activity); } }; let ipc_close = { let activity = activity.clone(); move || { record_ipc_close(&activity); } }; tokio::spawn(ipc::serve( ipc_listener, handler, ipc_open, ipc_activity, ipc_close, async move { let _ = ipc_shutdown_rx.await; }, )) }; let (_idle_tx, idle_rx) = tokio::sync::oneshot::channel::<()>(); let idle_task = { let activity = activity.clone(); let state = Arc::clone(&state); let daemon_idle = cfg.daemon_idle; tokio::spawn(async move { let tick = (daemon_idle / 4).max(Duration::from_millis(250)); let mut ticker = tokio::time::interval(tick); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; let ipc_is_idle = lock_activity(&activity).should_exit_for_idle(Instant::now(), daemon_idle); if !ipc_is_idle { continue; } // IPC liveness (open-connection count + idle window) is already // enforced above by `should_exit_for_idle`, which reads // `IpcActivityState` under one lock and returns true only when // there are zero connections AND the idle interval has elapsed. // Here we additionally hold the daemon alive while any browser // is paired or any session is live (design §3.2, M4/M5 // registries). if !state.browsers.is_empty() || !state.sessions.is_empty() { continue; } info!( idle_secs = daemon_idle.as_secs(), "daemon exceeded idle threshold; exiting" ); return Some(()); } }) }; drop(idle_rx); // Created before `select!` so a `notify_one` from the update // check task is never missed (Notify stores one permit). let restart_notified = restart_notify.notified(); tokio::pin!(restart_notified); tokio::select! { _ = wait_for_shutdown() => { info!("bsk daemon shutting down (signal)"); } res = idle_task => { if matches!(res, Ok(Some(()))) { info!("bsk daemon shutting down (idle)"); } } _ = &mut restart_notified => { info!("bsk daemon shutting down (auto-update restart)"); } } let _ = ipc_shutdown_tx.send(()); let _ = ipc_task.await; session_idle_task.abort(); let _ = session_idle_task.await; browser_liveness_task.abort(); let _ = browser_liveness_task.await; update_check_task.abort(); let _ = update_check_task.await; ws_handle.shutdown.notify_waiters(); let _ = ws_handle.task.await; let _ = daemon_info::remove(); let _ = std::fs::remove_file(&sock_path); Result::<()>::Ok(()) })?; drop(lock); Ok(()) } /// Spawn the browser-liveness reaper shared by the production foreground /// daemon and the test/embed entry point. /// /// A normal disconnect closes the WebSocket, which the per-connection /// read loop observes and cleans up. But if the OS resumed from sleep /// and killed the MV3 service worker, the socket can be left half-open /// with no close frame ever delivered. Without the ~20s `system.heartbeat` /// arriving, such a connection would otherwise sit in the registry /// forever — pinning a phantom "connected" browser and preventing the /// daemon from ever idle-exiting. This reaper drops any browser that has /// gone silent past [`BROWSER_LIVENESS_TIMEOUT`] and purges its sessions, /// mirroring the WS disconnect cleanup. pub(crate) fn spawn_browser_liveness_reaper( state: Arc, ) -> tokio::task::JoinHandle<()> { let timeout = state.config.browser_liveness_timeout; let tick = state.config.browser_liveness_tick; tokio::spawn(async move { let mut ticker = tokio::time::interval(tick); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // The first tick is immediate; skip it so a freshly connected // browser always gets at least one full window before a scan. ticker.tick().await; loop { ticker.tick().await; for client in state.browsers.stale_browsers(timeout) { if state .browsers .remove_if_generation_matches(&client.id, client.generation) .is_some() { warn!( id = %client.id, idle_secs = client.idle_for().as_secs(), "reaping unresponsive browser (no heartbeat within liveness window)" ); for s in state.sessions.purge_browser(&client.id) { state.tool_queues.remove(&s.id); state.session_interrupts.drop_session(&s.id); debug!(session = %s.id, "purged session on browser liveness timeout"); } } } } }) } /// Spawn the cooperative session-idle reaper shared by the production /// foreground daemon and the test/embed daemon entry point. pub(crate) fn spawn_session_idle_reaper(state: Arc) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { let session_idle = state.config.session_idle; let tick = (session_idle / 4) .max(Duration::from_millis(100)) .min(Duration::from_secs(30)); let mut ticker = tokio::time::interval(tick); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); // `interval`'s first tick is immediate. Consume it so a zero/very // short test setting still gets one real inactivity window. ticker.tick().await; loop { ticker.tick().await; let idle_ids = state.sessions.idle_ids_at(session_idle, Instant::now()); for session_id in idle_ids { match stop_session( &state.browsers, &state.sessions, &state.tool_queues, &state.session_interrupts, &session_id, Duration::from_secs(10), ) .await { Ok(_) => info!(session = %session_id, "idle session stopped"), Err(StopSessionError::SessionBusy | StopSessionError::Stopping) => { debug!(session = %session_id, "idle session still active; retrying later"); } Err(StopSessionError::NotFound | StopSessionError::BrowserGone) => { forget_session( &state.sessions, &state.tool_queues, &state.session_interrupts, &session_id, ); } Err(err) => { warn!(session = %session_id, error = %err, "failed to stop idle session"); } } } } }) } /// Spawn the periodic update check owned by the production daemon. /// /// The daemon is the only writer of `~/.bsk/update-check.json`; CLI /// commands only read it to print the "new version available" hint /// (see [`crate::cli::update::print_update_hint_from_cache`]). The first /// tick of `tokio::time::interval` is immediate, so the check runs right /// after startup and then every /// [`crate::cli::update::UPDATE_CHECK_INTERVAL`]. Each tick re-reads the /// cache and skips the network fetch while it is still fresh within /// [`crate::cli::update::DAEMON_REFRESH_WINDOW`] (25min — shorter than /// the 30min tick, so steady state really refreshes on every tick /// instead of every other one). The task /// loops forever; shutdown aborts it like the other background tasks, so /// it never delays daemon exit (an in-flight fetch is bounded by the /// update client's own timeout and detached on abort). /// /// When a tick finds a newer version the daemon also *installs* it /// (auto-update, on by default; [`crate::cli::update::AUTO_UPDATE_ENV`] /// `=off` disables it and keeps the check cache/hint-only). Safety /// gate: while any agent session is live the tick postpones the /// install and retries next time. Once the new binary is in place the /// task spawns the replacement daemon (see /// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process /// shuts down and the new version takes over; on Windows the /// replacement can only be staged, so it just logs that a restart is /// needed. pub(crate) fn spawn_update_check_task( state: Arc, restart: Arc, ) -> tokio::task::JoinHandle<()> { use crate::cli::update; tokio::spawn(async move { let cache_path = match paths::update_check_path() { Ok(path) => path, Err(err) => { warn!(error = %err, "periodic update check disabled: cannot resolve cache path"); return; } }; // Capture our own executable path once, up front: after an // auto-update replaces the binary, `current_exe` on Linux starts // returning a " (deleted)"-suffixed path that can neither be // replaced again nor spawned. let exe_path = match std::env::current_exe() { Ok(exe) => Some(exe), Err(err) => { warn!(error = %err, "auto-update install disabled: cannot locate current executable"); None } }; let mut ticker = tokio::time::interval(update::UPDATE_CHECK_INTERVAL); ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { ticker.tick().await; let needs_refresh = match update::read_update_cache(&cache_path) { Ok(cache) => update::cache_needs_refresh( cache.as_ref(), update::now_epoch_secs(), update::DAEMON_REFRESH_WINDOW, ), Err(err) => { warn!(error = %err, "update cache unreadable; will refresh it"); true } }; if !needs_refresh { debug!("update cache still fresh; skipping update check"); continue; } let result = { let cache_path = cache_path.clone(); let state = Arc::clone(&state); let exe_path = exe_path.clone(); tokio::task::spawn_blocking(move || { let candidate = update::refresh_update_cache(&cache_path)?; // The session gate is read after the fetch, as late // as possible before the binary gets replaced. let active_sessions = state.sessions.len(); let auto_update = update::auto_update_enabled() && exe_path.is_some(); update::auto_update_step( candidate.as_ref(), auto_update, active_sessions, |candidate| { let target = exe_path.as_deref().context("current executable unknown")?; update::self_install_candidate(candidate, target) }, ) }) .await }; let outcome = match result { Ok(Ok(outcome)) => outcome, Ok(Err(err)) => { warn!(error = %err, "periodic update check failed"); continue; } Err(err) => { warn!(error = %err, "periodic update check task panicked"); continue; } }; match outcome { update::AutoUpdateOutcome::UpToDate => { info!("periodic update check refreshed cache (already up to date)") } update::AutoUpdateOutcome::Disabled { latest } => info!( %latest, "periodic update check found a new version; auto-update off, CLI hint only" ), update::AutoUpdateOutcome::PostponedSessions { latest, sessions } => info!( %latest, sessions, "auto-update postponed: agent session(s) active; will retry on the next tick" ), update::AutoUpdateOutcome::Staged { latest } => warn!( %latest, "auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade" ), update::AutoUpdateOutcome::Replaced { latest } => { info!( current = env!("CARGO_PKG_VERSION"), %latest, "auto-update installed the new bsk binary; restarting daemon" ); // `exe_path` is always Some here: the install only // runs when it was captured. if let Some(exe) = &exe_path { let args = restart_start_args(&state.config); match spawn_detached_at(exe, &args, Some(std::process::id())) { Ok(()) => { info!( pid = std::process::id(), "replacement daemon spawned; exiting so it can take over" ); restart.notify_one(); return; } Err(err) => warn!( error = %err, "auto-update replaced the binary but failed to spawn the replacement daemon; the next daemon start picks up the new version" ), } } } } } }) } /// Rebuild the `StartArgs` for the replacement daemon from the running /// config so the respawn keeps the same port and idle timeouts. fn restart_start_args(cfg: &DaemonConfig) -> StartArgs { StartArgs { port: Some(cfg.ws_port), foreground: false, session_idle: Some(cfg.session_idle), daemon_idle: Some(cfg.daemon_idle), } } #[derive(Debug)] struct IpcActivityState { last_activity: Instant, active_connections: usize, } impl IpcActivityState { fn new(now: Instant) -> Self { Self { last_activity: now, active_connections: 0, } } fn touch(&mut self, now: Instant) { self.last_activity = now; } fn opened(&mut self, now: Instant) { self.active_connections = self.active_connections.saturating_add(1); self.last_activity = now; } fn closed(&mut self, now: Instant) { self.active_connections = self.active_connections.saturating_sub(1); self.last_activity = now; } fn should_exit_for_idle(&self, now: Instant, idle: Duration) -> bool { self.active_connections == 0 && now.saturating_duration_since(self.last_activity) >= idle } } fn lock_activity( activity: &Arc>, ) -> std::sync::MutexGuard<'_, IpcActivityState> { activity.lock().unwrap_or_else(|poison| poison.into_inner()) } fn record_activity(activity: &Arc>) { lock_activity(activity).touch(Instant::now()); } fn record_ipc_open(activity: &Arc>) { lock_activity(activity).opened(Instant::now()); } fn record_ipc_close(activity: &Arc>) { lock_activity(activity).closed(Instant::now()); } /// Initialise tracing for the daemon: write a daily-rotated file in /// `~/.bsk/` and (best-effort) also mirror to stderr. Returns the /// non-blocking writer guard which must outlive the daemon loop. fn init_tracing() -> Option { use tracing_subscriber::EnvFilter; use tracing_subscriber::fmt; use tracing_subscriber::prelude::*; let log_dir = paths::log_dir().ok(); let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let (file_layer, guard) = if let Some(dir) = log_dir { let appender = tracing_appender::rolling::daily(&dir, "daemon.log"); let (writer, guard) = tracing_appender::non_blocking(appender); let layer = fmt::layer() .with_writer(writer) .with_ansi(false) .with_target(true) .json(); (Some(layer), Some(guard)) } else { (None, None) }; let stderr_layer = fmt::layer().with_writer(std::io::stderr).with_ansi(true); let _ = tracing_subscriber::registry() .with(env_filter) .with(file_layer) .with(stderr_layer) .try_init(); guard } async fn wait_for_shutdown() { let _ = shutdown_signal().await; } #[cfg(unix)] async fn shutdown_signal() -> Option<()> { use tokio::signal::unix::{SignalKind, signal}; let mut sigint = signal(SignalKind::interrupt()).ok()?; let mut sigterm = signal(SignalKind::terminate()).ok()?; tokio::select! { _ = sigint.recv() => {} _ = sigterm.recv() => {} } Some(()) } #[cfg(windows)] async fn shutdown_signal() -> Option<()> { let _ = tokio::signal::ctrl_c().await; Some(()) } #[cfg(not(any(unix, windows)))] async fn shutdown_signal() -> Option<()> { std::future::pending::<()>().await } fn is_daemonized_child() -> bool { std::env::var(DAEMONIZED_ENV).as_deref() == Ok("1") } /// Self-update handoff: when the outgoing daemon spawned us as its /// replacement ([`DAEMON_REPLACEMENT_WAIT_ENV`]), wait for its pid to /// exit so the daemon lock, IPC socket, and WS port are free before we /// try to take them over. Bounded on purpose — the lockfile is the real /// backstop if the predecessor somehow lingers. fn wait_for_replaced_daemon() { let pid = std::env::var(DAEMON_REPLACEMENT_WAIT_ENV) .ok() .and_then(|raw| raw.parse::().ok()); let Some(pid) = pid else { return; }; let deadline = Instant::now() + Duration::from_secs(30); while lockfile::pid_alive(pid) && Instant::now() < deadline { std::thread::sleep(Duration::from_millis(50)); } } #[cfg(unix)] fn detach_stdio() -> Result<()> { use std::fs::OpenOptions; use std::os::fd::AsRawFd; let dev_null_in = OpenOptions::new() .read(true) .open("/dev/null") .context("open /dev/null for stdin")?; let dev_null_out = OpenOptions::new() .write(true) .open("/dev/null") .context("open /dev/null for stdout")?; let dev_null_err = OpenOptions::new() .write(true) .open("/dev/null") .context("open /dev/null for stderr")?; unsafe { let _ = libc::dup2(dev_null_in.as_raw_fd(), libc::STDIN_FILENO); let _ = libc::dup2(dev_null_out.as_raw_fd(), libc::STDOUT_FILENO); let _ = libc::dup2(dev_null_err.as_raw_fd(), libc::STDERR_FILENO); } // setsid is best-effort; if we were already a session leader (e.g. // when launched via systemd) the call simply fails harmlessly. unsafe { libc::setsid(); } Ok(()) } #[cfg(windows)] fn detach_stdio() -> Result<()> { // On Windows the parent spawned us with DETACHED_PROCESS so stdio // is already detached. Nothing extra to do here. Ok(()) } #[cfg(not(any(unix, windows)))] fn detach_stdio() -> Result<()> { Ok(()) } #[cfg(unix)] fn spawn_detached(args: &StartArgs) -> Result<()> { let exe = std::env::current_exe().context("current_exe")?; spawn_detached_at(&exe, args, None) } /// Spawn a detached daemon child running the binary at `exe`. When /// `predecessor_pid` is set, the child first waits for that process to /// exit ([`DAEMON_REPLACEMENT_WAIT_ENV`]) — used by the auto-update /// self-restart, where the on-disk binary has already been replaced, so /// the child runs the new version. #[cfg(unix)] fn spawn_detached_at(exe: &Path, args: &StartArgs, predecessor_pid: Option) -> Result<()> { use std::os::unix::process::CommandExt; let mut cmd = std::process::Command::new(exe); apply_start_args(&mut cmd, args); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .env(DAEMONIZED_ENV, "1"); if let Some(pid) = predecessor_pid { cmd.env(DAEMON_REPLACEMENT_WAIT_ENV, pid.to_string()); } // Place the child into its own session before exec to fully detach. unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); } let child = cmd.spawn().context("spawn detached daemon child")?; drop(child); // parent immediately disowns; child runs independently Ok(()) } #[cfg(windows)] fn spawn_detached(args: &StartArgs) -> Result<()> { let exe = std::env::current_exe().context("current_exe")?; spawn_detached_at(&exe, args, None) } /// Windows counterpart of the unix [`spawn_detached_at`]; see its docs. #[cfg(windows)] fn spawn_detached_at(exe: &Path, args: &StartArgs, predecessor_pid: Option) -> Result<()> { use std::os::windows::process::CommandExt; const DETACHED_PROCESS: u32 = 0x0000_0008; const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; let mut cmd = std::process::Command::new(exe); apply_start_args(&mut cmd, args); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .env(DAEMONIZED_ENV, "1"); if let Some(pid) = predecessor_pid { cmd.env(DAEMON_REPLACEMENT_WAIT_ENV, pid.to_string()); } cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); let _child = cmd.spawn().context("spawn detached daemon child")?; Ok(()) } #[cfg(not(any(unix, windows)))] fn spawn_detached(_args: &StartArgs) -> Result<()> { Err(anyhow::anyhow!( "detached daemon spawn is not supported on this platform" )) } #[cfg(not(any(unix, windows)))] fn spawn_detached_at(_exe: &Path, _args: &StartArgs, _predecessor_pid: Option) -> Result<()> { Err(anyhow::anyhow!( "detached daemon spawn is not supported on this platform" )) } fn apply_start_args(cmd: &mut std::process::Command, args: &StartArgs) { cmd.arg("daemon").arg("start"); if let Some(p) = args.port { cmd.arg("--port").arg(p.to_string()); } if let Some(d) = args.session_idle { cmd.arg("--session-idle").arg(format_duration(d)); } if let Some(d) = args.daemon_idle { cmd.arg("--daemon-idle").arg(format_duration(d)); } } fn format_duration(d: Duration) -> String { let secs = d.as_secs(); let millis = d.subsec_millis(); if millis != 0 { format!("{}ms", d.as_millis()) } else { format!("{secs}s") } } fn verified_existing_daemon() -> Result> { let Some(info) = daemon_info::read_valid()? else { return Ok(None); }; match confirm_daemon(&info, Duration::from_millis(500)) { Ok(status) if status.pid == info.pid => Ok(Some(status)), Ok(_) | Err(_) => Ok(None), } } fn validate_existing_start(args: &StartArgs, status: &StatusResult) -> Result<()> { if let Some(port) = args.port && status.ws_port != port { return Err(anyhow::anyhow!( "daemon already running on ws port {}; use `bsk daemon restart --port {port}` to change it", status.ws_port )); } if args.session_idle.is_some() || args.daemon_idle.is_some() { return Err(anyhow::anyhow!( "daemon already running; use `bsk daemon restart` to apply idle timeout changes" )); } Ok(()) } fn wait_for_ready(timeout: Duration, expected_port: Option) -> Result<()> { let info_path = paths::info_path()?; let deadline = Instant::now() + timeout; let _ = info_path; loop { if let Some(info) = daemon_info::read_valid()? { match confirm_daemon(&info, Duration::from_millis(500)) { Ok(status) if status.pid == info.pid => { if let Some(port) = expected_port && status.ws_port != port { return Err(anyhow::anyhow!( "daemon started on ws port {}, expected {port}", status.ws_port )); } tracing::debug!(?info, "daemon ready"); return Ok(()); } Ok(status) => { tracing::debug!( expected_pid = info.pid, actual_pid = status.pid, "daemon.json did not match IPC status yet" ); } Err(err) => { tracing::debug!(?err, "daemon IPC not ready yet"); } } } if Instant::now() >= deadline { return Err(anyhow::anyhow!( "daemon failed to start within {:?} (no valid daemon.json)", timeout )); } std::thread::sleep(Duration::from_millis(50)); } } fn confirm_daemon(info: &daemon_info::DaemonInfo, timeout: Duration) -> Result { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .context("build tokio runtime for daemon verification")?; rt.block_on(async { let mut client = crate::ipc_client::Client::connect_path(info.sock_path.clone()).await?; let outcome = client .call::<_, StatusResult>(Method::SystemStatus, &StatusParams::default(), timeout) .await?; outcome.map_err(|err| { anyhow::anyhow!( "daemon verification RPC failed: {} ({:?})", err.message, err.code ) }) }) } #[cfg(unix)] fn send_term(pid: u32) -> Result<()> { use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; kill(Pid::from_raw(pid as i32), Signal::SIGTERM) .map_err(|e| anyhow::anyhow!("kill -TERM {pid}: {e}")) } #[cfg(windows)] fn send_term(pid: u32) -> Result<()> { send_kill(pid) } #[cfg(not(any(unix, windows)))] fn send_term(_pid: u32) -> Result<()> { Ok(()) } #[cfg(unix)] fn send_kill(pid: u32) -> Result<()> { use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; kill(Pid::from_raw(pid as i32), Signal::SIGKILL) .map_err(|e| anyhow::anyhow!("kill -KILL {pid}: {e}")) } #[cfg(windows)] fn send_kill(pid: u32) -> Result<()> { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; unsafe { let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); if handle.is_null() { return Err(anyhow::anyhow!("OpenProcess({pid}) failed")); } let _ = TerminateProcess(handle, 1); let _ = CloseHandle(handle); } Ok(()) } #[cfg(not(any(unix, windows)))] fn send_kill(_pid: u32) -> Result<()> { Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn format_duration_round_trips_seconds_and_millis() { assert_eq!(format_duration(Duration::from_secs(5)), "5s"); assert_eq!(format_duration(Duration::from_millis(750)), "750ms"); } #[test] fn restart_start_args_preserve_the_running_config() { let cfg = DaemonConfig { ws_port: 1234, session_idle: Duration::from_secs(11), daemon_idle: Duration::from_secs(22), ..DaemonConfig::new(0) }; let args = restart_start_args(&cfg); assert_eq!(args.port, Some(1234)); assert!(!args.foreground); assert_eq!(args.session_idle, Some(Duration::from_secs(11))); assert_eq!(args.daemon_idle, Some(Duration::from_secs(22))); } #[test] fn ipc_close_restarts_the_idle_window_atomically() { let started = Instant::now(); let idle = Duration::from_secs(10); let mut activity = IpcActivityState::new(started); activity.opened(started); let closed_at = started + Duration::from_secs(30); assert!( !activity.should_exit_for_idle(closed_at, idle), "an open IPC connection must hold the daemon alive" ); activity.closed(closed_at); assert_eq!(activity.active_connections, 0); assert_eq!(activity.last_activity, closed_at); assert!(!activity.should_exit_for_idle(closed_at, idle)); assert!(!activity.should_exit_for_idle(closed_at + idle - Duration::from_millis(1), idle)); assert!(activity.should_exit_for_idle(closed_at + idle, idle)); } #[test] fn ipc_activity_keeps_the_daemon_alive_until_a_full_idle_interval_passes() { let started = Instant::now(); let idle = Duration::from_secs(10); let mut activity = IpcActivityState::new(started); let request_at = started + Duration::from_secs(20); activity.touch(request_at); assert!(!activity.should_exit_for_idle(request_at + Duration::from_secs(9), idle)); assert!(activity.should_exit_for_idle(request_at + idle, idle)); } }