use serde::Serialize;
use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Endpoint {
UnixSocket { path: PathBuf },
WindowsPipe { name: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct Diagnostics {
pub endpoint: Endpoint,
pub log_path: Option<PathBuf>,
pub last_error: Option<String>,
pub startup_attempted: bool,
pub startup_elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum EnsureDaemonStatus {
AlreadyRunning {
endpoint: Endpoint,
pid: Option<u32>,
},
Started {
endpoint: Endpoint,
pid: Option<u32>,
log_path: PathBuf,
},
Unavailable {
reason: DaemonUnavailableReason,
diagnostics: Diagnostics,
},
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DaemonUnavailableReason {
SpawnFailed,
StartupTimeout,
EndpointBindFailed,
BinaryNotFound,
}
#[derive(Debug, Error)]
pub enum EnsureError {
#[error("daemon binary not found at {0}")]
BinaryNotFound(PathBuf),
}
#[derive(Debug, Clone)]
pub struct EnsureDaemonOptions {
pub daemon_binary: PathBuf,
pub state_dir: PathBuf,
pub log_dir: PathBuf,
pub endpoint: Endpoint,
pub startup_timeout: Duration,
pub allow_spawn: bool,
}
pub async fn ensure_daemon(opts: EnsureDaemonOptions) -> EnsureDaemonStatus {
let start = std::time::Instant::now();
if probe_endpoint(&opts.endpoint).await {
return EnsureDaemonStatus::AlreadyRunning {
endpoint: opts.endpoint,
pid: None,
};
}
if !opts.allow_spawn {
return EnsureDaemonStatus::Unavailable {
reason: DaemonUnavailableReason::EndpointBindFailed,
diagnostics: Diagnostics {
endpoint: opts.endpoint,
log_path: None,
last_error: Some("endpoint unreachable; spawn disabled".into()),
startup_attempted: false,
startup_elapsed_ms: start.elapsed().as_millis() as u64,
},
};
}
let binary_has_separator =
opts.daemon_binary.components().nth(1).is_some() || opts.daemon_binary.is_absolute();
if binary_has_separator && !opts.daemon_binary.exists() {
return EnsureDaemonStatus::Unavailable {
reason: DaemonUnavailableReason::BinaryNotFound,
diagnostics: Diagnostics {
endpoint: opts.endpoint,
log_path: None,
last_error: Some(format!(
"daemon binary not found: {}",
opts.daemon_binary.display()
)),
startup_attempted: false,
startup_elapsed_ms: start.elapsed().as_millis() as u64,
},
};
}
let _ = std::fs::create_dir_all(&opts.log_dir);
let log_path = opts.log_dir.join("terminal-commanderd.log");
let log_file = match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(f) => f,
Err(e) => {
return EnsureDaemonStatus::Unavailable {
reason: DaemonUnavailableReason::SpawnFailed,
diagnostics: Diagnostics {
endpoint: opts.endpoint,
log_path: Some(log_path),
last_error: Some(format!("open log: {e}")),
startup_attempted: false,
startup_elapsed_ms: start.elapsed().as_millis() as u64,
},
};
}
};
let log_file_err = match log_file.try_clone() {
Ok(f) => f,
Err(e) => {
return EnsureDaemonStatus::Unavailable {
reason: DaemonUnavailableReason::SpawnFailed,
diagnostics: Diagnostics {
endpoint: opts.endpoint,
log_path: Some(log_path),
last_error: Some(format!("clone log fd: {e}")),
startup_attempted: false,
startup_elapsed_ms: start.elapsed().as_millis() as u64,
},
};
}
};
let tc_socket_val: std::ffi::OsString = match &opts.endpoint {
Endpoint::UnixSocket { path } => path.as_os_str().into(),
Endpoint::WindowsPipe { name } => name.into(),
};
let mut cmd = std::process::Command::new(&opts.daemon_binary);
cmd.arg("--data-dir")
.arg(&opts.state_dir)
.arg("start")
.arg("--mode")
.arg("ipc-server")
.env("TC_SOCKET", &tc_socket_val)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log_file))
.stderr(std::process::Stdio::from(log_file_err));
let child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
return EnsureDaemonStatus::Unavailable {
reason: DaemonUnavailableReason::SpawnFailed,
diagnostics: Diagnostics {
endpoint: opts.endpoint,
log_path: Some(log_path),
last_error: Some(format!("spawn: {e}")),
startup_attempted: true,
startup_elapsed_ms: start.elapsed().as_millis() as u64,
},
};
}
};
let pid = Some(child.id());
drop(child);
let deadline = std::time::Instant::now() + opts.startup_timeout;
while std::time::Instant::now() < deadline {
if probe_endpoint(&opts.endpoint).await {
return EnsureDaemonStatus::Started {
endpoint: opts.endpoint,
pid,
log_path,
};
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
EnsureDaemonStatus::Unavailable {
reason: DaemonUnavailableReason::StartupTimeout,
diagnostics: Diagnostics {
endpoint: opts.endpoint,
log_path: Some(log_path),
last_error: Some(format!(
"endpoint did not bind within {}ms",
opts.startup_timeout.as_millis()
)),
startup_attempted: true,
startup_elapsed_ms: start.elapsed().as_millis() as u64,
},
}
}
async fn probe_endpoint(endpoint: &Endpoint) -> bool {
match endpoint {
#[cfg(unix)]
Endpoint::UnixSocket { path } => tokio::net::UnixStream::connect(path).await.is_ok(),
#[cfg(not(unix))]
Endpoint::UnixSocket { .. } => false,
#[cfg(windows)]
Endpoint::WindowsPipe { name } => {
use tokio::net::windows::named_pipe::ClientOptions;
ClientOptions::new().open(name.as_str()).is_ok()
}
#[cfg(not(windows))]
Endpoint::WindowsPipe { .. } => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn stub_returns_unavailable() {
let opts = EnsureDaemonOptions {
daemon_binary: PathBuf::from("nonexistent"),
state_dir: PathBuf::from("."),
log_dir: PathBuf::from("."),
endpoint: Endpoint::WindowsPipe {
name: r"\\.\pipe\unused".into(),
},
startup_timeout: Duration::from_millis(10),
allow_spawn: false,
};
let status = ensure_daemon(opts).await;
assert!(matches!(status, EnsureDaemonStatus::Unavailable { .. }));
}
#[tokio::test]
async fn bare_binary_name_does_not_fail_fast_on_missing_check() {
let dir = tempfile::TempDir::new().unwrap();
let opts = EnsureDaemonOptions {
daemon_binary: PathBuf::from("definitely-not-installed-xyz"),
state_dir: dir.path().into(),
log_dir: dir.path().into(),
endpoint: Endpoint::WindowsPipe {
name: r"\\.\pipe\unused".into(),
},
startup_timeout: Duration::from_millis(10),
allow_spawn: true,
};
let status = ensure_daemon(opts).await;
match status {
EnsureDaemonStatus::Unavailable {
reason,
diagnostics,
} => {
assert!(
matches!(reason, DaemonUnavailableReason::SpawnFailed),
"expected SpawnFailed, got {reason:?}"
);
assert!(
diagnostics.startup_attempted,
"startup must have been attempted (spawn was called)"
);
}
other => panic!("expected Unavailable, got {other:?}"),
}
}
}