use crate::service::server::{build_router, SearchAppState};
use fs4::FileExt;
use std::{
fs::{File, OpenOptions},
io::{Read, Write},
net::SocketAddr,
path::{Path, PathBuf},
};
use thiserror::Error;
use tokio::net::TcpListener;
#[derive(Debug, Error)]
pub enum DaemonError {
#[error("another trusty-search daemon is already running (lock held at {0})")]
AlreadyRunning(PathBuf),
#[error("could not determine data-local directory")]
NoDataDir,
#[error("could not find a free port starting at {0}")]
NoFreePort(u16),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("server error: {0}")]
Server(String),
}
pub fn daemon_lock_path() -> Result<PathBuf, DaemonError> {
Ok(daemon_dir()?.join("daemon.lock"))
}
pub fn daemon_port_path() -> Result<PathBuf, DaemonError> {
Ok(daemon_dir()?.join("daemon.port"))
}
pub fn http_addr_path() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".trusty-search").join("http_addr"))
}
fn daemon_dir() -> Result<PathBuf, DaemonError> {
let dir = dirs::data_local_dir()
.ok_or(DaemonError::NoDataDir)?
.join("trusty-search");
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
pub struct DaemonHandle {
pub port: u16,
pub addr: SocketAddr,
}
async fn bind_with_auto_port(
start_port: u16,
max_attempts: u16,
) -> Result<TcpListener, DaemonError> {
let addr: SocketAddr =
format!("127.0.0.1:{start_port}")
.parse()
.map_err(|e: std::net::AddrParseError| {
DaemonError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
})?;
trusty_common::bind_with_auto_port(addr, max_attempts)
.await
.map_err(|e| {
tracing::warn!("auto-port exhausted from {start_port}: {e:#}");
DaemonError::NoFreePort(start_port)
})
}
pub fn is_already_running() -> Option<PathBuf> {
let lock_path = daemon_lock_path().ok()?;
if !lock_path.exists() {
return None;
}
let file = OpenOptions::new()
.create(false)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.ok()?;
if file.try_lock_exclusive().is_err() {
Some(lock_path)
} else {
None
}
}
pub fn running_daemon_pid() -> Option<u32> {
let lock_path = daemon_lock_path().ok()?;
if !lock_path.exists() {
return None;
}
let pid = read_lockfile_pid(&lock_path)?;
if pid_alive(pid) {
Some(pid)
} else {
None
}
}
fn read_lockfile_pid(lock_path: &Path) -> Option<u32> {
let mut s = String::new();
File::open(lock_path).ok()?.read_to_string(&mut s).ok()?;
s.trim().parse::<u32>().ok()
}
#[cfg(unix)]
fn pid_alive(pid: u32) -> bool {
let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
if rc == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
fn pid_alive(_pid: u32) -> bool {
true
}
fn acquire_lock(lock_path: &PathBuf) -> Result<File, DaemonError> {
let file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(lock_path)?;
if file.try_lock_exclusive().is_ok() {
return Ok(file);
}
if let Some(prev_pid) = read_lockfile_pid(lock_path) {
if !pid_alive(prev_pid) {
tracing::warn!(
"stale lockfile at {} (pid {prev_pid} is dead) — removing and retrying",
lock_path.display()
);
drop(file);
let _ = std::fs::remove_file(lock_path);
let retry = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(lock_path)?;
if retry.try_lock_exclusive().is_ok() {
return Ok(retry);
}
}
}
Err(DaemonError::AlreadyRunning(lock_path.clone()))
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{signal, SignalKind};
let mut term = match signal(SignalKind::terminate()) {
Ok(s) => s,
Err(e) => {
tracing::warn!("install SIGTERM handler failed: {e}");
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
pub async fn run_daemon(state: SearchAppState, requested_port: u16) -> Result<(), DaemonError> {
let lock_path = daemon_lock_path()?;
let port_path = daemon_port_path()?;
let mut lock_file = acquire_lock(&lock_path)?;
let pid_string = std::process::id().to_string();
let _ = lock_file.set_len(0);
let _ = lock_file.write_all(pid_string.as_bytes());
let listener = bind_with_auto_port(requested_port, 64).await?;
let addr = listener.local_addr()?;
let port = addr.port();
write_port_file(&port_path, port)?;
let http_addr_written = match http_addr_path() {
Some(path) => match write_http_addr_file(&path, &addr) {
Ok(()) => Some(path),
Err(e) => {
tracing::warn!("could not write {}: {e}", path.display());
None
}
},
None => None,
};
eprintln!(
"trusty-search v{} — HTTP admin panel: http://{}",
env!("CARGO_PKG_VERSION"),
addr,
);
let state = state.with_daemon_port(port);
let router = build_router(state);
tracing::info!("daemon listening on {addr} (lock {})", lock_path.display());
let serve_result = axum::serve(listener, router)
.with_graceful_shutdown(shutdown_signal())
.await;
let _ = std::fs::remove_file(&port_path);
if let Some(path) = http_addr_written {
let _ = std::fs::remove_file(&path);
}
serve_result.map_err(|e| DaemonError::Server(e.to_string()))?;
drop(lock_file);
Ok(())
}
fn write_http_addr_file(path: &Path, addr: &SocketAddr) -> Result<(), DaemonError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("addr.tmp");
{
let mut f = File::create(&tmp)?;
writeln!(f, "{addr}")?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
fn write_port_file(path: &PathBuf, port: u16) -> Result<(), DaemonError> {
let tmp = path.with_extension("port.tmp");
{
let mut f = File::create(&tmp)?;
writeln!(f, "{port}")?;
f.sync_all()?;
}
std::fs::rename(&tmp, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener as StdTcpListener;
#[test]
fn http_addr_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("http_addr");
let addr: SocketAddr = "127.0.0.1:54321".parse().unwrap();
write_http_addr_file(&path, &addr).unwrap();
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read.trim(), "127.0.0.1:54321");
}
#[test]
fn port_file_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.port");
write_port_file(&path, 12345).unwrap();
let read = std::fs::read_to_string(&path).unwrap();
assert_eq!(read.trim(), "12345");
}
#[test]
fn pid_alive_current_process_is_alive() {
assert!(pid_alive(std::process::id()));
assert!(!pid_alive(2_000_000_000));
}
#[test]
fn read_lockfile_pid_parses_pid() {
let dir = tempfile::tempdir().unwrap();
let good = dir.path().join("good.lock");
std::fs::write(&good, "12345\n").unwrap();
assert_eq!(read_lockfile_pid(&good), Some(12345));
let bad = dir.path().join("bad.lock");
std::fs::write(&bad, "not-a-pid").unwrap();
assert_eq!(read_lockfile_pid(&bad), None);
}
#[test]
fn lockfile_contention_errors() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("daemon.lock");
let _first = acquire_lock(&path).unwrap();
let err = acquire_lock(&path).unwrap_err();
assert!(matches!(err, DaemonError::AlreadyRunning(_)));
}
#[tokio::test]
async fn auto_port_walks_forward() {
let occupied = StdTcpListener::bind("127.0.0.1:0").unwrap();
let occupied_port = occupied.local_addr().unwrap().port();
let next = bind_with_auto_port(occupied_port, 64).await.unwrap();
assert_ne!(next.local_addr().unwrap().port(), occupied_port);
}
#[tokio::test]
async fn auto_port_zero_uses_os() {
let l = bind_with_auto_port(0, 1).await.unwrap();
assert!(l.local_addr().unwrap().port() > 0);
}
}