mod acp;
mod agent;
mod api;
mod auth;
mod embedded;
mod engine;
mod fs;
mod git;
mod models;
mod presets;
mod proxy;
mod update;
mod utils;
mod workspaces;
mod ws;
#[cfg(test)]
mod test_utils;
use anyhow::Context;
use axum::Router;
use axum::body::Body;
use axum::http::StatusCode;
use axum::middleware;
use axum::response::{IntoResponse, Response};
use axum::serve::ListenerExt;
use clap::{Parser, Subcommand};
use sqlx::sqlite::SqlitePoolOptions;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use tokio::signal::unix::{self, SignalKind};
use tower_http::cors::CorsLayer;
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
#[cfg(unix)]
use std::os::unix::io::{AsRawFd, RawFd};
#[derive(Parser)]
#[command(name = "omniterm", version, about = "Web-based terminal session manager")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Start(StartArgs),
Stop(StopArgs),
Status(StatusArgs),
ResetAuth(ResetAuthArgs),
Update(update::UpdateArgs),
}
#[derive(Parser)]
struct StopArgs {
#[arg(long, env = "OMNITERM_DB")]
db: Option<String>,
}
#[derive(Parser)]
struct StatusArgs {
#[arg(long, env = "OMNITERM_DB")]
db: Option<String>,
}
#[derive(Parser)]
struct ResetAuthArgs {
#[arg(long, env = "OMNITERM_DB")]
db: Option<String>,
}
#[derive(Parser)]
struct StartArgs {
#[arg(short = 'p', long, env = "OMNITERM_PORT", default_value = "9077")]
port: u16,
#[arg(long, env = "OMNITERM_DB")]
db: Option<String>,
#[arg(long, env = "OMNITERM_JWT_SECRET")]
jwt_secret: Option<String>,
#[arg(
long,
env = "OMNITERM_AUTH_ENABLED",
num_args = 0..=1,
default_missing_value = "true",
value_parser = parse_bool_flag,
)]
auth_enabled: Option<bool>,
#[arg(short = 'H', long, env = "OMNITERM_HOST", default_value = "127.0.0.1")]
host: String,
#[arg(short = 'd', long)]
daemonize: bool,
#[arg(long, env = "OMNITERM_RESET_AUTH")]
reset_auth: bool,
#[arg(long)]
debug: bool,
#[arg(long, env = "OMNITERM_PROXY_DOMAIN")]
proxy_domain: Option<String>,
#[arg(long, env = "OMNITERM_PROXY_MAX_BODY")]
proxy_max_body: Option<usize>,
#[arg(long, env = "OMNITERM_MAX_UPLOAD_BODY")]
max_upload_body: Option<usize>,
}
#[derive(Clone)]
pub struct AppState {
pub db: sqlx::SqlitePool,
pub jwt_secret: String,
pub token_cookie: String,
pub api_keys: HashMap<String, String>,
pub auth_enabled: std::sync::Arc<std::sync::atomic::AtomicBool>,
pub acp_idle_recycle_secs: std::sync::Arc<std::sync::atomic::AtomicU64>,
pub login_guard: auth::LoginGuard,
pub engines: engine::EngineRegistry,
pub acp_supervisor: acp::AcpSupervisor,
pub proxy: proxy::ProxyState,
pub max_upload_body: usize,
}
async fn embedded_static_handler(uri: axum::http::Uri) -> impl IntoResponse {
let path = uri.path();
if let Some((data, mime)) = embedded::serve_embedded(path) {
return Response::builder()
.status(StatusCode::OK)
.header("content-type", mime)
.body(Body::from(data))
.unwrap();
}
if let Some((data, mime)) = embedded::serve_spa_fallback(path) {
return Response::builder()
.status(StatusCode::OK)
.header("content-type", mime)
.body(Body::from(data))
.unwrap();
}
(StatusCode::NOT_FOUND, "Not Found").into_response()
}
fn db_file_path(db_url: &str) -> &str {
let path = db_url.strip_prefix("sqlite:").unwrap_or(db_url);
path.split('?').next().unwrap_or("")
}
fn pid_path(db_url: &str) -> String {
format!("{}.pid", db_file_path(db_url))
}
pub static DAEMONIZED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg(unix)]
fn pid_exists(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
#[cfg(not(unix))]
fn pid_exists(_pid: i32) -> bool {
false
}
fn binary_name() -> String {
std::env::args()
.next()
.and_then(|a| Path::new(&a).file_stem().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "omniterm".to_string())
}
fn omniterm_data_dir() -> PathBuf {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_else(|_| ".".into());
Path::new(&home).join(".omniterm")
}
fn is_dev_build() -> bool {
if cfg!(debug_assertions) {
return true;
}
std::env::args()
.next()
.map(|a| {
let parent = Path::new(&a).parent();
parent.is_some_and(|d| d.ends_with("target/debug") || d.ends_with("target/release"))
})
.unwrap_or(false)
}
fn default_db_stem() -> String {
if is_dev_build() { "omniterm-dev".to_string() } else { binary_name() }
}
#[cfg(unix)]
fn daemon_log_path() -> PathBuf {
omniterm_data_dir().join(format!("{}.log", default_db_stem()))
}
fn default_db_url() -> String {
let dir = omniterm_data_dir();
let _ = std::fs::create_dir_all(&dir);
format!("sqlite:{}?mode=rwc", dir.join(format!("{}.db", default_db_stem())).display())
}
const INSTANCE_PREFIX: &str = "omniterm";
pub const TOKEN_COOKIE_BASE: &str = "omniterm_token";
const JWT_SECRET_FILE_BASE: &str = "jwt_secret";
fn instance_id(db_url: &str) -> String {
let path = db_file_path(db_url);
if path.is_empty() || path.contains(":memory:") {
return default_db_stem();
}
Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(default_db_stem)
}
fn instance_suffix(instance: &str) -> String {
let rest = instance.strip_prefix(INSTANCE_PREFIX).unwrap_or(instance);
rest.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>()
.trim_matches('_')
.to_string()
}
fn token_cookie_name(suffix: &str) -> String {
if suffix.is_empty() {
TOKEN_COOKIE_BASE.to_string()
} else {
format!("{TOKEN_COOKIE_BASE}_{suffix}")
}
}
fn jwt_secret_file_name(suffix: &str) -> String {
if suffix.is_empty() {
JWT_SECRET_FILE_BASE.to_string()
} else {
format!("{JWT_SECRET_FILE_BASE}_{suffix}")
}
}
fn parse_bool_flag(s: &str) -> Result<bool, String> {
match s.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
other => Err(format!("invalid boolean value '{other}' (expected true/false/1/0)")),
}
}
fn resolve_jwt_secret(explicit: Option<String>, suffix: &str) -> anyhow::Result<String> {
if let Some(s) = explicit {
if s.trim().is_empty() {
anyhow::bail!("JWT_SECRET must not be empty");
}
return Ok(s);
}
let dir = omniterm_data_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(jwt_secret_file_name(suffix));
let path = path.to_string_lossy().into_owned();
if let Ok(existing) = std::fs::read_to_string(&path) {
let existing = existing.trim();
if !existing.is_empty() {
return Ok(existing.to_string());
}
}
let secret = format!("{}{}", uuid::Uuid::new_v4(), uuid::Uuid::new_v4()).replace('-', "");
match write_secret_file(&path, &secret) {
Ok(()) => Ok(secret),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
let s = std::fs::read_to_string(&path)?.trim().to_string();
if s.is_empty() {
anyhow::bail!("jwt secret file {} is empty", path);
}
Ok(s)
}
Err(e) => Err(e.into()),
}
}
fn resolve_api_keys() -> HashMap<String, String> {
let mut keys = HashMap::new();
let path = omniterm_data_dir().join("api_keys.toml");
if let Ok(content) = std::fs::read_to_string(&path)
&& let Ok(parsed) = content.parse::<toml::Table>()
{
for (k, v) in parsed {
if let Some(val) = v.as_str()
&& !val.is_empty()
{
keys.insert(k, val.to_string());
}
}
tracing::info!("loaded {} API key(s) from {}", keys.len(), path.display());
}
for var in ["SENSENOVA_API_KEY", "STEPFUN_API_KEY", "AMD_API_KEY"] {
if let Ok(val) = std::env::var(var)
&& !val.is_empty()
{
keys.insert(var.to_string(), val);
}
}
if !keys.is_empty() {
let names: Vec<&str> = keys.keys().map(|s| s.as_str()).collect();
tracing::info!("ACP agent API keys configured: {}", names.join(", "));
} else {
tracing::warn!(
"no ACP model API keys configured (create ~/.omniterm/api_keys.toml or set env vars)"
);
}
keys
}
#[cfg(unix)]
fn write_secret_file(path: &str, secret: &str) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
f.write_all(secret.as_bytes())?;
Ok(())
}
#[cfg(not(unix))]
fn write_secret_file(path: &str, secret: &str) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
f.write_all(secret.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn daemonize(log_file: &Path) -> std::io::Result<RawFd> {
use std::os::unix::fs::OpenOptionsExt;
use std::process;
if let Some(parent) = log_file.parent() {
std::fs::create_dir_all(parent)?;
}
let log = std::fs::OpenOptions::new().create(true).append(true).mode(0o600).open(log_file)?;
let mut fds = [0 as RawFd; 2];
if unsafe { libc::pipe(fds.as_mut_ptr()) } < 0 {
return Err(std::io::Error::last_os_error());
}
let (read_fd, write_fd) = (fds[0], fds[1]);
match unsafe { libc::fork() } {
-1 => return Err(std::io::Error::last_os_error()),
0 => unsafe {
libc::close(read_fd);
}, _ => {
unsafe { libc::close(write_fd) };
let mut buf = [0u8; 4096];
let n =
unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
if n > 0 && buf[0] == 1 {
if n > 1 {
let msg = String::from_utf8_lossy(&buf[1..n as usize]);
eprintln!("{}", msg);
} else {
eprintln!("OmniTerm started in the background");
}
process::exit(0);
} else if n > 0 && buf[0] == 0 {
let msg = String::from_utf8_lossy(&buf[1..n as usize]);
eprintln!("Error: {}", msg);
process::exit(1);
} else {
eprintln!(
"Error: server exited before it was ready (see {} for details)",
log_file.display()
);
process::exit(1);
}
}
}
unsafe {
libc::setsid();
}
match unsafe { libc::fork() } {
-1 => return Err(std::io::Error::last_os_error()),
0 => {} _ => process::exit(0), }
let devnull = std::fs::OpenOptions::new().read(true).open("/dev/null")?;
unsafe {
if libc::dup2(devnull.as_raw_fd(), 0) < 0
|| libc::dup2(log.as_raw_fd(), 1) < 0
|| libc::dup2(log.as_raw_fd(), 2) < 0
{
return Err(std::io::Error::last_os_error());
}
}
Ok(write_fd)
}
#[cfg(unix)]
fn daemon_notify_ready(pipe_write: Option<RawFd>, msg: &str) {
if let Some(fd) = pipe_write {
let body = &msg.as_bytes()[..msg.len().min(4000)];
let mut buf = Vec::with_capacity(1 + body.len());
buf.push(1u8);
buf.extend_from_slice(body);
unsafe {
let _ = libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len());
libc::close(fd);
}
}
}
#[cfg(unix)]
fn daemon_notify_fail(pipe_write: Option<RawFd>, msg: &str) {
if let Some(fd) = pipe_write {
let body = &msg.as_bytes()[..msg.len().min(4000)];
let mut buf = Vec::with_capacity(1 + body.len());
buf.push(0u8);
buf.extend_from_slice(body);
unsafe {
let _ = libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len());
libc::close(fd);
}
}
}
#[cfg(not(unix))]
fn daemon_notify_ready(_pipe_write: (), _msg: &str) {}
#[cfg(not(unix))]
fn daemon_notify_fail(_pipe_write: (), _msg: &str) {}
fn warn_legacy_env() {
const RENAMED: &[(&str, &str)] = &[
("BIND_ADDR", "OMNITERM_HOST + OMNITERM_PORT"),
("BACKEND_PORT", "OMNITERM_PORT"),
("DATABASE_URL", "OMNITERM_DB"),
("JWT_SECRET", "OMNITERM_JWT_SECRET"),
];
for (legacy, replacement) in RENAMED {
if std::env::var_os(legacy).is_some() {
tracing::warn!(
"ignoring legacy env var {} — omniterm only reads {} now (rename or unset it)",
legacy,
replacement
);
}
}
}
fn rust_log_covers_omniterm(rust_log: Option<&str>) -> bool {
let Some(rust_log) = rust_log else {
return false;
};
rust_log.split(',').any(|seg| {
let seg = seg.trim();
if seg.is_empty() {
return false;
}
let Some((target, _)) = seg.split_once('=') else {
return true;
};
let target = target.trim();
target == "omniterm" || target.starts_with("omniterm::") || target.starts_with("omniterm-")
})
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
let debug_logging = matches!(&cli.command, Commands::Start(args) if args.debug);
#[cfg(unix)]
let daemon_pipe: Option<RawFd> = if let Commands::Start(ref args) = cli.command
&& args.daemonize
{
let log_path = daemon_log_path();
let fd =
Some(daemonize(&log_path).with_context(|| {
format!("failed to daemonize (log file: {})", log_path.display())
})?);
DAEMONIZED.store(true, std::sync::atomic::Ordering::Relaxed);
fd
} else {
None
};
#[cfg(not(unix))]
let daemon_pipe: () = ();
#[cfg(not(unix))]
if let Commands::Start(ref args) = cli.command
&& args.daemonize
{
anyhow::bail!("--daemonize is only supported on Unix");
}
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(async {
let filter = if debug_logging {
EnvFilter::from_default_env().add_directive("omniterm=debug".parse()?)
} else if rust_log_covers_omniterm(std::env::var("RUST_LOG").ok().as_deref()) {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("omniterm=info"))
} else {
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("omniterm=info"))
.add_directive("omniterm=info".parse()?)
};
tracing_subscriber::fmt().with_env_filter(filter).init();
warn_legacy_env();
let result: anyhow::Result<()> = async {
match cli.command {
Commands::Update(args) => update::run(args).await,
Commands::ResetAuth(args) => {
let db_url = args.db.unwrap_or_else(default_db_url);
let db = SqlitePoolOptions::new().max_connections(1).connect(&db_url).await?;
sqlx::migrate!("./migrations").run(&db).await?;
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&db).await?;
if count == 0 {
eprintln!("No user accounts to delete.");
return Ok(());
}
sqlx::query("DELETE FROM users").execute(&db).await?;
eprintln!(
"Deleted {} user account(s). Start the server with `omniterm start` and set a new password.",
count
);
Ok(())
}
Commands::Stop(args) => {
let db_url = args.db.unwrap_or_else(default_db_url);
let pid_file = pid_path(&db_url);
let pid: i32 = match std::fs::read_to_string(&pid_file) {
Ok(s) => s.trim().parse().unwrap_or(0),
Err(_) => {
eprintln!("Not running (no PID file at {})", pid_file);
std::process::exit(1);
}
};
if pid == 0 || !pid_exists(pid) {
let _ = std::fs::remove_file(&pid_file);
eprintln!("Server is not running (stale PID file removed).");
std::process::exit(1);
}
#[cfg(windows)]
{
eprintln!("stop is not supported on Windows");
std::process::exit(1);
}
#[cfg(unix)]
{
unsafe {
libc::kill(pid, libc::SIGTERM);
}
for _ in 0..100 {
if !pid_exists(pid) {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
if pid_exists(pid) {
eprintln!("Server did not stop within 10s. Killing forcefully.");
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
let _ = std::fs::remove_file(&pid_file);
eprintln!("Stopped.");
Ok(())
}
}
Commands::Status(args) => {
let db_url = args.db.unwrap_or_else(default_db_url);
let pid_file = pid_path(&db_url);
let pid: i32 = match std::fs::read_to_string(&pid_file) {
Ok(s) => s.trim().parse().unwrap_or(0),
Err(_) => {
eprintln!("Not running");
return Ok(());
}
};
if pid == 0 || !pid_exists(pid) {
let _ = std::fs::remove_file(&pid_file);
eprintln!("Not running (stale PID file cleaned)");
return Ok(());
}
eprintln!("Running (PID: {})", pid);
Ok(())
}
Commands::Start(args) => {
let db_url = args.db.unwrap_or_else(default_db_url);
let suffix = instance_suffix(&instance_id(&db_url));
let jwt_secret = resolve_jwt_secret(args.jwt_secret.clone(), &suffix)?;
let db = SqlitePoolOptions::new().max_connections(5).connect(&db_url).await?;
sqlx::migrate!("./migrations").run(&db).await?;
let engines = engine::EngineRegistry::new(db.clone(), args.port);
if let Err(e) = engines.check_multiplexer() {
tracing::warn!(
"{} — multiplexer-backed sessions will fail until installed; ACP sessions unaffected.",
e
);
}
if let Err(e) =
sqlx::query("UPDATE chat_messages SET status = 'complete' WHERE status = 'streaming'")
.execute(&db)
.await
{
tracing::warn!("failed to reconcile orphaned streaming chat messages: {}", e);
}
presets::seed_builtin_presets(&db).await;
if args.reset_auth {
sqlx::query("DELETE FROM users").execute(&db).await?;
tracing::warn!("All user accounts deleted. Set a new password via the web UI.");
}
let mut auth_enabled = sqlx::query_scalar::<_, String>(
"SELECT value FROM settings WHERE key = 'auth_enabled'",
)
.fetch_optional(&db)
.await?
.map(|v| v == "1")
.unwrap_or(false);
if let Some(forced) = args.auth_enabled {
auth_enabled = forced;
sqlx::query(
"INSERT INTO settings (key, value) VALUES ('auth_enabled', ?) \
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
)
.bind(if forced { "1" } else { "0" })
.execute(&db)
.await?;
}
let acp_idle_recycle_secs = sqlx::query_scalar::<_, String>(
"SELECT value FROM settings WHERE key = 'acp_idle_recycle_min'",
)
.fetch_optional(&db)
.await?
.as_deref()
.map(|v| acp_idle_recycle_secs_from_setting(Some(v)))
.unwrap_or(acp::reaper::IDLE_RECYCLE_SECS);
let acp_idle_recycle_secs = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(
acp_idle_recycle_secs,
));
let pid_file = pid_path(&db_url);
let proxy_client = reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.context("failed to build proxy HTTP client")?;
let state = AppState {
db,
jwt_secret,
token_cookie: token_cookie_name(&suffix),
api_keys: resolve_api_keys(),
auth_enabled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(auth_enabled)),
acp_idle_recycle_secs,
login_guard: auth::LoginGuard::new(),
engines,
acp_supervisor: acp::AcpSupervisor::default(),
proxy: proxy::ProxyState {
client: proxy_client,
self_port: args.port,
base_host: args.proxy_domain.clone(),
max_request_body: args.proxy_max_body.unwrap_or(proxy::MAX_REQUEST_BODY),
},
max_upload_body: args.max_upload_body.unwrap_or(api::files::MAX_UPLOAD_BODY_DEFAULT),
};
agent::watch::spawn(state.engines.watcher().clone(), state.engines.clone());
let reaper_supervisor = state.acp_supervisor.clone();
let reaper_idle_secs = state.acp_idle_recycle_secs.clone();
let reaper_db = state.db.clone();
tokio::spawn(async move {
acp::reaper::run_reaper(reaper_supervisor, reaper_db, reaper_idle_secs).await;
});
let frontend_dir =
std::env::var("FRONTEND_DIR").unwrap_or_else(|_| "frontend/dist".into());
let app = Router::new().merge(api::routes(state.clone()));
let dev_mode = Path::new(&frontend_dir).is_dir();
let app = if dev_mode {
let static_service = ServeDir::new(&frontend_dir)
.not_found_service(ServeFile::new(format!("{}/index.html", frontend_dir)));
tracing::info!("Serving frontend from {}", frontend_dir);
app.fallback_service(static_service)
} else {
tracing::debug!("Serving from embedded assets");
app.fallback(embedded_static_handler)
};
let app = app.layer(CorsLayer::permissive()).layer(TraceLayer::new_for_http());
let app = if state.proxy.base_host.is_some() {
app.layer(middleware::from_fn_with_state(state.clone(), proxy::proxy_host_mw))
} else {
app
};
let bind = format!("{}:{}", args.host, args.port);
let listener = tokio::net::TcpListener::bind(&bind).await?.tap_io(|stream| {
if let Err(e) = stream.set_nodelay(true) {
warn!("failed to set TCP_NODELAY on accepted connection: {e}");
}
});
if let Some(parent) = Path::new(&pid_file).parent()
&& !parent.as_os_str().is_empty()
{
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(&pid_file, std::process::id().to_string())?;
daemon_notify_ready(
daemon_pipe,
&format!(
"OmniTerm v{} started in the background — http://{} (PID: {})",
env!("CARGO_PKG_VERSION"),
bind,
std::process::id()
),
);
let listen_host = bind.split_once(':').map(|(h, _)| h).unwrap_or(&bind);
let is_loopback = matches!(listen_host, "127.0.0.1" | "localhost" | "::1" | "[::1]");
if !auth_enabled && !is_loopback {
tracing::warn!(
"密码验证已关闭且监听非回环地址 {} — 任何能访问该端口的人都可完全控制本机。\
请在设置中开启密码验证,或设置环境变量 OMNITERM_AUTH_ENABLED=1。",
listen_host
);
}
if dev_mode {
let branch = std::env::var("BRANCH_NAME").unwrap_or_else(|_| "dev".into());
let version = env!("CARGO_PKG_VERSION");
info!("starting omniterm branch={} version={}", branch, version);
tracing::info!("OmniTerm server listening on {}", bind);
} else {
eprintln!("OmniTerm v{} — http://{}", env!("CARGO_PKG_VERSION"), bind);
}
let shutdown_supervisor = state.acp_supervisor.clone();
let shutdown_signal = {
let shutdown_pid = pid_file.clone();
async move {
#[cfg(unix)]
{
let mut term =
unix::signal(SignalKind::terminate()).expect("install SIGTERM handler");
let mut int =
unix::signal(SignalKind::interrupt()).expect("install SIGINT handler");
tokio::select! {
_ = term.recv() => {}
_ = int.recv() => {}
}
}
#[cfg(windows)]
{
let _ = tokio::signal::ctrl_c().await;
}
info!("shutdown signal received, recycling ACP agent subprocesses");
shutdown_supervisor.shutdown_all().await;
let _ = std::fs::remove_file(&shutdown_pid);
}
};
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(shutdown_signal)
.await?;
Ok(())
}
}
}
.await;
if let Err(ref e) = result {
daemon_notify_fail(daemon_pipe, &format!("{e:#}"));
}
result
})
}
fn acp_idle_recycle_secs_from_setting(setting_min: Option<&str>) -> u64 {
match setting_min.and_then(|v| v.trim().parse::<u64>().ok()) {
Some(min) => min.saturating_mul(60),
None => acp::reaper::IDLE_RECYCLE_SECS,
}
}
#[cfg(test)]
mod tests {
use super::{
acp_idle_recycle_secs_from_setting, default_db_stem, instance_id, instance_suffix,
jwt_secret_file_name, rust_log_covers_omniterm, token_cookie_name,
};
use crate::acp::reaper::IDLE_RECYCLE_SECS;
#[test]
fn instance_suffix_separates_dev_from_release() {
assert_eq!(instance_suffix("omniterm"), "");
assert_eq!(token_cookie_name(""), "omniterm_token");
assert_eq!(jwt_secret_file_name(""), "jwt_secret");
assert_eq!(instance_suffix("omniterm-dev"), "dev");
assert_eq!(token_cookie_name("dev"), "omniterm_token_dev");
assert_eq!(jwt_secret_file_name("dev"), "jwt_secret_dev");
assert_eq!(instance_suffix("omniterm-preview"), "preview");
assert_eq!(instance_suffix("my db"), "my_db");
}
#[test]
fn instance_id_follows_effective_db() {
assert_eq!(
instance_id("sqlite:/home/pax/.omniterm/omniterm-dev.db?mode=rwc"),
"omniterm-dev"
);
assert_eq!(
instance_id("sqlite:/home/pax/.omniterm/omniterm-preview.db?mode=rwc"),
"omniterm-preview"
);
let docker = "sqlite:/app/data/omniterm.db?mode=rwc";
assert_eq!(instance_id(docker), "omniterm");
assert_eq!(instance_suffix(&instance_id(docker)), "");
assert_eq!(instance_id("sqlite::memory:"), default_db_stem());
}
#[test]
fn missing_setting_falls_back_to_default() {
assert_eq!(acp_idle_recycle_secs_from_setting(None), IDLE_RECYCLE_SECS);
}
#[test]
fn unparseable_setting_falls_back_to_default() {
assert_eq!(acp_idle_recycle_secs_from_setting(Some("abc")), IDLE_RECYCLE_SECS);
assert_eq!(acp_idle_recycle_secs_from_setting(Some("")), IDLE_RECYCLE_SECS);
assert_eq!(acp_idle_recycle_secs_from_setting(Some(" ")), IDLE_RECYCLE_SECS);
}
#[test]
fn minutes_are_converted_to_seconds() {
assert_eq!(acp_idle_recycle_secs_from_setting(Some("1")), 60);
assert_eq!(acp_idle_recycle_secs_from_setting(Some("5")), 300);
assert_eq!(acp_idle_recycle_secs_from_setting(Some("30")), 1800);
assert_eq!(acp_idle_recycle_secs_from_setting(Some(" 10 ")), 600);
}
#[test]
fn rust_log_covering_omniterm_skips_floor() {
assert!(rust_log_covers_omniterm(Some("omniterm=debug")));
assert!(rust_log_covers_omniterm(Some("warn,omniterm=info")));
assert!(rust_log_covers_omniterm(Some("omniterm::engine=debug,sqlx=warn")));
assert!(rust_log_covers_omniterm(Some("info"))); }
#[test]
fn rust_log_without_omniterm_target_gets_floor() {
assert!(!rust_log_covers_omniterm(Some("omniterm_main=info,omniterm_server=info")));
assert!(!rust_log_covers_omniterm(Some("sqlx=warn,tower_http=info")));
assert!(!rust_log_covers_omniterm(Some(""))); assert!(!rust_log_covers_omniterm(None));
}
}