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 d2f24f0c3..568d15e26 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,12 @@ urlencoding = "2.1.3" [features] 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 d56f2ae51..db9c8f916 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; @@ -159,28 +163,71 @@ 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 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 result = unsafe { libc::statvfs(path_cstring.as_ptr(), stats.as_mut_ptr()) }; - if result != 0 { - return Err(anyhow::anyhow!("statvfs call failed")); + return Ok(StorageStatus { + used_bytes, + total_bytes, + available_bytes, + }); } - 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; + #[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 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 used_bytes = directory_size_bytes(path)?; + return Ok(StorageStatus { + used_bytes, + total_bytes, + available_bytes, + }); + } - 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, + available_bytes: 0, + }) + } } fn directory_size_bytes(root: &Path) -> anyhow::Result { diff --git a/src/daemon.rs b/src/daemon.rs index 2f953a395..7da74437d 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -2,12 +2,17 @@ use crate::config::{Config, TelemetryConfig}; -use anyhow::{Context as _, anyhow}; +#[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}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt}; +#[cfg(unix)] use tokio::net::{UnixListener, UnixStream}; use tokio::sync::watch; use tracing_subscriber::fmt::format; @@ -15,7 +20,32 @@ use tracing_subscriber::layer::SubscriberExt as _; 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)] +// 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)] +pub struct DaemonStartOptions { + pub config_path: Option, + pub debug: bool, +} /// Commands sent from CLI client to the running daemon. #[derive(Debug, Serialize, Deserialize)] @@ -53,6 +83,17 @@ impl DaemonPaths { pub fn from_default() -> Self { Self::new(&Config::default_instance_dir()) } + +#[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]) + } } fn truncate_for_log(message: &str, max_chars: usize) -> (&str, bool) { @@ -64,6 +105,7 @@ fn truncate_for_log(message: &str, max_chars: usize) -> (&str, bool) { /// 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)?; @@ -89,9 +131,39 @@ pub fn is_running(paths: &DaemonPaths) -> Option { Some(pid) } +#[cfg(not(unix))] +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; + } + + #[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) +} + /// Daemonize the current process. Returns in the child; the parent prints /// a message and exits. -pub fn daemonize(paths: &DaemonPaths) -> anyhow::Result<()> { +#[cfg(unix)] +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: {}", @@ -124,6 +196,57 @@ pub fn daemonize(paths: &DaemonPaths) -> anyhow::Result<()> { Ok(()) } +#[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); + } + + 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::from(stdout)) + .stderr(std::process::Stdio::from(stderr)) + .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. /// /// Returns an `SdkTracerProvider` if OTLP export is configured. The caller must @@ -316,6 +439,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<()>)> { @@ -347,9 +471,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"); } }); @@ -365,26 +487,153 @@ 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; - let _ = std::fs::remove_file(&cleanup_socket); + 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" + ); + } + } }); Ok((shutdown_rx, handle)) } -/// Handle a single IPC client connection. -async fn handle_ipc_connection( - stream: UnixStream, +/// 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?")?; + 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)] +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 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) => { + return Err(error).with_context(|| { + format!("failed to connect to spacebot daemon pipe {pipe_name}") + }); + } + } + }; + + 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)))] +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(); - let mut reader = tokio::io::BufReader::new(reader); - let mut line = String::new(); - reader.read_line(&mut line).await?; +) -> anyhow::Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let (reader, mut writer) = tokio::io::split(stream); + let line = read_bounded_ipc_line(reader, "IPC command").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 => { @@ -406,27 +655,40 @@ async fn handle_ipc_connection( Ok(()) } -/// Send a command to the running daemon and return the response. -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?; writer.flush().await?; - let mut reader = tokio::io::BufReader::new(reader); + 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(); - reader.read_line(&mut line).await?; + 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 response: IpcResponse = serde_json::from_str(line.trim()) - .with_context(|| format!("invalid IPC response: {line}"))?; + 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(response) + Ok(line) } /// Clean up PID and socket files on shutdown. @@ -448,14 +710,76 @@ 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(|| { + 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(windows)] +fn is_process_alive(pid: u32) -> bool { + unsafe { + let handle = OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE_ACCESS, + 0, + pid, + ); + if (handle as isize) == 0 { + return false; + } + + let wait_result = WaitForSingleObject(handle, 0); + if CloseHandle(handle) == 0 { + tracing::warn!(pid, "CloseHandle failed during process liveness check"); + } + + 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 +} + fn cleanup_stale_files(paths: &DaemonPaths) { - let _ = std::fs::remove_file(&paths.pid_file); - let _ = std::fs::remove_file(&paths.socket); + 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)] + { + 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" + ); + } + } } /// Wait for the daemon process to exit after sending a shutdown command. diff --git a/src/main.rs b/src/main.rs index f0c6f733a..687468943 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,7 +184,7 @@ fn cmd_start( // Validate config loads successfully before forking let config = load_config(&resolved_config_path)?; - 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 @@ -181,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,