use std::path::Path;
use std::time::{Duration, Instant};
use crate::proxy_lock;
pub enum StopResult {
Stopped { name: String, pid: u32 },
StaleCleaned { name: String },
}
pub fn stop_proxy(name: &str) -> Result<StopResult, String> {
match proxy_lock::check_lock(name) {
proxy_lock::LockStatus::Held(info) => {
let pid = info.pid;
proxy_lock::stop_proxy(name);
Ok(StopResult::Stopped {
name: name.to_string(),
pid,
})
}
proxy_lock::LockStatus::Stale(_) => {
proxy_lock::remove_lock(name);
Ok(StopResult::StaleCleaned {
name: name.to_string(),
})
}
proxy_lock::LockStatus::Free => Err(format!("proxy \"{}\" is not running.", name)),
}
}
pub fn stop_all_proxies() -> Vec<String> {
proxy_lock::stop_all_proxies()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReloadOutcome {
Applied,
Rejected { message: String },
Timeout,
}
const RELOAD_TIMEOUT: Duration = Duration::from_secs(3);
const RELOAD_POLL_INTERVAL: Duration = Duration::from_millis(50);
pub fn reload_proxy(name: &str, config_path: &Path) -> Result<ReloadOutcome, String> {
let info = match proxy_lock::check_lock(name) {
proxy_lock::LockStatus::Held(info) => info,
proxy_lock::LockStatus::Stale(_) | proxy_lock::LockStatus::Free => {
return Err(format!("proxy \"{name}\" is not running"));
}
};
let contents = read_config_file(config_path)?;
proxy_lock::snapshot_config(name, &contents)
.map_err(|e| format!("failed to write config snapshot for \"{name}\": {e}"))?;
let nonce = next_reload_nonce();
proxy_lock::write_reload_request(name, nonce)
.map_err(|e| format!("failed to write reload request for \"{name}\": {e}"))?;
send_sighup(info.pid)?;
Ok(wait_for_reload_result(
name,
nonce,
RELOAD_TIMEOUT,
RELOAD_POLL_INTERVAL,
))
}
fn next_reload_nonce() -> u64 {
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64
}
fn wait_for_reload_result(
name: &str,
nonce: u64,
timeout: Duration,
poll_interval: Duration,
) -> ReloadOutcome {
let deadline = Instant::now() + timeout;
loop {
if let Some(result) = proxy_lock::read_reload_result(name)
&& result.nonce == nonce
{
return match result.status {
proxy_lock::ReloadStatus::Applied => ReloadOutcome::Applied,
proxy_lock::ReloadStatus::Rejected => ReloadOutcome::Rejected {
message: result.message,
},
};
}
if Instant::now() >= deadline {
return ReloadOutcome::Timeout;
}
std::thread::sleep(poll_interval);
}
}
fn read_config_file(path: &Path) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|e| format!("failed to read {}: {e}", path.display()))
}
#[cfg(unix)]
fn send_sighup(pid: u32) -> Result<(), String> {
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
kill(Pid::from_raw(pid as i32), Signal::SIGHUP)
.map_err(|e| format!("failed to send SIGHUP to pid {pid}: {e}"))
}
#[cfg(not(unix))]
fn send_sighup(_pid: u32) -> Result<(), String> {
Err("reload is not supported on this platform".to_string())
}
pub fn list_proxies() -> Vec<(String, proxy_lock::LockStatus)> {
proxy_lock::list_proxies()
}
pub fn delete_proxy(name: &str) -> Result<(), String> {
match proxy_lock::check_lock(name) {
proxy_lock::LockStatus::Held(_) => {
return Err(format!(
"proxy \"{name}\" is running. Stop it first with `mcpr proxy stop {name}`."
));
}
proxy_lock::LockStatus::Stale(_) => {
proxy_lock::remove_lock(name);
}
proxy_lock::LockStatus::Free => {
if !proxy_lock::proxy_dir_exists(name) {
return Err(format!("proxy \"{name}\" not found."));
}
}
}
proxy_lock::delete_proxy_dir(name)
.map_err(|e| format!("failed to remove proxy \"{name}\" directory: {e}"))
}
#[cfg(test)]
#[allow(non_snake_case)]
mod tests {
use super::*;
#[test]
fn delete_proxy__missing_returns_not_found() {
let err = delete_proxy("__test_delete_logic_missing_zzz__").unwrap_err();
assert!(err.contains("not found"));
}
#[test]
fn delete_proxy__stopped_proxy_removes_dir() {
let name = "__test_delete_logic_stopped__";
proxy_lock::snapshot_config(name, "[mcp]\nurl=\"x\"\n").unwrap();
assert!(proxy_lock::proxy_dir_exists(name));
delete_proxy(name).unwrap();
assert!(!proxy_lock::proxy_dir_exists(name));
}
#[test]
fn reload_proxy__not_running_returns_err() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "[mcp]\nurl=\"http://localhost:9000\"\n").unwrap();
let err = reload_proxy("__nonexistent_reload_target_zzz__", tmp.path()).unwrap_err();
assert!(
err.contains("not running"),
"expected 'not running' in err, got: {err}"
);
}
#[cfg(unix)]
#[test]
fn reload_proxy__missing_config_file_returns_err() {
let name = "__test_reload_proxy_missing_cfg__";
proxy_lock::write_lock(name, 4242, "/tmp/x.toml").unwrap();
let result = reload_proxy(name, std::path::Path::new("/nonexistent/missing.toml"));
let _ = std::fs::remove_dir_all(proxy_lock_dir(name));
let err = result.unwrap_err();
assert!(err.contains("failed to read"), "got: {err}");
}
#[test]
fn wait_for_reload_result__applied_match_returns_immediately() {
let name = "__test_wait_reload_applied__";
proxy_lock::write_reload_result(name, 100, proxy_lock::ReloadStatus::Applied, "ok")
.unwrap();
let outcome = wait_for_reload_result(
name,
100,
Duration::from_millis(500),
Duration::from_millis(10),
);
let _ = std::fs::remove_dir_all(proxy_lock_dir(name));
assert_eq!(outcome, ReloadOutcome::Applied);
}
#[test]
fn wait_for_reload_result__rejected_carries_message() {
let name = "__test_wait_reload_rejected__";
proxy_lock::write_reload_result(
name,
200,
proxy_lock::ReloadStatus::Rejected,
"fields require restart: mcp",
)
.unwrap();
let outcome = wait_for_reload_result(
name,
200,
Duration::from_millis(500),
Duration::from_millis(10),
);
let _ = std::fs::remove_dir_all(proxy_lock_dir(name));
assert_eq!(
outcome,
ReloadOutcome::Rejected {
message: "fields require restart: mcp".into(),
}
);
}
#[test]
fn wait_for_reload_result__missing_file_times_out() {
let name = "__test_wait_reload_missing__";
let outcome = wait_for_reload_result(
name,
300,
Duration::from_millis(150),
Duration::from_millis(20),
);
assert_eq!(outcome, ReloadOutcome::Timeout);
}
#[test]
fn wait_for_reload_result__stale_nonce_times_out() {
let name = "__test_wait_reload_stale__";
proxy_lock::write_reload_result(name, 1, proxy_lock::ReloadStatus::Applied, "old").unwrap();
let outcome = wait_for_reload_result(
name,
999,
Duration::from_millis(150),
Duration::from_millis(20),
);
let _ = std::fs::remove_dir_all(proxy_lock_dir(name));
assert_eq!(outcome, ReloadOutcome::Timeout);
}
#[test]
fn wait_for_reload_result__appears_during_poll() {
let name = "__test_wait_reload_late__";
let n = name.to_string();
let writer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(60));
proxy_lock::write_reload_result(&n, 555, proxy_lock::ReloadStatus::Applied, "ok")
.unwrap();
});
let outcome = wait_for_reload_result(
name,
555,
Duration::from_millis(500),
Duration::from_millis(20),
);
writer.join().unwrap();
let _ = std::fs::remove_dir_all(proxy_lock_dir(name));
assert_eq!(outcome, ReloadOutcome::Applied);
}
fn proxy_lock_dir(name: &str) -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".mcpr")
.join("proxies")
.join(name)
}
#[cfg(unix)]
#[test]
fn delete_proxy__running_proxy_errors_without_removing() {
let name = "__test_delete_logic_running__";
proxy_lock::write_lock(name, 4242, "/tmp/x.toml").unwrap();
assert!(proxy_lock::proxy_dir_exists(name));
let err = delete_proxy(name).unwrap_err();
assert!(err.contains("is running"));
assert!(proxy_lock::proxy_dir_exists(name));
let _ = proxy_lock::delete_proxy_dir(name);
}
}