From 8af87b863d7f848591209e0ec788eb59d0c11f92 Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 24 Feb 2026 12:39:43 +0300 Subject: [PATCH 1/5] Fix Windows release build by gating Unix daemon code --- Cargo.toml | 6 ++++-- src/api/system.rs | 49 ++++++++++++++++++++++++++---------------- src/daemon.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 7 ++++++ 4 files changed, 95 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d2f24f0c3..2f206b6ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,8 +82,7 @@ rmcp = { version = "0.16", features = ["client", "reqwest", "transport-child-pro clap = { version = "4.5", features = ["derive"] } dialoguer = { version = "0.11", features = ["password"] } -# Daemonization -daemonize = "0.5" +# Daemonization / low-level OS calls libc = "0.2" ignore = "0.4" @@ -146,6 +145,9 @@ urlencoding = "2.1.3" [features] metrics = ["dep:prometheus"] +[target.'cfg(unix)'.dependencies] +daemonize = "0.5" + [lints.clippy] dbg_macro = "deny" todo = "deny" diff --git a/src/api/system.rs b/src/api/system.rs index da99f248c..aa1f677e8 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -157,28 +157,41 @@ pub(super) async fn storage_status( } fn read_filesystem_usage(path: &Path) -> anyhow::Result { - let mut stats = std::mem::MaybeUninit::::uninit(); - let path_cstring = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?; + #[cfg(unix)] + { + let mut stats = std::mem::MaybeUninit::::uninit(); + let path_cstring = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?; + + let result = unsafe { libc::statvfs(path_cstring.as_ptr(), stats.as_mut_ptr()) }; + if result != 0 { + return Err(anyhow::anyhow!("statvfs call failed")); + } - let result = unsafe { libc::statvfs(path_cstring.as_ptr(), stats.as_mut_ptr()) }; - if result != 0 { - return Err(anyhow::anyhow!("statvfs call failed")); - } + let stats = unsafe { stats.assume_init() }; + let block_size = stats.f_frsize as u128; + let total_blocks = stats.f_blocks as u128; + let avail_blocks = stats.f_bavail as u128; - let stats = unsafe { stats.assume_init() }; - let block_size = stats.f_frsize as u128; - let total_blocks = stats.f_blocks as u128; - let avail_blocks = stats.f_bavail as u128; + let total_bytes = (block_size * total_blocks) as u64; + let used_bytes = directory_size_bytes(path)?; + let available_bytes = (block_size * avail_blocks) as u64; - let total_bytes = (block_size * total_blocks) as u64; - let used_bytes = directory_size_bytes(path)?; - let available_bytes = (block_size * avail_blocks) as u64; + return Ok(StorageStatus { + used_bytes, + total_bytes, + available_bytes, + }); + } - Ok(StorageStatus { - used_bytes, - total_bytes, - available_bytes, - }) + #[cfg(not(unix))] + { + let used_bytes = directory_size_bytes(path)?; + Ok(StorageStatus { + used_bytes, + total_bytes: 0, + available_bytes: 0, + }) + } } fn directory_size_bytes(root: &Path) -> anyhow::Result { diff --git a/src/daemon.rs b/src/daemon.rs index 3bc7962ed..07a79ee8e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2,12 +2,16 @@ use crate::config::{Config, TelemetryConfig}; -use anyhow::{Context as _, anyhow}; +#[cfg(unix)] +use anyhow::Context as _; +use anyhow::anyhow; use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::WithHttpConfig; use opentelemetry_sdk::trace::SdkTracerProvider; use serde::{Deserialize, Serialize}; +#[cfg(unix)] use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; +#[cfg(unix)] use tokio::net::{UnixListener, UnixStream}; use tokio::sync::watch; use tracing_subscriber::fmt::format; @@ -15,6 +19,7 @@ use tracing_subscriber::layer::SubscriberExt as _; use tracing_subscriber::util::SubscriberInitExt as _; use std::path::PathBuf; +#[cfg(unix)] use std::time::Instant; /// Commands sent from CLI client to the running daemon. @@ -57,6 +62,7 @@ impl DaemonPaths { /// Check whether a daemon is already running by testing PID file liveness /// and socket connectivity. +#[cfg(unix)] pub fn is_running(paths: &DaemonPaths) -> Option { let pid = read_pid_file(&paths.pid_file)?; @@ -82,8 +88,14 @@ pub fn is_running(paths: &DaemonPaths) -> Option { Some(pid) } +#[cfg(not(unix))] +pub fn is_running(_paths: &DaemonPaths) -> Option { + None +} + /// Daemonize the current process. Returns in the child; the parent prints /// a message and exits. +#[cfg(unix)] pub fn daemonize(paths: &DaemonPaths) -> anyhow::Result<()> { std::fs::create_dir_all(&paths.log_dir).with_context(|| { format!( @@ -117,6 +129,13 @@ pub fn daemonize(paths: &DaemonPaths) -> anyhow::Result<()> { Ok(()) } +#[cfg(not(unix))] +pub fn daemonize(_paths: &DaemonPaths) -> anyhow::Result<()> { + Err(anyhow!( + "background daemon mode is only supported on Unix targets; rerun with --foreground" + )) +} + /// Initialize tracing for background (daemon) mode. /// /// Returns an `SdkTracerProvider` if OTLP export is configured. The caller must @@ -309,6 +328,7 @@ fn build_otlp_provider(telemetry: &TelemetryConfig) -> Option /// Start the IPC server. Returns a shutdown receiver that the main event /// loop should select on. +#[cfg(unix)] pub async fn start_ipc_server( paths: &DaemonPaths, ) -> anyhow::Result<(watch::Receiver, tokio::task::JoinHandle<()>)> { @@ -366,6 +386,7 @@ pub async fn start_ipc_server( } /// Handle a single IPC client connection. +#[cfg(unix)] async fn handle_ipc_connection( stream: UnixStream, shutdown_tx: &watch::Sender, @@ -400,6 +421,7 @@ async fn handle_ipc_connection( } /// Send a command to the running daemon and return the response. +#[cfg(unix)] pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::Result { let stream = UnixStream::connect(&paths.socket) .await @@ -422,6 +444,28 @@ pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::R Ok(response) } +#[cfg(not(unix))] +pub async fn start_ipc_server( + _paths: &DaemonPaths, +) -> anyhow::Result<(watch::Receiver, tokio::task::JoinHandle<()>)> { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let handle = tokio::spawn(async move { + let _shutdown_tx = shutdown_tx; + std::future::pending::<()>().await; + }); + Ok((shutdown_rx, handle)) +} + +#[cfg(not(unix))] +pub async fn send_command( + _paths: &DaemonPaths, + _command: IpcCommand, +) -> anyhow::Result { + Err(anyhow!( + "daemon IPC commands are only supported on Unix targets" + )) +} + /// Clean up PID and socket files on shutdown. pub fn cleanup(paths: &DaemonPaths) { if let Err(error) = std::fs::remove_file(&paths.pid_file) @@ -436,16 +480,24 @@ pub fn cleanup(paths: &DaemonPaths) { } } +#[cfg(unix)] fn read_pid_file(path: &std::path::Path) -> Option { let content = std::fs::read_to_string(path).ok()?; content.trim().parse::().ok() } +#[cfg(unix)] fn is_process_alive(pid: u32) -> bool { // kill(pid, 0) checks if the process exists without sending a signal unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } } +#[cfg(not(unix))] +fn is_process_alive(_pid: u32) -> bool { + false +} + +#[cfg(unix)] fn cleanup_stale_files(paths: &DaemonPaths) { let _ = std::fs::remove_file(&paths.pid_file); let _ = std::fs::remove_file(&paths.socket); diff --git a/src/main.rs b/src/main.rs index 7ff262e48..647a4ba26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -173,6 +173,13 @@ fn cmd_start( // Validate config loads successfully before forking let config = load_config(&resolved_config_path)?; + #[cfg(not(unix))] + if !foreground { + return Err(anyhow::anyhow!( + "background daemon mode is only supported on Unix targets; rerun with --foreground" + )); + } + if !foreground { // Fork the process before creating any Tokio runtime. After daemonize() // returns, we are in the child process — the parent has exited. Any From fa502535176cba535e78084e1609a86b963ecbd7 Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 24 Feb 2026 12:46:09 +0300 Subject: [PATCH 2/5] Add Windows daemon mode with named-pipe IPC --- Cargo.lock | 1 + Cargo.toml | 3 + README.md | 2 + src/api/system.rs | 34 ++++++ src/daemon.rs | 294 ++++++++++++++++++++++++++++++++++++---------- src/main.rs | 36 +++--- 6 files changed, 296 insertions(+), 74 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b5bf8604..787f9b792 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8310,6 +8310,7 @@ dependencies = [ "twitch-irc", "urlencoding", "uuid", + "windows-sys 0.59.0", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index 2f206b6ef..568d15e26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,9 @@ metrics = ["dep:prometheus"] [target.'cfg(unix)'.dependencies] daemonize = "0.5" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading"] } + [lints.clippy] dbg_macro = "deny" todo = "deny" diff --git a/README.md b/README.md index 0387d04ec..d18ad5295 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,8 @@ spacebot status # show pid and uptime spacebot auth login # authenticate via Anthropic OAuth ``` +On Unix, daemon control uses a Unix domain socket in the instance directory. On Windows, it uses a local named pipe with the same `start`/`stop`/`status` CLI flow. + The binary creates all databases and directories automatically on first run. See the [quickstart guide](docs/content/docs/(getting-started)/quickstart.mdx) for more detail. ### Authentication diff --git a/src/api/system.rs b/src/api/system.rs index aa1f677e8..26d545159 100644 --- a/src/api/system.rs +++ b/src/api/system.rs @@ -13,6 +13,10 @@ use std::io::Write as _; use std::path::Component; use std::path::Path; use std::sync::Arc; +#[cfg(windows)] +use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt as _; use zip::CompressionMethod; use zip::write::SimpleFileOptions; @@ -185,7 +189,37 @@ fn read_filesystem_usage(path: &Path) -> anyhow::Result { #[cfg(not(unix))] { + #[cfg(windows)] + { + let mut available_bytes = 0u64; + let mut total_bytes = 0u64; + let mut free_bytes = 0u64; + let mut path_wide: Vec = path.as_os_str().encode_wide().collect(); + path_wide.push(0); + + let result = unsafe { + GetDiskFreeSpaceExW( + path_wide.as_ptr(), + &mut available_bytes, + &mut total_bytes, + &mut free_bytes, + ) + }; + if result == 0 { + return Err(anyhow::anyhow!("GetDiskFreeSpaceExW call failed")); + } + + let used_bytes = directory_size_bytes(path)?; + return Ok(StorageStatus { + used_bytes, + total_bytes, + available_bytes, + }); + } + + #[cfg(not(windows))] let used_bytes = directory_size_bytes(path)?; + #[cfg(not(windows))] Ok(StorageStatus { used_bytes, total_bytes: 0, diff --git a/src/daemon.rs b/src/daemon.rs index 07a79ee8e..3efc8ba6d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2,15 +2,16 @@ use crate::config::{Config, TelemetryConfig}; -#[cfg(unix)] +#[cfg(any(unix, windows))] use anyhow::Context as _; use anyhow::anyhow; +#[cfg(windows)] +use hex::ToHex as _; use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::WithHttpConfig; use opentelemetry_sdk::trace::SdkTracerProvider; use serde::{Deserialize, Serialize}; -#[cfg(unix)] -use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt}; #[cfg(unix)] use tokio::net::{UnixListener, UnixStream}; use tokio::sync::watch; @@ -21,6 +22,26 @@ use tracing_subscriber::util::SubscriberInitExt as _; use std::path::PathBuf; #[cfg(unix)] use std::time::Instant; +#[cfg(windows)] +use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions}; +#[cfg(windows)] +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_PIPE_BUSY, WAIT_FAILED, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +#[cfg(windows)] +use windows_sys::Win32::System::Threading::{ + CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, DETACHED_PROCESS, OpenProcess, + PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject, +}; +#[cfg(windows)] +const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; + +/// Spawn options used when detaching into background mode. +#[derive(Debug, Clone, Default)] +pub struct DaemonStartOptions { + pub config_path: Option, + pub debug: bool, +} /// Commands sent from CLI client to the running daemon. #[derive(Debug, Serialize, Deserialize)] @@ -58,6 +79,17 @@ impl DaemonPaths { pub fn from_default() -> Self { Self::new(&Config::default_instance_dir()) } + + #[cfg(windows)] + fn pipe_name(&self) -> String { + let mut hasher = sha2::Sha256::new(); + use sha2::Digest as _; + + hasher.update(self.pid_file.as_os_str().to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let digest_hex = digest.encode_hex::(); + format!(r"\\.\pipe\spacebot-{}", &digest_hex[..32]) + } } /// Check whether a daemon is already running by testing PID file liveness @@ -89,14 +121,21 @@ pub fn is_running(paths: &DaemonPaths) -> Option { } #[cfg(not(unix))] -pub fn is_running(_paths: &DaemonPaths) -> Option { - None +pub fn is_running(paths: &DaemonPaths) -> Option { + let pid = read_pid_file(&paths.pid_file)?; + + if !is_process_alive(pid) { + cleanup_stale_files(paths); + return None; + } + + Some(pid) } /// Daemonize the current process. Returns in the child; the parent prints /// a message and exits. #[cfg(unix)] -pub fn daemonize(paths: &DaemonPaths) -> anyhow::Result<()> { +pub fn daemonize(paths: &DaemonPaths, _options: &DaemonStartOptions) -> anyhow::Result<()> { std::fs::create_dir_all(&paths.log_dir).with_context(|| { format!( "failed to create log directory: {}", @@ -129,11 +168,38 @@ pub fn daemonize(paths: &DaemonPaths) -> anyhow::Result<()> { Ok(()) } -#[cfg(not(unix))] -pub fn daemonize(_paths: &DaemonPaths) -> anyhow::Result<()> { - Err(anyhow!( - "background daemon mode is only supported on Unix targets; rerun with --foreground" - )) +#[cfg(windows)] +pub fn daemonize(_paths: &DaemonPaths, options: &DaemonStartOptions) -> anyhow::Result<()> { + use std::os::windows::process::CommandExt as _; + + let current_exe = std::env::current_exe().context("failed to resolve current executable")?; + let mut command = std::process::Command::new(current_exe); + command.arg("start").arg("--daemon-child"); + + if options.debug { + command.arg("--debug"); + } + if let Some(config_path) = &options.config_path { + command.arg("--config").arg(config_path); + } + + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW); + + let child = command + .spawn() + .context("failed to spawn detached background process")?; + + eprintln!("spacebot daemon started (pid {})", child.id()); + std::process::exit(0); +} + +#[cfg(all(not(unix), not(windows)))] +pub fn daemonize(_paths: &DaemonPaths, _options: &DaemonStartOptions) -> anyhow::Result<()> { + Err(anyhow!("background daemon mode is not supported on this target")) } /// Initialize tracing for background (daemon) mode. @@ -360,9 +426,7 @@ pub async fn start_ipc_server( let shutdown_tx = shutdown_tx.clone(); let uptime = start_time.elapsed(); tokio::spawn(async move { - if let Err(error) = - handle_ipc_connection(stream, &shutdown_tx, uptime).await - { + if let Err(error) = handle_ipc_stream(stream, &shutdown_tx, uptime).await { tracing::warn!(%error, "IPC connection handler failed"); } }); @@ -385,20 +449,124 @@ pub async fn start_ipc_server( Ok((shutdown_rx, handle)) } -/// Handle a single IPC client connection. +/// Send a command to the running daemon and return the response. #[cfg(unix)] -async fn handle_ipc_connection( - stream: UnixStream, +pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::Result { + let stream = UnixStream::connect(&paths.socket) + .await + .with_context(|| "failed to connect to spacebot daemon. is it running?")?; + send_command_over_stream(stream, command).await +} + +#[cfg(windows)] +pub async fn start_ipc_server( + paths: &DaemonPaths, +) -> anyhow::Result<(watch::Receiver, tokio::task::JoinHandle<()>)> { + if let Some(parent) = paths.pid_file.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create instance directory: {}", parent.display()) + })?; + } + + write_pid_file(&paths.pid_file)?; + + let pipe_name = paths.pipe_name(); + let mut first_server_options = ServerOptions::new(); + first_server_options.first_pipe_instance(true); + let mut server = first_server_options + .create(&pipe_name) + .with_context(|| format!("failed to create IPC named pipe: {pipe_name}"))?; + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let start_time = std::time::Instant::now(); + + let handle = tokio::spawn(async move { + loop { + if let Err(error) = server.connect().await { + tracing::warn!(%error, "failed to accept IPC named pipe connection"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + + let connected_server = server; + server = match ServerOptions::new().create(&pipe_name) { + Ok(next_server) => next_server, + Err(error) => { + tracing::error!(%error, "failed to create next IPC named pipe server"); + break; + } + }; + + let shutdown_tx = shutdown_tx.clone(); + let uptime = start_time.elapsed(); + tokio::spawn(async move { + if let Err(error) = handle_ipc_stream(connected_server, &shutdown_tx, uptime).await + { + tracing::warn!(%error, "IPC named pipe handler failed"); + } + }); + } + }); + + Ok((shutdown_rx, handle)) +} + +#[cfg(windows)] +pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::Result { + let pipe_name = paths.pipe_name(); + + let stream = loop { + match ClientOptions::new().open(&pipe_name) { + Ok(stream) => break stream, + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + Err(error) => { + return Err(error).with_context(|| { + format!("failed to connect to spacebot daemon pipe {pipe_name}") + }); + } + } + }; + + send_command_over_stream(stream, command).await +} + +#[cfg(all(not(unix), not(windows)))] +pub async fn start_ipc_server( + _paths: &DaemonPaths, +) -> anyhow::Result<(watch::Receiver, tokio::task::JoinHandle<()>)> { + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let handle = tokio::spawn(async move { + let _shutdown_tx = shutdown_tx; + std::future::pending::<()>().await; + }); + Ok((shutdown_rx, handle)) +} + +#[cfg(all(not(unix), not(windows)))] +pub async fn send_command( + _paths: &DaemonPaths, + _command: IpcCommand, +) -> anyhow::Result { + Err(anyhow!("daemon IPC is not supported on this target")) +} + +async fn handle_ipc_stream( + stream: S, shutdown_tx: &watch::Sender, uptime: std::time::Duration, -) -> anyhow::Result<()> { - let (reader, mut writer) = stream.into_split(); +) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let (reader, mut writer) = tokio::io::split(stream); let mut reader = tokio::io::BufReader::new(reader); let mut line = String::new(); reader.read_line(&mut line).await?; - let command: IpcCommand = serde_json::from_str(line.trim()) - .with_context(|| format!("invalid IPC command: {line}"))?; + let command: IpcCommand = + serde_json::from_str(line.trim()).map_err(|error| anyhow!("invalid IPC command: {error}"))?; let response = match command { IpcCommand::Shutdown => { @@ -420,15 +588,11 @@ async fn handle_ipc_connection( Ok(()) } -/// Send a command to the running daemon and return the response. -#[cfg(unix)] -pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::Result { - let stream = UnixStream::connect(&paths.socket) - .await - .with_context(|| "failed to connect to spacebot daemon. is it running?")?; - - let (reader, mut writer) = stream.into_split(); - +async fn send_command_over_stream(stream: S, command: IpcCommand) -> anyhow::Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let (reader, mut writer) = tokio::io::split(stream); let mut command_bytes = serde_json::to_vec(&command)?; command_bytes.push(b'\n'); writer.write_all(&command_bytes).await?; @@ -438,32 +602,7 @@ pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::R let mut line = String::new(); reader.read_line(&mut line).await?; - let response: IpcResponse = serde_json::from_str(line.trim()) - .with_context(|| format!("invalid IPC response: {line}"))?; - - Ok(response) -} - -#[cfg(not(unix))] -pub async fn start_ipc_server( - _paths: &DaemonPaths, -) -> anyhow::Result<(watch::Receiver, tokio::task::JoinHandle<()>)> { - let (shutdown_tx, shutdown_rx) = watch::channel(false); - let handle = tokio::spawn(async move { - let _shutdown_tx = shutdown_tx; - std::future::pending::<()>().await; - }); - Ok((shutdown_rx, handle)) -} - -#[cfg(not(unix))] -pub async fn send_command( - _paths: &DaemonPaths, - _command: IpcCommand, -) -> anyhow::Result { - Err(anyhow!( - "daemon IPC commands are only supported on Unix targets" - )) + serde_json::from_str(line.trim()).map_err(|error| anyhow!("invalid IPC response: {error}")) } /// Clean up PID and socket files on shutdown. @@ -480,27 +619,62 @@ pub fn cleanup(paths: &DaemonPaths) { } } -#[cfg(unix)] fn read_pid_file(path: &std::path::Path) -> Option { let content = std::fs::read_to_string(path).ok()?; content.trim().parse::().ok() } +fn write_pid_file(path: &std::path::Path) -> anyhow::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create PID directory: {}", parent.display()) + })?; + } + + std::fs::write(path, format!("{}\n", std::process::id())) + .with_context(|| format!("failed to write PID file: {}", path.display())) +} + #[cfg(unix)] fn is_process_alive(pid: u32) -> bool { // kill(pid, 0) checks if the process exists without sending a signal unsafe { libc::kill(pid as libc::pid_t, 0) == 0 } } -#[cfg(not(unix))] +#[cfg(windows)] +fn is_process_alive(pid: u32) -> bool { + unsafe { + let handle = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE_ACCESS, + 0, + pid, + ); + if handle.is_null() { + return false; + } + + let wait_result = WaitForSingleObject(handle, 0); + let _ = CloseHandle(handle); + + match wait_result { + WAIT_TIMEOUT => true, + WAIT_OBJECT_0 | WAIT_FAILED => false, + _ => false, + } + } +} + +#[cfg(not(any(unix, windows)))] fn is_process_alive(_pid: u32) -> bool { false } -#[cfg(unix)] fn cleanup_stale_files(paths: &DaemonPaths) { let _ = std::fs::remove_file(&paths.pid_file); - let _ = std::fs::remove_file(&paths.socket); + #[cfg(unix)] + { + let _ = std::fs::remove_file(&paths.socket); + } } /// Wait for the daemon process to exit after sending a shutdown command. diff --git a/src/main.rs b/src/main.rs index 647a4ba26..2794f76dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,6 +32,9 @@ enum Command { /// Run in the foreground instead of daemonizing #[arg(short, long)] foreground: bool, + /// Internal flag used by detached background child processes. + #[arg(long, hide = true)] + daemon_child: bool, }, /// Stop the running daemon Stop, @@ -132,14 +135,20 @@ fn main() -> anyhow::Result<()> { .map_err(|_| anyhow::anyhow!("failed to install rustls crypto provider"))?; let cli = Cli::parse(); - let command = cli.command.unwrap_or(Command::Start { foreground: false }); + let command = cli.command.unwrap_or(Command::Start { + foreground: false, + daemon_child: false, + }); match command { - Command::Start { foreground } => cmd_start(cli.config, cli.debug, foreground), + Command::Start { + foreground, + daemon_child, + } => cmd_start(cli.config, cli.debug, foreground, daemon_child), Command::Stop => cmd_stop(), Command::Restart { foreground } => { cmd_stop_if_running(); - cmd_start(cli.config, cli.debug, foreground) + cmd_start(cli.config, cli.debug, foreground, false) } Command::Status => cmd_status(), Command::Skill(skill_cmd) => cmd_skill(cli.config, skill_cmd), @@ -151,6 +160,7 @@ fn cmd_start( config_path: Option, debug: bool, foreground: bool, + daemon_child: bool, ) -> anyhow::Result<()> { let paths = spacebot::daemon::DaemonPaths::from_default(); @@ -160,8 +170,9 @@ fn cmd_start( std::process::exit(1); } - // Run onboarding interactively before daemonizing - let resolved_config_path = if config_path.is_some() { + // Run onboarding interactively before daemonizing. Background child + // processes skip this because the parent already handled it. + let resolved_config_path = if daemon_child || config_path.is_some() { config_path.clone() } else if spacebot::config::Config::needs_onboarding() { // Returns Some(path) if CLI wizard ran, None if user chose the UI. @@ -173,14 +184,7 @@ fn cmd_start( // Validate config loads successfully before forking let config = load_config(&resolved_config_path)?; - #[cfg(not(unix))] - if !foreground { - return Err(anyhow::anyhow!( - "background daemon mode is only supported on Unix targets; rerun with --foreground" - )); - } - - if !foreground { + if !foreground && !daemon_child { // Fork the process before creating any Tokio runtime. After daemonize() // returns, we are in the child process — the parent has exited. Any // runtime created before this point would be in a broken state inside @@ -188,7 +192,11 @@ fn cmd_start( // which is why tracing init (and the OTLP batch exporter it creates) // must happen *after* this call. let paths = spacebot::daemon::DaemonPaths::new(&config.instance_dir); - spacebot::daemon::daemonize(&paths)?; + let daemon_options = spacebot::daemon::DaemonStartOptions { + config_path: resolved_config_path.clone(), + debug, + }; + spacebot::daemon::daemonize(&paths, &daemon_options)?; } // Build a fresh Tokio runtime in this process (the child after daemonize, From e77c274445511dde76af99f39ebd7eed12f875fe Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 24 Feb 2026 13:15:30 +0300 Subject: [PATCH 3/5] Harden Windows daemon review follow-ups --- src/daemon.rs | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 6b5da17ff..2eecf3547 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -136,6 +136,23 @@ pub fn is_running(paths: &DaemonPaths) -> Option { return None; } + #[cfg(windows)] + { + let pipe_name = paths.pipe_name(); + match ClientOptions::new().open(&pipe_name) { + Ok(stream) => { + drop(stream); + Some(pid) + } + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => Some(pid), + Err(_) => { + cleanup_stale_files(paths); + None + } + } + } + + #[cfg(not(windows))] Some(pid) } @@ -521,11 +538,17 @@ pub async fn start_ipc_server( #[cfg(windows)] pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::Result { let pipe_name = paths.pipe_name(); + let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); let stream = loop { match ClientOptions::new().open(&pipe_name) { Ok(stream) => break stream, Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY as i32) => { + if std::time::Instant::now() >= connect_deadline { + return Err(anyhow!( + "timed out waiting for spacebot daemon IPC pipe; is it responsive?" + )); + } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } Err(error) => { @@ -631,6 +654,7 @@ fn read_pid_file(path: &std::path::Path) -> Option { content.trim().parse::().ok() } +#[cfg(windows)] fn write_pid_file(path: &std::path::Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).with_context(|| { @@ -677,10 +701,26 @@ fn is_process_alive(_pid: u32) -> bool { } fn cleanup_stale_files(paths: &DaemonPaths) { - let _ = std::fs::remove_file(&paths.pid_file); + if let Err(error) = std::fs::remove_file(&paths.pid_file) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + %error, + path = %paths.pid_file.display(), + "failed to remove stale PID file" + ); + } #[cfg(unix)] { - let _ = std::fs::remove_file(&paths.socket); + if let Err(error) = std::fs::remove_file(&paths.socket) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + %error, + path = %paths.socket.display(), + "failed to remove stale socket file" + ); + } } } From ad789311ad69b4e462b6c93581aac1636b1350b2 Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 24 Feb 2026 13:26:44 +0300 Subject: [PATCH 4/5] Address daemon IPC review feedback --- src/daemon.rs | 100 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 24 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index 2eecf3547..b8d2782bc 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -11,7 +11,7 @@ use opentelemetry::trace::TracerProvider as _; use opentelemetry_otlp::WithHttpConfig; use opentelemetry_sdk::trace::SdkTracerProvider; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt}; #[cfg(unix)] use tokio::net::{UnixListener, UnixStream}; use tokio::sync::watch; @@ -34,7 +34,11 @@ use windows_sys::Win32::System::Threading::{ PROCESS_QUERY_LIMITED_INFORMATION, WaitForSingleObject, }; #[cfg(windows)] +// Win32 SYNCHRONIZE access right (0x0010_0000). The windows-sys constant is +// not available from the imported modules in this crate/version combination. const SYNCHRONIZE_ACCESS: u32 = 0x0010_0000; +const MAX_IPC_LINE_BYTES: u64 = 64 * 1024; +const IPC_ROUND_TRIP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); /// Spawn options used when detaching into background mode. #[derive(Debug, Clone, Default)] @@ -80,15 +84,15 @@ impl DaemonPaths { Self::new(&Config::default_instance_dir()) } - #[cfg(windows)] - fn pipe_name(&self) -> String { - let mut hasher = sha2::Sha256::new(); - use sha2::Digest as _; - - hasher.update(self.pid_file.as_os_str().to_string_lossy().as_bytes()); - let digest = hasher.finalize(); - let digest_hex = digest.encode_hex::(); - format!(r"\\.\pipe\spacebot-{}", &digest_hex[..32]) +#[cfg(windows)] +fn pipe_name(&self) -> String { + use sha2::Digest as _; + let mut hasher = sha2::Sha256::new(); + + hasher.update(self.pid_file.as_os_str().to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let digest_hex = digest.encode_hex::(); + format!(r"\\.\pipe\spacebot-{}", &digest_hex[..32]) } } @@ -193,7 +197,7 @@ pub fn daemonize(paths: &DaemonPaths, _options: &DaemonStartOptions) -> anyhow:: } #[cfg(windows)] -pub fn daemonize(_paths: &DaemonPaths, options: &DaemonStartOptions) -> anyhow::Result<()> { +pub fn daemonize(paths: &DaemonPaths, options: &DaemonStartOptions) -> anyhow::Result<()> { use std::os::windows::process::CommandExt as _; let current_exe = std::env::current_exe().context("failed to resolve current executable")?; @@ -207,10 +211,27 @@ pub fn daemonize(_paths: &DaemonPaths, options: &DaemonStartOptions) -> anyhow:: command.arg("--config").arg(config_path); } + std::fs::create_dir_all(&paths.log_dir).with_context(|| { + format!( + "failed to create log directory: {}", + paths.log_dir.display() + ) + })?; + let stdout = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(paths.log_dir.join("spacebot.out")) + .context("failed to open stdout log")?; + let stderr = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(paths.log_dir.join("spacebot.err")) + .context("failed to open stderr log")?; + command .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) + .stdout(std::process::Stdio::from(stdout)) + .stderr(std::process::Stdio::from(stderr)) .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW); let child = command @@ -467,7 +488,15 @@ pub async fn start_ipc_server( let mut cleanup_rx = shutdown_rx.clone(); tokio::spawn(async move { let _ = cleanup_rx.wait_for(|shutdown| *shutdown).await; - let _ = std::fs::remove_file(&cleanup_socket); + if let Err(error) = std::fs::remove_file(&cleanup_socket) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + %error, + path = %cleanup_socket.display(), + "failed to remove cleanup socket file" + ); + } }); Ok((shutdown_rx, handle)) @@ -479,7 +508,9 @@ pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::R let stream = UnixStream::connect(&paths.socket) .await .with_context(|| "failed to connect to spacebot daemon. is it running?")?; - send_command_over_stream(stream, command).await + tokio::time::timeout(IPC_ROUND_TRIP_TIMEOUT, send_command_over_stream(stream, command)) + .await + .map_err(|_| anyhow!("timed out waiting for daemon IPC response"))? } #[cfg(windows)] @@ -559,7 +590,9 @@ pub async fn send_command(paths: &DaemonPaths, command: IpcCommand) -> anyhow::R } }; - send_command_over_stream(stream, command).await + tokio::time::timeout(IPC_ROUND_TRIP_TIMEOUT, send_command_over_stream(stream, command)) + .await + .map_err(|_| anyhow!("timed out waiting for daemon IPC response"))? } #[cfg(all(not(unix), not(windows)))] @@ -591,9 +624,7 @@ where S: AsyncRead + AsyncWrite + Unpin, { let (reader, mut writer) = tokio::io::split(stream); - let mut reader = tokio::io::BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await?; + let line = read_bounded_ipc_line(reader, "IPC command").await?; let command: IpcCommand = serde_json::from_str(line.trim()).map_err(|error| anyhow!("invalid IPC command: {error}"))?; @@ -628,13 +659,32 @@ where writer.write_all(&command_bytes).await?; writer.flush().await?; - let mut reader = tokio::io::BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await?; + let line = read_bounded_ipc_line(reader, "IPC response").await?; serde_json::from_str(line.trim()).map_err(|error| anyhow!("invalid IPC response: {error}")) } +async fn read_bounded_ipc_line(reader: R, label: &str) -> anyhow::Result +where + R: AsyncRead + Unpin, +{ + let limited_reader = reader.take(MAX_IPC_LINE_BYTES + 1); + let mut reader = tokio::io::BufReader::new(limited_reader); + let mut line = String::new(); + let bytes_read = reader.read_line(&mut line).await?; + + if bytes_read == 0 { + return Err(anyhow!("{label} stream closed before a line was received")); + } + + let exceeded_limit = bytes_read as u64 > MAX_IPC_LINE_BYTES; + if exceeded_limit || !line.ends_with('\n') { + return Err(anyhow!("{label} too large or missing newline terminator")); + } + + Ok(line) +} + /// Clean up PID and socket files on shutdown. pub fn cleanup(paths: &DaemonPaths) { if let Err(error) = std::fs::remove_file(&paths.pid_file) @@ -680,12 +730,14 @@ fn is_process_alive(pid: u32) -> bool { 0, pid, ); - if handle.is_null() { + if (handle as isize) == 0 { return false; } let wait_result = WaitForSingleObject(handle, 0); - let _ = CloseHandle(handle); + if CloseHandle(handle) == 0 { + tracing::warn!(pid, "CloseHandle failed during process liveness check"); + } match wait_result { WAIT_TIMEOUT => true, From 00ff4731327da6b9d346329664ff072bf3d6cb7a Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 24 Feb 2026 13:39:51 +0300 Subject: [PATCH 5/5] Handle IPC cleanup wait result explicitly --- src/daemon.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/daemon.rs b/src/daemon.rs index b8d2782bc..7da74437d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -487,15 +487,21 @@ pub async fn start_ipc_server( let cleanup_socket = socket_path.clone(); let mut cleanup_rx = shutdown_rx.clone(); tokio::spawn(async move { - let _ = cleanup_rx.wait_for(|shutdown| *shutdown).await; - if let Err(error) = std::fs::remove_file(&cleanup_socket) - && error.kind() != std::io::ErrorKind::NotFound - { - tracing::warn!( - %error, - path = %cleanup_socket.display(), - "failed to remove cleanup socket file" - ); + match cleanup_rx.wait_for(|shutdown| *shutdown).await { + Ok(_) => {} + Err(error) => { + tracing::warn!(%error, "cleanup wait_for failed"); + } + } + + if let Err(error) = std::fs::remove_file(&cleanup_socket) { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!( + %error, + path = %cleanup_socket.display(), + "failed to remove cleanup socket file" + ); + } } });