use std::path::PathBuf;
use std::time::Duration;
use tracing::warn;
pub const DEFAULT_STARTUP_TIMEOUT_MS: u64 = 5000;
pub const STARTUP_CHECK_INTERVAL_MS: u64 = 100;
pub fn pid_file_path() -> PathBuf {
if let Ok(custom) = std::env::var("CONPROXY_PID_FILE") {
return PathBuf::from(custom);
}
if cfg!(windows) {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("conproxy")
.join("proxy.pid")
} else if let Some(dir) = dirs::runtime_dir() {
dir.join("conproxy.pid")
} else {
PathBuf::from("/tmp/conproxy.pid")
}
}
pub fn pid_file_path_for_project(project_root: &std::path::Path) -> PathBuf {
let hash = blake3::hash(project_root.to_string_lossy().as_bytes());
let hash8 = &hash.to_hex()[..8];
let filename = format!("conproxy-{}.pid", hash8);
if cfg!(windows) {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("conproxy")
.join(filename)
} else if let Some(dir) = dirs::runtime_dir() {
dir.join(filename)
} else {
PathBuf::from("/tmp").join(filename)
}
}
pub fn write_pid_file(pid: u32) {
let path = pid_file_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Err(e) = std::fs::write(&path, pid.to_string()) {
warn!(path = %path.display(), error = %e, "Failed to write PID file");
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
if let Err(e) = std::fs::set_permissions(&path, perms) {
warn!(error = %e, "Failed to set PID file permissions");
}
}
}
pub fn remove_pid_file() {
let path = pid_file_path();
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %path.display(), error = %e, "Failed to remove PID file");
}
}
}
pub fn read_pid() -> Option<u32> {
let path = pid_file_path();
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
return content.trim().parse().ok();
}
}
None
}
pub fn is_process_alive(pid: u32) -> bool {
#[cfg(unix)]
{
let proc_path = format!("/proc/{}", pid);
if std::path::Path::new(&proc_path).exists() {
let comm_path = format!("/proc/{}/comm", pid);
if let Ok(comm) = std::fs::read_to_string(&comm_path) {
return comm.trim().contains("conproxy");
}
return false;
}
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(windows)]
{
std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid), "/NH"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.map(|o| {
let out = String::from_utf8_lossy(&o.stdout);
out.contains(&pid.to_string())
})
.unwrap_or(false)
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
false
}
}
pub fn is_proxy_running() -> bool {
if let Some(pid) = read_pid() {
is_process_alive(pid)
} else {
false
}
}
pub fn is_port_in_use(addr: &str) -> bool {
use std::net::TcpListener;
TcpListener::bind(addr).is_err()
}
pub async fn wait_for_proxy(addr: &str, timeout_ms: u64) -> Result<(), ProxyError> {
let iterations = timeout_ms / STARTUP_CHECK_INTERVAL_MS;
for _ in 0..iterations {
if is_port_in_use(addr) {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(STARTUP_CHECK_INTERVAL_MS)).await;
}
Err(ProxyError::StartupTimeout(format!(
"Proxy not listening on {} after {}ms",
addr, timeout_ms
)))
}
#[derive(Debug)]
pub enum ProxyError {
StartupTimeout(String),
Http(String),
}
impl std::fmt::Display for ProxyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::StartupTimeout(msg) => write!(f, "Startup timeout: {}", msg),
Self::Http(msg) => write!(f, "HTTP error: {}", msg),
}
}
}
impl std::error::Error for ProxyError {}
pub async fn stop_proxy(addr: &str) -> Result<(), ProxyError> {
let url = format!("http://{}/shutdown", addr);
let client = super::socket_tuning::create_tuned_client_default(Duration::from_secs(5))
.build()
.map_err(|e| ProxyError::Http(e.to_string()))?;
let _ = client.post(&url).send().await;
if let Some(pid) = read_pid() {
if is_process_alive(pid) {
#[cfg(unix)]
{
let _ = std::process::Command::new("kill")
.args(["-TERM", &pid.to_string()])
.status();
tokio::time::sleep(Duration::from_millis(500)).await;
if is_process_alive(pid) {
let _ = std::process::Command::new("kill")
.args(["-KILL", &pid.to_string()])
.status();
}
}
#[cfg(windows)]
{
let _ = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
}
}
remove_pid_file();
}
Ok(())
}
pub async fn proxy_status(addr: &str) -> Result<ProxyStatus, ProxyError> {
let pid = read_pid();
let process_alive = pid.map(is_process_alive).unwrap_or(false);
let url = format!("http://{}/health", addr);
let client = super::socket_tuning::create_tuned_client_default(Duration::from_secs(2))
.build()
.map_err(|e| ProxyError::Http(e.to_string()))?;
let health: Option<serde_json::Value> = match client.get(&url).send().await {
Ok(response) if response.status().is_success() => {
tokio::time::timeout(Duration::from_secs(2), response.json())
.await
.ok()
.and_then(|r| r.ok())
}
_ => None,
};
Ok(ProxyStatus {
running: health.is_some() || process_alive,
pid,
health,
})
}
#[derive(Debug)]
pub struct ProxyStatus {
pub running: bool,
pub pid: Option<u32>,
pub health: Option<serde_json::Value>,
}
#[cfg(test)]
#[path = "tests/lifecycle_tests.rs"]
mod tests;