diff --git a/Cargo.lock b/Cargo.lock index c47bfea78..bb20082c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1858,6 +1858,7 @@ version = "0.176.2" dependencies = [ "cc", "libc", + "tempfile", ] [[package]] diff --git a/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md b/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md index 469c39b49..cd2d56bff 100644 --- a/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md +++ b/docs/plans/2026-04-02-001-feat-server-daemon-management-plan.md @@ -1,7 +1,7 @@ --- title: "feat: Add server daemon management with Unix socket support" type: feat -status: active +status: completed date: 2026-04-02 deepened: 2026-04-02 --- @@ -164,7 +164,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum ## Implementation Units -- [ ] **Unit 1: Add flock wrapper to fabro-proc** +- [x] **Unit 1: Add flock wrapper to fabro-proc** **Goal:** Provide `try_flock_exclusive` in `fabro-proc` as a thin libc wrapper for advisory file locking. @@ -196,7 +196,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum - `cargo nextest run -p fabro-proc` passes - `cargo clippy -p fabro-proc -- -D warnings` clean -- [ ] **Unit 2: Add ServerRecord and lifecycle helpers** +- [x] **Unit 2: Add ServerRecord and lifecycle helpers** **Goal:** Create a `ServerRecord` struct with read/write/remove/is_running helpers, mirroring `LauncherRecord`. @@ -228,7 +228,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum **Verification:** - `cargo nextest run -p fabro-cli` for the new module's tests pass -- [ ] **Unit 3: Add Bind enum, replace --host/--port with --bind, add Unix socket listener** +- [x] **Unit 3: Add Bind enum, replace --host/--port with --bind, add Unix socket listener** **Goal:** Define the `Bind` enum as the shared type for bind addresses. Change `ServeArgs` to use `--bind` instead of `--host`/`--port`. Support both Unix socket and TCP binding in `serve_command`. Wire graceful shutdown. @@ -273,7 +273,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum - `cargo nextest run -p fabro-server` passes - Existing server tests still pass (adapted for --bind) -- [ ] **Unit 4: Add daemon spawn, __serve hidden subcommand, and foreground wrapper** +- [x] **Unit 4: Add daemon spawn, __serve hidden subcommand, and foreground wrapper** **Goal:** Make `server start` launch a background daemon by default. `--foreground` retains current behavior. Both modes write/clean server records. Daemon mode uses flock to prevent thundering herd. `__serve` is the hidden subcommand the daemon child runs. @@ -314,7 +314,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum - `cargo nextest run -p fabro-cli` passes - Manual: `fabro server start` starts daemon, `fabro server start` again says "already running" -- [ ] **Unit 5: Add server stop subcommand** +- [x] **Unit 5: Add server stop subcommand** **Goal:** `fabro server stop` sends SIGTERM, waits for graceful exit, escalates to SIGKILL, cleans up. @@ -350,7 +350,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum - `cargo nextest run -p fabro-cli` passes - Manual: `fabro server start && fabro server stop` completes cleanly -- [ ] **Unit 6: Add server status subcommand** +- [x] **Unit 6: Add server status subcommand** **Goal:** `fabro server status` reports running/stopped state with metadata. Supports `--json`. @@ -382,7 +382,7 @@ Units 1 and 3 can run in parallel. Unit 2 depends on Unit 3 (for the `Bind` enum **Verification:** - `cargo nextest run -p fabro-cli` passes -- [ ] **Unit 7: Update main.rs dispatch and config loading for new server subcommands** +- [x] **Unit 7: Update main.rs dispatch and config loading for new server subcommands** **Goal:** Wire all server subcommands (start, stop, status, __serve) into CLI dispatch. Fix config log level extraction to handle new ServerCommand variants. diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 1601a1e47..4336cf698 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -857,7 +857,10 @@ impl Commands { }, #[cfg(feature = "server")] Self::Server(ns) => match &ns.command { - ServerCommand::Start(_) => "server start", + ServerCommand::Start { .. } => "server start", + ServerCommand::Stop { .. } => "server stop", + ServerCommand::Status { .. } => "server status", + ServerCommand::Serve { .. } => "server __serve", }, Self::Doctor { .. } => "doctor", Self::Repo(ns) => match &ns.command { @@ -976,11 +979,43 @@ pub(crate) struct ServerNamespace { pub(crate) command: ServerCommand, } +#[cfg(feature = "server")] +use fabro_server::serve::ServeArgs; + #[cfg(feature = "server")] #[derive(Subcommand)] pub(crate) enum ServerCommand { /// Start the HTTP API server - Start(fabro_server::serve::ServeArgs), + Start { + /// Run in the foreground instead of daemonizing + #[arg(long)] + foreground: bool, + + #[command(flatten)] + serve_args: ServeArgs, + }, + /// Stop the HTTP API server + Stop { + /// Seconds to wait for graceful shutdown before SIGKILL + #[arg(long, default_value = "10")] + timeout: u64, + }, + /// Show server status + Status { + /// Output as JSON + #[arg(long)] + json: bool, + }, + /// Internal: run the server process (spawned by `start`) + #[command(name = "__serve", hide = true)] + Serve { + /// Path to the server record file + #[arg(long)] + record_path: PathBuf, + + #[command(flatten)] + serve_args: ServeArgs, + }, } #[derive(Args)] diff --git a/lib/crates/fabro-cli/src/commands/mod.rs b/lib/crates/fabro-cli/src/commands/mod.rs index ab45ccf85..e7fb7a942 100644 --- a/lib/crates/fabro-cli/src/commands/mod.rs +++ b/lib/crates/fabro-cli/src/commands/mod.rs @@ -15,6 +15,8 @@ pub(crate) mod run; pub(crate) mod runs; pub(crate) mod sandbox; pub(crate) mod secret; +#[cfg(feature = "server")] +pub(crate) mod server; pub(crate) mod skill; pub(crate) mod store; pub(crate) mod system; diff --git a/lib/crates/fabro-cli/src/commands/server/foreground.rs b/lib/crates/fabro-cli/src/commands/server/foreground.rs new file mode 100644 index 000000000..fe30837f0 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/foreground.rs @@ -0,0 +1,37 @@ +use std::path::PathBuf; + +use anyhow::Result; +use fabro_server::bind::Bind; +use fabro_server::serve; +use fabro_server::serve::ServeArgs; +use fabro_util::terminal::Styles; + +use super::record; + +pub(crate) async fn execute( + record_path: PathBuf, + mut serve_args: ServeArgs, + bind: Bind, + storage_dir: Option, + styles: &'static Styles, +) -> Result<()> { + let _ = fabro_proc::title_init(); + fabro_proc::title_set(&format!("fabro: server {bind}")); + + serve_args.bind = Some(bind.to_string()); + + let _record_guard = scopeguard::guard(record_path, |path| { + record::remove_server_record(&path); + }); + + let _socket_guard = if let Bind::Unix(ref path) = bind { + let path = path.clone(); + Some(scopeguard::guard(path, |p| { + let _ = std::fs::remove_file(p); + })) + } else { + None + }; + + serve::serve_command(serve_args, styles, storage_dir).await +} diff --git a/lib/crates/fabro-cli/src/commands/server/mod.rs b/lib/crates/fabro-cli/src/commands/server/mod.rs new file mode 100644 index 000000000..e17c14815 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/mod.rs @@ -0,0 +1,67 @@ +pub(crate) mod foreground; +pub(crate) mod record; +pub(crate) mod start; +pub(crate) mod status; +pub(crate) mod stop; + +use std::time::Duration; + +use anyhow::Result; +use fabro_server::bind; +use fabro_server::bind::Bind; +use fabro_util::terminal::Styles; + +use crate::args::{GlobalArgs, ServerCommand}; +use crate::user_config; + +pub(crate) async fn dispatch(command: ServerCommand, globals: &GlobalArgs) -> Result<()> { + match command { + ServerCommand::Start { + foreground, + serve_args, + } => { + let settings = user_config::load_user_settings_with_globals(globals)?; + let storage_dir = settings.storage_dir(); + let bind_addr = match serve_args.bind.as_deref() { + Some(s) => bind::parse_bind(s)?, + None => Bind::Unix(storage_dir.join("fabro.sock")), + }; + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + start::execute(bind_addr, foreground, serve_args, storage_dir, styles).await + } + ServerCommand::Stop { timeout } => { + let settings = user_config::load_user_settings_with_globals(globals)?; + let storage_dir = settings.storage_dir(); + stop::execute(&storage_dir, Duration::from_secs(timeout)); + Ok(()) + } + ServerCommand::Status { json } => { + let settings = user_config::load_user_settings_with_globals(globals)?; + let storage_dir = settings.storage_dir(); + status::execute(&storage_dir, json) + } + ServerCommand::Serve { + record_path, + serve_args, + } => { + let bind_addr = match serve_args.bind.as_deref() { + Some(s) => bind::parse_bind(s)?, + None => { + // __serve should always receive an explicit --bind from the parent, + // but fall back to the storage dir default if missing. + let settings = user_config::load_user_settings_with_globals(globals)?; + Bind::Unix(settings.storage_dir().join("fabro.sock")) + } + }; + let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); + foreground::execute( + record_path, + serve_args, + bind_addr, + globals.storage_dir.clone(), + styles, + ) + .await + } + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/record.rs b/lib/crates/fabro-cli/src/commands/server/record.rs new file mode 100644 index 000000000..2e197dd2c --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/record.rs @@ -0,0 +1,120 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use fabro_server::bind::Bind; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct ServerRecord { + pub pid: u32, + pub bind: Bind, + pub log_path: PathBuf, + pub started_at: DateTime, +} + +pub(crate) fn server_record_path(storage_dir: &Path) -> PathBuf { + storage_dir.join("server.json") +} + +pub(crate) fn server_lock_path(storage_dir: &Path) -> PathBuf { + storage_dir.join("server.lock") +} + +pub(crate) fn server_log_path(storage_dir: &Path) -> PathBuf { + storage_dir.join("server.log") +} + +pub(crate) fn write_server_record(path: &Path, record: &ServerRecord) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, serde_json::to_string_pretty(record)?) + .with_context(|| format!("Failed to write server metadata to {}", path.display())) +} + +pub(crate) fn read_server_record(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +pub(crate) fn remove_server_record(path: &Path) { + let _ = std::fs::remove_file(path); +} + +pub(crate) fn server_record_is_running(record: &ServerRecord) -> bool { + fabro_proc::process_alive(record.pid) && server_process_matches(record) +} + +pub(crate) fn active_server_record(storage_dir: &Path) -> Option { + let path = server_record_path(storage_dir); + let record = read_server_record(&path)?; + if server_record_is_running(&record) { + Some(record) + } else { + remove_server_record(&path); + None + } +} + +#[cfg(unix)] +fn server_process_matches(record: &ServerRecord) -> bool { + let output = match std::process::Command::new("ps") + .args(["-ww", "-o", "command=", "-p", &record.pid.to_string()]) + .output() + { + Ok(output) if output.status.success() => output, + _ => return false, + }; + let command = String::from_utf8_lossy(&output.stdout); + command.contains("fabro") && command.contains("server") +} + +#[cfg(not(unix))] +fn server_process_matches(_record: &ServerRecord) -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_record(bind: Bind) -> ServerRecord { + ServerRecord { + pid: std::process::id(), + bind, + log_path: PathBuf::from("/tmp/server.log"), + started_at: Utc::now(), + } + } + + #[test] + fn write_and_read_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = server_record_path(dir.path()); + let record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap())); + write_server_record(&path, &record).unwrap(); + + let loaded = read_server_record(&path).unwrap(); + assert_eq!(loaded.pid, record.pid); + assert_eq!(loaded.bind, record.bind); + } + + #[test] + fn active_server_record_returns_none_when_no_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(active_server_record(dir.path()).is_none()); + } + + #[test] + fn active_server_record_cleans_stale_dead_pid() { + let dir = tempfile::tempdir().unwrap(); + let path = server_record_path(dir.path()); + let mut record = test_record(Bind::Tcp("127.0.0.1:3000".parse().unwrap())); + record.pid = u32::MAX; // definitely not alive + write_server_record(&path, &record).unwrap(); + + assert!(active_server_record(dir.path()).is_none()); + assert!(!path.exists()); // lazy cleanup removed file + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/start.rs b/lib/crates/fabro-cli/src/commands/server/start.rs new file mode 100644 index 000000000..70fbc05c1 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/start.rs @@ -0,0 +1,247 @@ +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::Duration; + +use anyhow::{Result, bail}; +use chrono::Utc; +use fabro_server::bind::Bind; +use fabro_server::serve; +use fabro_server::serve::ServeArgs; +use fabro_util::terminal::Styles; + +use super::record; + +pub(crate) async fn execute( + bind: Bind, + foreground: bool, + mut serve_args: ServeArgs, + storage_dir: PathBuf, + styles: &'static Styles, +) -> Result<()> { + serve_args.bind = Some(bind.to_string()); + + if foreground { + execute_foreground(bind, serve_args, storage_dir, styles).await + } else { + execute_daemon(&bind, &serve_args, &storage_dir) + } +} + +// --------------------------------------------------------------------------- +// Foreground mode +// --------------------------------------------------------------------------- + +async fn execute_foreground( + bind: Bind, + serve_args: ServeArgs, + storage_dir: PathBuf, + styles: &'static Styles, +) -> Result<()> { + let lock_file = acquire_lock(&storage_dir)?; + let _lock_file = lock_file; // keep alive for the duration + + if let Some(existing) = record::active_server_record(&storage_dir) { + bail!( + "Server already running (pid {}) on {}", + existing.pid, + existing.bind + ); + } + + let record_path = record::server_record_path(&storage_dir); + record::write_server_record( + &record_path, + &record::ServerRecord { + pid: std::process::id(), + bind: bind.clone(), + log_path: record::server_log_path(&storage_dir), + started_at: Utc::now(), + }, + )?; + + let _record_guard = scopeguard::guard(record_path, |path| { + record::remove_server_record(&path); + }); + + let _socket_guard = if let Bind::Unix(ref path) = bind { + let path = path.clone(); + Some(scopeguard::guard(path, |p| { + let _ = std::fs::remove_file(p); + })) + } else { + None + }; + + serve::serve_command(serve_args, styles, Some(storage_dir)).await +} + +// --------------------------------------------------------------------------- +// Daemon mode +// --------------------------------------------------------------------------- + +fn execute_daemon(bind: &Bind, serve_args: &ServeArgs, storage_dir: &Path) -> Result<()> { + let lock_file = acquire_lock(storage_dir)?; + let _lock_file = lock_file; // keep alive until function returns + + if let Some(existing) = record::active_server_record(storage_dir) { + bail!( + "Server already running (pid {}) on {}", + existing.pid, + existing.bind + ); + } + + // Rotate logs + let log_path = record::server_log_path(storage_dir); + let prev_path = log_path.with_extension("log.prev"); + let _ = std::fs::rename(&log_path, &prev_path); + + let record_path = record::server_record_path(storage_dir); + let log_file = std::fs::File::create(&log_path)?; + let stdout_log = log_file.try_clone()?; + let exe = std::env::current_exe()?; + + let mut cmd = std::process::Command::new(&exe); + cmd.args(["server", "__serve"]) + .arg("--record-path") + .arg(&record_path) + .arg("--bind") + .arg(bind.to_string()); + + if let Some(ref model) = serve_args.model { + cmd.args(["--model", model]); + } + if let Some(ref provider) = serve_args.provider { + cmd.args(["--provider", provider]); + } + if serve_args.dry_run { + cmd.arg("--dry-run"); + } + if let Some(ref sandbox) = serve_args.sandbox { + cmd.args(["--sandbox", &sandbox.to_string()]); + } + if let Some(max) = serve_args.max_concurrent_runs { + cmd.args(["--max-concurrent-runs", &max.to_string()]); + } + if let Some(ref config) = serve_args.config { + cmd.arg("--config").arg(config); + } + + cmd.arg("--storage-dir").arg(storage_dir); + + cmd.env_remove("FABRO_JSON"); + cmd.stdout(stdout_log) + .stderr(log_file) + .stdin(std::process::Stdio::null()); + + #[cfg(unix)] + fabro_proc::pre_exec_setsid(&mut cmd); + + let mut child = cmd.spawn()?; + + record::write_server_record( + &record_path, + &record::ServerRecord { + pid: child.id(), + bind: bind.clone(), + log_path: log_path.clone(), + started_at: Utc::now(), + }, + )?; + + if let Ok(Some(status)) = child.try_wait() { + record::remove_server_record(&record_path); + let tail = read_log_tail(&log_path, 20); + if !tail.is_empty() { + eprintln!("{tail}"); + } + bail!("Server exited immediately with status {status}"); + } + + let poll_interval = Duration::from_millis(50); + let timeout = Duration::from_secs(5); + let mut elapsed = Duration::ZERO; + + while elapsed < timeout { + if try_connect(bind) { + eprintln!("Server started (pid {}) on {bind}", child.id()); + return Ok(()); + } + + if let Ok(Some(status)) = child.try_wait() { + record::remove_server_record(&record_path); + if let Bind::Unix(ref path) = *bind { + let _ = std::fs::remove_file(path); + } + let tail = read_log_tail(&log_path, 20); + if !tail.is_empty() { + eprintln!("{tail}"); + } + bail!("Server exited during startup with status {status}"); + } + + thread::sleep(poll_interval); + elapsed += poll_interval; + } + + record::remove_server_record(&record_path); + if let Bind::Unix(ref path) = *bind { + let _ = std::fs::remove_file(path); + } + let _ = child.kill(); + let _ = child.wait(); + let tail = read_log_tail(&log_path, 20); + if !tail.is_empty() { + eprintln!("{tail}"); + } + bail!("Server did not become ready within {timeout:?}"); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn acquire_lock(storage_dir: &Path) -> Result { + let lock_path = record::server_lock_path(storage_dir); + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + + let poll_interval = Duration::from_millis(50); + let timeout = Duration::from_secs(5); + let mut elapsed = Duration::ZERO; + + while !fabro_proc::try_flock_exclusive(&lock_file)? { + if elapsed >= timeout { + bail!("timed out waiting for server lock"); + } + thread::sleep(poll_interval); + elapsed += poll_interval; + } + + Ok(lock_file) +} + +fn try_connect(bind: &Bind) -> bool { + match bind { + Bind::Tcp(addr) => { + std::net::TcpStream::connect_timeout(addr, Duration::from_millis(100)).is_ok() + } + Bind::Unix(path) => std::os::unix::net::UnixStream::connect(path).is_ok(), + } +} + +fn read_log_tail(log_path: &Path, lines: usize) -> String { + match std::fs::read_to_string(log_path) { + Ok(content) => { + let tail: Vec<&str> = content.lines().rev().take(lines).collect(); + tail.into_iter().rev().collect::>().join("\n") + } + Err(_) => String::new(), + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/status.rs b/lib/crates/fabro-cli/src/commands/server/status.rs new file mode 100644 index 000000000..2d84fbd90 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/status.rs @@ -0,0 +1,52 @@ +use std::path::Path; + +use anyhow::Result; +use chrono::Utc; + +use super::record; + +pub(crate) fn execute(storage_dir: &Path, json: bool) -> Result<()> { + let Some(record) = record::active_server_record(storage_dir) else { + if json { + println!(r#"{{"status":"stopped"}}"#); + } else { + eprintln!("Server is not running"); + } + std::process::exit(1); + }; + + if json { + let uptime_seconds = (Utc::now() - record.started_at).num_seconds().max(0); + let output = serde_json::json!({ + "status": "running", + "pid": record.pid, + "bind": record.bind.to_string(), + "started_at": record.started_at.to_rfc3339(), + "uptime_seconds": uptime_seconds, + }); + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + let uptime = format_uptime(Utc::now() - record.started_at); + eprintln!( + "Server running (pid {}) on {}, started {} ago", + record.pid, record.bind, uptime + ); + } + + Ok(()) +} + +fn format_uptime(duration: chrono::Duration) -> String { + let total_seconds = duration.num_seconds().max(0); + let hours = total_seconds / 3600; + let minutes = (total_seconds % 3600) / 60; + let seconds = total_seconds % 60; + + if hours > 0 { + format!("{hours}h {minutes}m {seconds}s") + } else if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} diff --git a/lib/crates/fabro-cli/src/commands/server/stop.rs b/lib/crates/fabro-cli/src/commands/server/stop.rs new file mode 100644 index 000000000..a26506b43 --- /dev/null +++ b/lib/crates/fabro-cli/src/commands/server/stop.rs @@ -0,0 +1,40 @@ +use std::path::Path; +use std::thread; +use std::time::Duration; + +use fabro_server::bind::Bind; + +use super::record; + +pub(crate) fn execute(storage_dir: &Path, timeout: Duration) { + let Some(record) = record::active_server_record(storage_dir) else { + eprintln!("Server is not running"); + std::process::exit(1); + }; + + fabro_proc::sigterm(record.pid); + + let poll_interval = Duration::from_millis(100); + let mut elapsed = Duration::ZERO; + while elapsed < timeout { + if !fabro_proc::process_alive(record.pid) { + break; + } + thread::sleep(poll_interval); + elapsed += poll_interval; + } + + if fabro_proc::process_alive(record.pid) { + fabro_proc::sigkill(record.pid); + thread::sleep(Duration::from_millis(100)); + } + + let record_path = record::server_record_path(storage_dir); + record::remove_server_record(&record_path); + + if let Bind::Unix(ref path) = record.bind { + let _ = std::fs::remove_file(path); + } + + eprintln!("Server stopped"); +} diff --git a/lib/crates/fabro-cli/src/main.rs b/lib/crates/fabro-cli/src/main.rs index bfd664422..d58b48f11 100644 --- a/lib/crates/fabro-cli/src/main.rs +++ b/lib/crates/fabro-cli/src/main.rs @@ -109,7 +109,13 @@ async fn main_inner() -> (String, Result<()>) { #[cfg(feature = "server")] { if let Commands::Server(ServerNamespace { - command: ServerCommand::Start(args), + command: + ServerCommand::Start { + serve_args: args, .. + } + | ServerCommand::Serve { + serve_args: args, .. + }, }) = command.as_ref() { match fabro_config::server::load_server_settings(args.config.as_deref()) { @@ -141,7 +147,7 @@ async fn main_inner() -> (String, Result<()>) { } }; - let log_prefix = if command_name == "server start" { + let log_prefix = if command_name == "server start" || command_name == "server __serve" { "server" } else { "cli" @@ -188,10 +194,7 @@ async fn main_inner() -> (String, Result<()>) { Commands::Model { command } => commands::model::execute(command, &globals).await?, #[cfg(feature = "server")] Commands::Server(ns) => { - let ServerCommand::Start(args) = ns.command; - let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); - fabro_server::serve::serve_command(args, styles, globals.storage_dir.clone()) - .await?; + commands::server::dispatch(ns.command, &globals).await?; } Commands::Doctor { verbose, dry_run } => { let cli_settings = user_config::load_user_settings()?; diff --git a/lib/crates/fabro-cli/tests/it/cmd/mod.rs b/lib/crates/fabro-cli/tests/it/cmd/mod.rs index 127308e22..5a517c6e6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/mod.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/mod.rs @@ -51,7 +51,9 @@ mod secret_rm; mod secret_set; mod send_analytics; mod send_panic; -mod server; +mod server_start; +mod server_status; +mod server_stop; mod start; mod store; mod store_dump; diff --git a/lib/crates/fabro-cli/tests/it/cmd/server.rs b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs similarity index 53% rename from lib/crates/fabro-cli/tests/it/cmd/server.rs rename to lib/crates/fabro-cli/tests/it/cmd/server_start.rs index aebf605b2..b28cc2c6d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/server.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/server_start.rs @@ -1,8 +1,8 @@ +use fabro_test::{fabro_snapshot, test_context}; + #[test] #[cfg(feature = "server")] fn help() { - use fabro_test::{fabro_snapshot, test_context}; - let context = test_context!(); let mut cmd = context.command(); cmd.args(["server", "start", "--help"]); @@ -15,32 +15,34 @@ fn help() { Usage: fabro server start [OPTIONS] Options: + --foreground + Run in the foreground instead of daemonizing + --json + Output as JSON [env: FABRO_JSON=] + --bind + Address to bind to (host:port for TCP, or path containing / for Unix socket) --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --port - Port to listen on [default: 3000] - --host - Host address to bind to [default: 127.0.0.1] - --no-upgrade-check - Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --model Override default LLM model - --quiet - Suppress non-essential output [env: FABRO_QUIET=] + --no-upgrade-check + Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] --provider Override default LLM provider - --verbose - Enable verbose output [env: FABRO_VERBOSE=] + --quiet + Suppress non-essential output [env: FABRO_QUIET=] --dry-run Execute with simulated LLM backend - --storage-dir - Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --verbose + Enable verbose output [env: FABRO_VERBOSE=] --sandbox Sandbox for agent tools - --server-url - Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=] + --storage-dir + Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] --max-concurrent-runs Maximum number of concurrent run executions + --server-url + Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=] --config Path to server config file (default: ~/.fabro/server.toml) -h, --help @@ -48,3 +50,38 @@ fn help() { ----- stderr ----- "); } + +#[test] +#[cfg(feature = "server")] +fn start_already_running_exits_with_error() { + let context = test_context!(); + + let sock_dir = tempfile::tempdir_in("/tmp").unwrap(); + let bind_addr = sock_dir.path().join("test.sock"); + let bind_str = bind_addr.to_string_lossy().to_string(); + + context + .command() + .args(["server", "start", "--dry-run", "--bind", &bind_str]) + .assert() + .success(); + + let mut filters = context.filters(); + filters.push((r"pid \d+".to_string(), "pid [PID]".to_string())); + filters.push((regex::escape(&bind_str), "[SOCKET_PATH]".to_string())); + let mut cmd = context.command(); + cmd.args(["server", "start", "--dry-run", "--bind", &bind_str]); + fabro_snapshot!(filters, cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + error: Server already running (pid [PID]) on [SOCKET_PATH] + "); + + context + .command() + .args(["server", "stop"]) + .assert() + .success(); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_status.rs b/lib/crates/fabro-cli/tests/it/cmd/server_status.rs new file mode 100644 index 000000000..4dd5c7182 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/server_status.rs @@ -0,0 +1,43 @@ +use fabro_test::{fabro_snapshot, test_context}; + +#[test] +#[cfg(feature = "server")] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["server", "status", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Show server status + + Usage: fabro server status [OPTIONS] + + Options: + --json Output as JSON + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --server-url Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +#[cfg(feature = "server")] +fn status_when_not_running() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["server", "status"]); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + Server is not running + "); +} diff --git a/lib/crates/fabro-cli/tests/it/cmd/server_stop.rs b/lib/crates/fabro-cli/tests/it/cmd/server_stop.rs new file mode 100644 index 000000000..70fef0005 --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/cmd/server_stop.rs @@ -0,0 +1,44 @@ +use fabro_test::{fabro_snapshot, test_context}; + +#[test] +#[cfg(feature = "server")] +fn help() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["server", "stop", "--help"]); + fabro_snapshot!(context.filters(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + Stop the HTTP API server + + Usage: fabro server stop [OPTIONS] + + Options: + --json Output as JSON [env: FABRO_JSON=] + --timeout Seconds to wait for graceful shutdown before SIGKILL [default: 10] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + --storage-dir Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] + --server-url Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=] + -h, --help Print help + ----- stderr ----- + "); +} + +#[test] +#[cfg(feature = "server")] +fn stop_when_not_running() { + let context = test_context!(); + let mut cmd = context.command(); + cmd.args(["server", "stop"]); + fabro_snapshot!(context.filters(), cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + Server is not running + "); +} diff --git a/lib/crates/fabro-cli/tests/it/scenario/mod.rs b/lib/crates/fabro-cli/tests/it/scenario/mod.rs index 2c5341b8b..a695f6a41 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/mod.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/mod.rs @@ -1,6 +1,7 @@ mod exec; mod lifecycle; mod recovery; +mod server_lifecycle; use std::path::{Path, PathBuf}; use std::sync::Arc; diff --git a/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs new file mode 100644 index 000000000..314a733fc --- /dev/null +++ b/lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs @@ -0,0 +1,79 @@ +use fabro_test::{fabro_snapshot, test_context}; + +#[test] +#[cfg(feature = "server")] +fn start_status_stop_lifecycle() { + let context = test_context!(); + + let sock_dir = tempfile::tempdir_in("/tmp").unwrap(); + let bind_addr = sock_dir.path().join("test.sock"); + let bind_str = bind_addr.to_string_lossy().to_string(); + + let mut filters = context.filters(); + filters.push((r"pid \d+".to_string(), "pid [PID]".to_string())); + filters.push((regex::escape(&bind_str), "[SOCKET_PATH]".to_string())); + filters.push(( + r"started \d+[hms] (?:\d+[hms] )*ago".to_string(), + "started [UPTIME] ago".to_string(), + )); + + let mut cmd = context.command(); + cmd.args(["server", "start", "--dry-run", "--bind", &bind_str]); + fabro_snapshot!(filters.clone(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Server started (pid [PID]) on [SOCKET_PATH] + "); + + let mut cmd = context.command(); + cmd.args(["server", "status"]); + fabro_snapshot!(filters.clone(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Server running (pid [PID]) on [SOCKET_PATH], started [UPTIME] ago + "); + + let status_output = context + .command() + .args(["server", "status", "--json"]) + .assert() + .success(); + let stdout = std::str::from_utf8(&status_output.get_output().stdout) + .expect("status --json stdout should be valid UTF-8"); + let json: serde_json::Value = + serde_json::from_str(stdout).expect("status --json should be valid JSON"); + assert_eq!( + json["status"].as_str(), + Some("running"), + "status should be running" + ); + + let mut cmd = context.command(); + cmd.args(["server", "stop"]); + fabro_snapshot!(filters.clone(), cmd, @" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- + Server stopped + "); + + let mut cmd = context.command(); + cmd.args(["server", "status"]); + fabro_snapshot!(filters, cmd, @" + success: false + exit_code: 1 + ----- stdout ----- + ----- stderr ----- + Server is not running + "); + + assert!( + !context.storage_dir.join("server.json").exists(), + "server.json should be removed after stop" + ); +} diff --git a/lib/crates/fabro-proc/Cargo.toml b/lib/crates/fabro-proc/Cargo.toml index 1d0adeff2..4ecdb8ab8 100644 --- a/lib/crates/fabro-proc/Cargo.toml +++ b/lib/crates/fabro-proc/Cargo.toml @@ -12,5 +12,8 @@ workspace = true [target.'cfg(unix)'.dependencies] libc = "0.2" +[dev-dependencies] +tempfile = "3" + [build-dependencies] cc = "1" diff --git a/lib/crates/fabro-proc/src/flock.rs b/lib/crates/fabro-proc/src/flock.rs new file mode 100644 index 000000000..09c8f0c60 --- /dev/null +++ b/lib/crates/fabro-proc/src/flock.rs @@ -0,0 +1,70 @@ +use std::fs::File; +use std::io; +use std::os::unix::io::AsRawFd; + +/// Try to acquire an exclusive (write) lock on `file` without blocking. +/// +/// Returns `Ok(true)` if the lock was acquired, `Ok(false)` if another +/// process/fd already holds the lock, and `Err` for unexpected errors. +pub fn try_flock_exclusive(file: &File) -> io::Result { + // SAFETY: flock() on a valid fd is safe; LOCK_NB makes it non-blocking. + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if ret == 0 { + Ok(true) + } else { + let err = io::Error::last_os_error(); + match err.raw_os_error() { + Some(libc::EWOULDBLOCK) => Ok(false), + _ => Err(err), + } + } +} + +/// Release any lock held on `file`. +pub fn flock_unlock(file: &File) -> io::Result<()> { + // SAFETY: flock() with LOCK_UN on a valid fd is safe. + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) }; + if ret == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + #[test] + fn acquire_exclusive_lock() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + let file = File::create(&path).unwrap(); + + let acquired = try_flock_exclusive(&file).unwrap(); + assert!(acquired); + } + + #[test] + fn unlock_then_reacquire() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + let file = File::create(&path).unwrap(); + + assert!(try_flock_exclusive(&file).unwrap()); + flock_unlock(&file).unwrap(); + assert!(try_flock_exclusive(&file).unwrap()); + } + + #[test] + fn second_fd_blocked() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lock"); + let file1 = File::create(&path).unwrap(); + let file2 = File::open(&path).unwrap(); + + assert!(try_flock_exclusive(&file1).unwrap()); + assert!(!try_flock_exclusive(&file2).unwrap()); + } +} diff --git a/lib/crates/fabro-proc/src/lib.rs b/lib/crates/fabro-proc/src/lib.rs index 5872cd2e5..dd63c64d4 100644 --- a/lib/crates/fabro-proc/src/lib.rs +++ b/lib/crates/fabro-proc/src/lib.rs @@ -1,5 +1,7 @@ #![allow(unsafe_code)] +#[cfg(unix)] +mod flock; #[cfg(unix)] mod pre_exec; mod signal; @@ -11,6 +13,9 @@ pub use signal::process_alive; #[cfg(unix)] pub use signal::{sigkill, sigterm, sigterm_process_group}; +#[cfg(unix)] +pub use flock::{flock_unlock, try_flock_exclusive}; + #[cfg(target_os = "linux")] pub use pre_exec::pre_exec_pdeathsig; #[cfg(unix)] diff --git a/lib/crates/fabro-server/src/bind.rs b/lib/crates/fabro-server/src/bind.rs new file mode 100644 index 000000000..ac417c18f --- /dev/null +++ b/lib/crates/fabro-server/src/bind.rs @@ -0,0 +1,112 @@ +use std::fmt; +use std::net::SocketAddr; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Bind { + Unix(PathBuf), + Tcp(SocketAddr), +} + +impl fmt::Display for Bind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unix(path) => write!(f, "{}", path.display()), + Self::Tcp(addr) => write!(f, "{addr}"), + } + } +} + +/// Parse a bind address string into a `Bind` value. +/// +/// If the string contains `/`, it is treated as a Unix socket path. Otherwise +/// it is parsed as a TCP `host:port` address. +/// +/// # Errors +/// +/// Returns an error if the TCP address cannot be parsed, or if a Unix socket +/// path exceeds the OS limit (104 bytes on macOS, 108 on Linux). +pub fn parse_bind(s: &str) -> anyhow::Result { + if s.contains('/') { + let path = PathBuf::from(s); + validate_unix_path_length(&path)?; + Ok(Bind::Unix(path)) + } else { + let addr: SocketAddr = s + .parse() + .map_err(|e| anyhow::anyhow!("invalid TCP address '{s}': {e}"))?; + Ok(Bind::Tcp(addr)) + } +} + +fn validate_unix_path_length(path: &std::path::Path) -> anyhow::Result<()> { + #[cfg(target_os = "macos")] + const MAX_UNIX_PATH: usize = 104; + #[cfg(not(target_os = "macos"))] + const MAX_UNIX_PATH: usize = 108; + + let path_bytes = path.as_os_str().as_encoded_bytes().len(); + if path_bytes >= MAX_UNIX_PATH { + anyhow::bail!( + "Unix socket path is too long ({path_bytes} bytes, max {MAX_UNIX_PATH}): {}", + path.display() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_tcp_address() { + let bind = parse_bind("127.0.0.1:3000").unwrap(); + assert_eq!(bind, Bind::Tcp("127.0.0.1:3000".parse().unwrap())); + } + + #[test] + fn parse_unix_socket_path() { + let bind = parse_bind("/tmp/fabro.sock").unwrap(); + assert_eq!(bind, Bind::Unix(PathBuf::from("/tmp/fabro.sock"))); + } + + #[test] + fn parse_invalid_tcp_address() { + let result = parse_bind("not-an-address"); + assert!(result.is_err()); + } + + #[test] + fn parse_unix_path_exceeding_limit() { + // Build a path that exceeds the OS limit + #[cfg(target_os = "macos")] + const LIMIT: usize = 104; + #[cfg(not(target_os = "macos"))] + const LIMIT: usize = 108; + + let long_path = format!("/{}", "a".repeat(LIMIT)); + let result = parse_bind(&long_path); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("too long"), + "expected 'too long' in error: {err_msg}" + ); + } + + #[test] + fn display_tcp() { + let bind = Bind::Tcp("0.0.0.0:8080".parse().unwrap()); + assert_eq!(bind.to_string(), "0.0.0.0:8080"); + } + + #[test] + fn display_unix() { + let bind = Bind::Unix(PathBuf::from("/run/fabro.sock")); + assert_eq!(bind.to_string(), "/run/fabro.sock"); + } +} diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 537b3ec18..12772d0b6 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -3,6 +3,7 @@ allow(clippy::absolute_paths, clippy::await_holding_lock, clippy::float_cmp) )] +pub mod bind; #[allow(clippy::wildcard_imports, clippy::absolute_paths)] mod demo; pub mod error; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 7dfeed151..60e8f987f 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -5,7 +5,7 @@ use std::time::Duration; use fabro_config::server::{load_server_settings, resolve_storage_dir}; use fabro_util::terminal::Styles; use object_store::local::LocalFileSystem; -use tokio::net::TcpListener; +use tokio::net::{TcpListener, UnixListener}; use tokio::time::interval; use tracing::{error, info, warn}; @@ -13,6 +13,7 @@ use clap::Args; use fabro_types::Settings; +use crate::bind::{self, Bind}; use crate::github_webhooks::WebhookManager; use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode}; use crate::server::{build_router, create_app_state_with_store, spawn_scheduler}; @@ -20,15 +21,11 @@ use crate::tls::{ClientAuth, build_rustls_config, serve_tls}; use fabro_llm::client::Client as LlmClient; use fabro_sandbox::SandboxProvider; -#[derive(Args)] +#[derive(Args, Clone)] pub struct ServeArgs { - /// Port to listen on - #[arg(long, default_value = "3000")] - pub port: u16, - - /// Host address to bind to - #[arg(long, default_value = "127.0.0.1")] - pub host: String, + /// Address to bind to (host:port for TCP, or path containing / for Unix socket) + #[arg(long)] + pub bind: Option, /// Override default LLM model #[arg(long)] @@ -151,16 +148,18 @@ pub async fn serve_command( spawn_scheduler(Arc::clone(&state)); let router = build_router(state, auth_mode); - let addr = format!("{}:{}", args.host, args.port); - let listener = TcpListener::bind(&addr).await?; + let bind_addr = match args.bind { + Some(ref s) => bind::parse_bind(s)?, + None => Bind::Tcp("127.0.0.1:3000".parse().unwrap()), + }; - info!(host = %args.host, port = args.port, dry_run = dry_run_mode, "API server started"); + info!(bind = %bind_addr, dry_run = dry_run_mode, "API server started"); eprintln!( "{}", styles.bold.apply_to(format!( "Fabro server listening on {}", - styles.cyan.apply_to(&addr) + styles.cyan.apply_to(&bind_addr) )), ); if dry_run_mode { @@ -215,16 +214,7 @@ pub async fn serve_command( // Spawn config polling task let settings_for_poll = Arc::clone(&shared_settings); let config_path_for_poll = config_path.clone(); - let args_for_poll = ServeArgs { - port: args.port, - host: args.host.clone(), - model: args.model.clone(), - provider: args.provider.clone(), - dry_run: args.dry_run, - sandbox: args.sandbox, - max_concurrent_runs: args.max_concurrent_runs, - config: config_path.clone(), - }; + let args_for_poll = args.clone(); tokio::spawn(async move { let mut interval = interval(Duration::from_secs(5)); interval.tick().await; // skip first immediate tick @@ -251,24 +241,48 @@ pub async fn serve_command( } }); - // Branch: TLS or plain HTTP + // Branch: TLS, plain TCP, or Unix socket let tls_settings = shared_settings .read() .expect("config lock poisoned") .api .as_ref() .and_then(|a| a.tls.clone()); - if let Some(ref tls_settings) = tls_settings { - let client_auth = client_auth.unwrap(); - let rustls_config = build_rustls_config(tls_settings, client_auth); - let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); + match bind_addr { + Bind::Unix(ref path) => { + if tls_settings.is_some() { + warn!("TLS is configured but not supported on Unix sockets; ignoring TLS settings"); + } - info!("TLS enabled"); + // Remove stale socket file before binding + if path.exists() { + std::fs::remove_file(path)?; + } - serve_tls(listener, tls_acceptor, router).await?; - } else { - axum::serve(listener, router).await?; + let listener = UnixListener::bind(path)?; + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await?; + } + Bind::Tcp(addr) => { + let listener = TcpListener::bind(addr).await?; + + if let Some(ref tls_settings) = tls_settings { + let client_auth = client_auth.unwrap(); + let rustls_config = build_rustls_config(tls_settings, client_auth); + let tls_acceptor = tokio_rustls::TlsAcceptor::from(rustls_config); + + info!("TLS enabled"); + + // TLS uses a manual accept loop and cannot use with_graceful_shutdown + serve_tls(listener, tls_acceptor, router).await?; + } else { + axum::serve(listener, router) + .with_graceful_shutdown(shutdown_signal()) + .await?; + } + } } // Clean up webhook listener on shutdown @@ -279,6 +293,34 @@ pub async fn serve_command( Ok(()) } +async fn shutdown_signal() { + use tokio::signal; + + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } + + info!("Shutdown signal received, stopping server"); +} + /// Derive client certificate verification mode from the resolved auth strategies. fn client_auth_from_mode(auth_mode: &AuthMode) -> ClientAuth { let strategies = match auth_mode {