use std::path::{Path, PathBuf};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use crate::app::config::{self, Config};
use super::dispatch::dispatch;
use super::ipc::{DaemonRequest, DaemonResponse};
use super::socket::daemon_socket_path;
use super::state::DaemonState;
#[derive(Debug, Default, Clone)]
pub struct DaemonArgs {
pub config: Option<PathBuf>,
}
pub async fn run_daemon(args: DaemonArgs) -> anyhow::Result<()> {
let cfg = config::load_xdg(args.config.as_deref())?;
let xdg_path = args.config.clone().unwrap_or_else(config::xdg_config_path);
let socket_path = daemon_socket_path();
serve_with_config(cfg, Some(xdg_path), &socket_path).await
}
async fn serve_with_config(
cfg: Config,
xdg_path: Option<PathBuf>,
socket_path: &Path,
) -> anyhow::Result<()> {
prepare_socket_dir(socket_path).await?;
let lock_path = lock_path_for(socket_path);
let lock = acquire_single_instance(&lock_path)?;
let state = DaemonState::open(cfg, xdg_path).await?;
remove_stale_socket(socket_path).await;
let watcher = super::watch::spawn_corpus_watcher(&state);
spawn_signal_handlers(&state);
let result = serve_on_listener(&state, socket_path).await;
drop(watcher);
cleanup(lock, socket_path).await;
result
}
pub fn spawn_signal_handlers(state: &DaemonState) {
let token = state.shutdown_token().clone();
let mut sigterm = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "hallouminate::daemon", error = %e, "failed to install SIGTERM handler");
return;
}
};
tokio::spawn(async move {
tokio::select! {
_ = tokio::signal::ctrl_c() => {
tracing::info!(target: "hallouminate::daemon", "received SIGINT; shutting down");
}
_ = sigterm.recv() => {
tracing::info!(target: "hallouminate::daemon", "received SIGTERM; shutting down");
}
}
token.cancel();
});
}
async fn cleanup(lock: std::fs::File, socket_path: &Path) {
let _ = tokio::fs::remove_file(socket_path).await;
drop(lock);
}
async fn prepare_socket_dir(socket_path: &Path) -> anyhow::Result<()> {
if let Some(parent) = socket_path.parent()
&& !parent.as_os_str().is_empty()
{
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| anyhow::anyhow!("create socket parent dir {}: {e}", parent.display()))?;
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
if let Err(e) = tokio::fs::set_permissions(parent, perms).await {
tracing::warn!(
target: "hallouminate::daemon",
parent = %parent.display(),
error = %e,
"failed to set socket parent permissions; continuing with default",
);
}
}
Ok(())
}
async fn remove_stale_socket(socket_path: &Path) {
if let Err(e) = tokio::fs::remove_file(socket_path).await
&& e.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!(
target: "hallouminate::daemon",
socket = %socket_path.display(),
error = %e,
"failed to remove stale socket before bind; bind may fail with address-in-use",
);
}
}
pub async fn serve(state: &DaemonState, socket_path: &Path) -> anyhow::Result<()> {
prepare_socket_dir(socket_path).await?;
let lock_path = lock_path_for(socket_path);
let lock = acquire_single_instance(&lock_path)?;
remove_stale_socket(socket_path).await;
let watcher = super::watch::spawn_corpus_watcher(state);
let result = serve_on_listener(state, socket_path).await;
drop(watcher);
cleanup(lock, socket_path).await;
result
}
async fn serve_on_listener(state: &DaemonState, socket_path: &Path) -> anyhow::Result<()> {
let listener = UnixListener::bind(socket_path).map_err(|e| {
tracing::error!(
target: "hallouminate::daemon",
socket = %socket_path.display(),
error = %e,
"failed to bind daemon socket",
);
anyhow::anyhow!("bind {}: {e}", socket_path.display())
})?;
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
if let Err(e) = tokio::fs::set_permissions(socket_path, perms).await {
tracing::warn!(
target: "hallouminate::daemon",
socket = %socket_path.display(),
error = %e,
"failed to set socket permissions; continuing with default",
);
}
tracing::info!(
target: "hallouminate::daemon",
socket = %socket_path.display(),
"daemon listening"
);
let shutdown = state.shutdown_token().clone();
loop {
let (stream, _addr) = tokio::select! {
_ = shutdown.cancelled() => {
tracing::info!(target: "hallouminate::daemon", "shutdown requested; stopping accept loop");
break;
}
accepted = listener.accept() => match accepted {
Ok(pair) => pair,
Err(e) => {
tracing::warn!(target: "hallouminate::daemon", error = %e, "accept error");
continue;
}
},
};
let state = state.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(state, stream).await {
tracing::warn!(
target: "hallouminate::daemon",
error = %e,
"connection handler errored"
);
}
});
}
Ok(())
}
async fn handle_connection(state: DaemonState, stream: UnixStream) -> anyhow::Result<()> {
let (read_half, mut write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut line = String::new();
let n = reader.read_line(&mut line).await?;
if n == 0 {
return Ok(());
}
let response = match serde_json::from_str::<DaemonRequest>(line.trim_end()) {
Ok(req) => dispatch(&state, req).await,
Err(e) => DaemonResponse::invalid_params(format!("invalid request: {e}")),
};
let mut text = serde_json::to_string(&response)?;
text.push('\n');
write_half.write_all(text.as_bytes()).await?;
write_half.flush().await?;
Ok(())
}
fn lock_path_for(socket_path: &Path) -> PathBuf {
let mut s = socket_path.as_os_str().to_os_string();
s.push(".lock");
PathBuf::from(s)
}
fn acquire_single_instance(lock_path: &Path) -> anyhow::Result<std::fs::File> {
use std::fs::OpenOptions;
use std::os::unix::fs::OpenOptionsExt;
use rustix::fs::{FlockOperation, flock};
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(lock_path)
.map_err(|e| anyhow::anyhow!("open lockfile {}: {e}", lock_path.display()))?;
if let Err(errno) = flock(&file, FlockOperation::NonBlockingLockExclusive) {
return Err(anyhow::anyhow!(
"another hallouminate daemon already holds {} ({})",
lock_path.display(),
std::io::Error::from(errno)
));
}
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn lock_path_appends_dot_lock_suffix() {
let sock = PathBuf::from("/tmp/hallouminate/daemon.sock");
assert_eq!(
lock_path_for(&sock),
PathBuf::from("/tmp/hallouminate/daemon.sock.lock"),
);
}
#[tokio::test]
async fn remove_stale_socket_tolerates_missing_file() {
let dir = std::env::temp_dir().join(format!("hallouminate-test-{}", std::process::id()));
let missing = dir.join("never-existed.sock");
assert!(!missing.exists());
remove_stale_socket(&missing).await;
assert!(!missing.exists());
}
#[tokio::test]
async fn remove_stale_socket_unlinks_existing_file() {
let dir = std::env::temp_dir().join(format!(
"hallouminate-test-{}-{}",
std::process::id(),
"stale"
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let stale = dir.join("daemon.sock");
std::fs::write(&stale, b"").expect("create stale socket stand-in");
assert!(stale.exists());
remove_stale_socket(&stale).await;
assert!(!stale.exists(), "stale socket must be removed before bind");
std::fs::remove_dir_all(&dir).ok();
}
}