mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-11 22:53:00 +00:00
Merge remote-tracking branch 'origin/main' into feat/event-sourced-run-state
This commit is contained in:
commit
946009dbef
23 changed files with 1108 additions and 65 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1858,6 +1858,7 @@ version = "0.176.2"
|
|||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
37
lib/crates/fabro-cli/src/commands/server/foreground.rs
Normal file
37
lib/crates/fabro-cli/src/commands/server/foreground.rs
Normal 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
|
||||
}
|
||||
67
lib/crates/fabro-cli/src/commands/server/mod.rs
Normal file
67
lib/crates/fabro-cli/src/commands/server/mod.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
120
lib/crates/fabro-cli/src/commands/server/record.rs
Normal file
120
lib/crates/fabro-cli/src/commands/server/record.rs
Normal 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
|
||||
}
|
||||
}
|
||||
247
lib/crates/fabro-cli/src/commands/server/start.rs
Normal file
247
lib/crates/fabro-cli/src/commands/server/start.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
52
lib/crates/fabro-cli/src/commands/server/status.rs
Normal file
52
lib/crates/fabro-cli/src/commands/server/status.rs
Normal 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")
|
||||
}
|
||||
}
|
||||
40
lib/crates/fabro-cli/src/commands/server/stop.rs
Normal file
40
lib/crates/fabro-cli/src/commands/server/stop.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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()?;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
43
lib/crates/fabro-cli/tests/it/cmd/server_status.rs
Normal file
43
lib/crates/fabro-cli/tests/it/cmd/server_status.rs
Normal 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
|
||||
");
|
||||
}
|
||||
44
lib/crates/fabro-cli/tests/it/cmd/server_stop.rs
Normal file
44
lib/crates/fabro-cli/tests/it/cmd/server_stop.rs
Normal 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
|
||||
");
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
mod exec;
|
||||
mod lifecycle;
|
||||
mod recovery;
|
||||
mod server_lifecycle;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
|
|
|||
79
lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs
Normal file
79
lib/crates/fabro-cli/tests/it/scenario/server_lifecycle.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -12,5 +12,8 @@ workspace = true
|
|||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
|
|
|||
70
lib/crates/fabro-proc/src/flock.rs
Normal file
70
lib/crates/fabro-proc/src/flock.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
112
lib/crates/fabro-server/src/bind.rs
Normal file
112
lib/crates/fabro-server/src/bind.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue