Merge remote-tracking branch 'origin/main' into feat/event-sourced-run-state

This commit is contained in:
Bryan Helmkamp 2026-04-03 06:44:58 -07:00
commit 946009dbef
No known key found for this signature in database
23 changed files with 1108 additions and 65 deletions

1
Cargo.lock generated
View file

@ -1858,6 +1858,7 @@ version = "0.176.2"
dependencies = [
"cc",
"libc",
"tempfile",
]
[[package]]

View file

@ -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.

View file

@ -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)]

View file

@ -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;

View file

@ -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<PathBuf>,
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
}

View file

@ -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
}
}
}

View file

@ -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<Utc>,
}
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<ServerRecord> {
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<ServerRecord> {
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
}
}

View file

@ -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<std::fs::File> {
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::<Vec<_>>().join("\n")
}
Err(_) => String::new(),
}
}

View file

@ -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")
}
}

View file

@ -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");
}

View file

@ -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()?;

View file

@ -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;

View file

@ -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 <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>
Port to listen on [default: 3000]
--host <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 <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 <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_DIR>
Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--verbose
Enable verbose output [env: FABRO_VERBOSE=]
--sandbox <SANDBOX>
Sandbox for agent tools
--server-url <SERVER_URL>
Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=]
--storage-dir <STORAGE_DIR>
Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--max-concurrent-runs <MAX_CONCURRENT_RUNS>
Maximum number of concurrent run executions
--server-url <SERVER_URL>
Server URL (overrides server.base_url from user.toml) [env: FABRO_SERVER_URL=]
--config <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();
}

View file

@ -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_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--server-url <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
");
}

View file

@ -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 <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_DIR> Storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]]
--server-url <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
");
}

View file

@ -1,6 +1,7 @@
mod exec;
mod lifecycle;
mod recovery;
mod server_lifecycle;
use std::path::{Path, PathBuf};
use std::sync::Arc;

View file

@ -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"
);
}

View file

@ -12,5 +12,8 @@ workspace = true
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
tempfile = "3"
[build-dependencies]
cc = "1"

View file

@ -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<bool> {
// 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());
}
}

View file

@ -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)]

View file

@ -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<Bind> {
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");
}
}

View file

@ -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;

View file

@ -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<String>,
/// 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 {