use crate::config::{ServiceConfig, resolve_leindex_home};
use std::fs::{File, OpenOptions};
use std::io::Read;
use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, PartialEq)]
pub struct ServiceStatus {
pub name: String,
pub pid: Option<i32>,
pub port_open: bool,
pub http_ready: bool,
pub endpoint: String,
pub model: Option<String>,
pub served_model: Option<String>,
pub auto_start: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopOutcome {
Stopped(i32),
AlreadyDown,
StalePidfileRemoved,
}
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("service '{0}' has no model configured; set services.{0}.model to allow starting")]
NoModel(String),
#[error("cannot execute '{0}'")]
BinaryNotFound(String),
#[error("could not resolve LeIndex home directory")]
NoHome,
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(
"service '{name}' did not answer on {endpoint} within {timeout_secs}s (attempt terminated; see {log})"
)]
StartTimeout {
name: String,
endpoint: String,
timeout_secs: u64,
log: PathBuf,
},
#[error("service '{name}' exited during startup (see {log})")]
DiedDuringStart {
name: String,
log: PathBuf,
},
#[error(
"pidfile for '{0}' points at pid {1} which is not this service; refusing to signal (pidfile left in place)"
)]
NotOurs(String, i32),
#[error("another start of '{0}' is already in progress")]
Concurrent(String),
#[error("managed service lifecycle is supported only on Linux and macOS")]
UnsupportedPlatform,
}
const HEALTH_POLL_INTERVAL: Duration = Duration::from_millis(250);
const CONNECT_TIMEOUT: Duration = Duration::from_millis(300);
const STOP_GRACE: Duration = Duration::from_secs(10);
const STOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
fn run_dir() -> Result<PathBuf, ServiceError> {
let home = resolve_leindex_home().ok_or(ServiceError::NoHome)?;
Ok(home.join("run").join("services"))
}
fn pid_path(name: &str) -> Result<PathBuf, ServiceError> {
Ok(run_dir()?.join(format!("{name}.pid")))
}
#[cfg(unix)]
fn lock_path(name: &str) -> Result<PathBuf, ServiceError> {
Ok(run_dir()?.join(format!("{name}.lock")))
}
fn log_path(name: &str) -> Result<PathBuf, ServiceError> {
let home = resolve_leindex_home().ok_or(ServiceError::NoHome)?;
Ok(home
.join("logs")
.join("services")
.join(format!("{name}.log")))
}
fn first_addr(host: &str, port: u16) -> Option<std::net::SocketAddr> {
(host, port).to_socket_addrs().ok()?.next()
}
pub fn port_open(host: &str, port: u16) -> bool {
let Some(addr) = first_addr(host, port) else {
return false;
};
TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT).is_ok()
}
pub fn http_ready(host: &str, port: u16) -> bool {
use std::io::Write;
let Some(addr) = first_addr(host, port) else {
return false;
};
let Ok(mut stream) = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT) else {
return false;
};
let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT));
let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT));
let request = format!("GET /health HTTP/1.0\r\nHost: {host}\r\n\r\n");
if stream.write_all(request.as_bytes()).is_err() {
return false;
}
let deadline = Instant::now() + CONNECT_TIMEOUT;
let mut line = Vec::new();
while line.len() < 256 {
let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
return false;
};
if remaining.is_zero() || stream.set_read_timeout(Some(remaining)).is_err() {
return false;
}
let mut byte = [0];
if stream.read_exact(&mut byte).is_err() {
return false;
}
if byte[0] == b'\n' {
let Ok(head) = std::str::from_utf8(&line) else {
return false;
};
let mut parts = head.split_whitespace();
return matches!(parts.next(), Some("HTTP/1.1" | "HTTP/1.0"))
&& parts.next() == Some("200");
}
line.push(byte[0]);
}
false
}
fn service_port_open(cfg: &ServiceConfig) -> bool {
port_open(&cfg.host, cfg.port)
}
fn service_http_ready(cfg: &ServiceConfig) -> bool {
http_ready(&cfg.host, cfg.port)
}
fn wait_ready(cfg: &ServiceConfig, secs: u64) -> bool {
let deadline = Instant::now() + Duration::from_secs(secs.max(1));
while Instant::now() < deadline {
if service_http_ready(cfg) {
return true;
}
std::thread::sleep(HEALTH_POLL_INTERVAL);
}
service_http_ready(cfg)
}
#[cfg(target_os = "linux")]
fn proc_exists(pid: i32) -> bool {
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => !stat_is_zombie(&stat),
Err(_) => false,
}
}
#[cfg(all(unix, not(target_os = "linux")))]
fn proc_exists(pid: i32) -> bool {
if unsafe { libc::waitpid(pid, std::ptr::null_mut(), libc::WNOHANG) } == pid {
return false;
}
if unsafe { libc::kill(pid, 0) } == 0 {
return true;
}
std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
fn proc_exists(_pid: i32) -> bool {
false
}
#[cfg(target_os = "linux")]
fn stat_is_zombie(stat: &str) -> bool {
match stat.rsplit_once(')') {
Some((_, rest)) => rest.split_whitespace().next() == Some("Z"),
None => false,
}
}
#[cfg(target_os = "linux")]
fn read_cmdline_args(pid: i32) -> Option<Vec<String>> {
let mut bytes = Vec::new();
File::open(format!("/proc/{pid}/cmdline"))
.ok()?
.read_to_end(&mut bytes)
.ok()?;
let args = bytes
.split(|b| *b == 0)
.filter(|a| !a.is_empty())
.map(|a| String::from_utf8_lossy(a).into_owned())
.collect();
Some(args)
}
#[cfg(target_os = "macos")]
fn read_cmdline_args(pid: i32) -> Option<Vec<String>> {
let mut argmax = 0i32;
let mut size = std::mem::size_of_val(&argmax);
let mut mib = [libc::CTL_KERN, libc::KERN_ARGMAX];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
2,
(&mut argmax as *mut i32).cast(),
&mut size,
std::ptr::null_mut(),
0,
)
} != 0
|| argmax <= 0
{
return None;
}
let mut bytes = vec![0u8; argmax as usize];
size = bytes.len();
let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid];
if unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
bytes.as_mut_ptr().cast(),
&mut size,
std::ptr::null_mut(),
0,
)
} != 0
{
return None;
}
bytes.truncate(size);
parse_macos_procargs(&bytes)
}
#[cfg(any(target_os = "macos", test))]
fn parse_macos_procargs(bytes: &[u8]) -> Option<Vec<String>> {
let argc = i32::from_ne_bytes(bytes.get(..4)?.try_into().ok()?);
if argc <= 0 || argc as usize > bytes.len() {
return None;
}
let mut rest = bytes.get(4..)?;
rest = rest.get(rest.iter().position(|b| *b == 0)? + 1..)?;
while rest.first() == Some(&0) {
rest = &rest[1..];
}
let mut args = Vec::new();
for _ in 0..argc {
let end = rest.iter().position(|b| *b == 0)?;
args.push(std::str::from_utf8(&rest[..end]).ok()?.to_string());
rest = &rest[end + 1..];
}
Some(args)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn read_cmdline_args(_pid: i32) -> Option<Vec<String>> {
None
}
fn basename(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
match trimmed.rsplit_once('/') {
Some((_, base)) => base,
None => trimmed,
}
}
fn pid_is_ours(pid: i32, cfg: &ServiceConfig) -> bool {
if !proc_exists(pid) {
return false;
}
let Some(args) = read_cmdline_args(pid) else {
return false;
};
command_matches(&args, cfg)
}
fn command_matches(args: &[String], cfg: &ServiceConfig) -> bool {
let Some(argv0) = args.first() else {
return false;
};
let binary = &cfg.binary;
let binary_matches = argv0 == binary
|| (!binary.contains('/') && basename(argv0) == binary)
|| args.get(1) == Some(binary);
if !binary_matches {
return false;
}
let has_option = |flag: &str, value: &str| {
args.windows(2)
.any(|pair| pair[0] == flag && pair[1] == value)
};
if let Some(model) = &cfg.model {
if !has_option("-m", model) {
return false;
}
}
has_option("--host", &cfg.host) && has_option("--port", &cfg.port.to_string())
}
fn read_pidfile(name: &str) -> Result<Option<i32>, ServiceError> {
let path = pid_path(name)?;
let Ok(content) = std::fs::read_to_string(&path) else {
return Ok(None);
};
match content.trim().parse::<i32>() {
Ok(pid) if pid > 0 => Ok(Some(pid)),
_ => Ok(None),
}
}
fn write_pidfile(name: &str, pid: i32) -> Result<(), ServiceError> {
let path = pid_path(name)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, pid.to_string())?;
Ok(())
}
fn remove_pidfile(name: &str) {
let _ = std::fs::remove_file(pid_path(name).unwrap_or_else(|_| PathBuf::from("/dev/null")));
}
fn clean_stale_pidfile(name: &str, _cfg: &ServiceConfig) -> Result<bool, ServiceError> {
if !cfg!(any(target_os = "linux", target_os = "macos")) {
return Ok(false);
}
if let Some(pid) = read_pidfile(name)? {
if !proc_exists(pid) {
remove_pidfile(name);
return Ok(true);
}
}
Ok(false)
}
fn build_command(cfg: &ServiceConfig) -> Command {
let mut cmd = Command::new(&cfg.binary);
if let Some(model) = &cfg.model {
cmd.arg("-m").arg(model);
}
cmd.arg("--host").arg(&cfg.host);
cmd.arg("--port").arg(cfg.port.to_string());
if let Some(alias) = &cfg.alias {
cmd.arg("--alias").arg(alias);
}
cmd.args(&cfg.extra_args);
cmd
}
fn spawn_detached(name: &str, cfg: &ServiceConfig) -> Result<i32, ServiceError> {
let log = log_path(name)?;
if let Some(parent) = log.parent() {
std::fs::create_dir_all(parent)?;
}
let log_file = OpenOptions::new().create(true).append(true).open(&log)?;
let mut cmd = build_command(cfg);
cmd.stdout(Stdio::from(log_file.try_clone()?))
.stderr(Stdio::from(log_file))
.stdin(Stdio::null())
.current_dir("/");
#[cfg(unix)]
cmd.process_group(0);
let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ServiceError::BinaryNotFound(cfg.binary.clone())
} else {
e.into()
}
})?;
Ok(child.id() as i32)
}
struct StartGuard {
_file: File,
}
impl StartGuard {
#[cfg(unix)]
fn acquire(name: &str) -> Result<Option<Self>, ServiceError> {
let path = lock_path(name)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(path)?;
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
Ok(Some(Self { _file: file }))
} else {
Ok(None)
}
}
#[cfg(not(unix))]
fn acquire(_name: &str) -> Result<Option<Self>, ServiceError> {
Ok(None)
}
}
#[cfg(unix)]
use std::os::fd::AsRawFd;
#[cfg(unix)]
use std::os::unix::process::CommandExt;
fn wait_healthy(name: &str, cfg: &ServiceConfig, pid: i32) -> Result<(), ServiceError> {
let deadline = Instant::now() + Duration::from_secs(cfg.start_timeout_secs.max(1));
loop {
if service_http_ready(cfg) {
return Ok(());
}
if !proc_exists(pid) {
return Err(ServiceError::DiedDuringStart {
name: name.to_string(),
log: log_path(name)?,
});
}
if Instant::now() >= deadline {
return Err(ServiceError::StartTimeout {
name: name.to_string(),
endpoint: cfg.base_url(),
timeout_secs: cfg.start_timeout_secs,
log: log_path(name)?,
});
}
std::thread::sleep(HEALTH_POLL_INTERVAL);
}
}
#[cfg(unix)]
fn terminate_group(pid: i32) {
let pgid = unsafe { libc::getpgid(pid) };
if pgid == pid {
unsafe { libc::kill(-pid, libc::SIGTERM) };
} else {
unsafe { libc::kill(pid, libc::SIGTERM) };
}
}
#[cfg(unix)]
fn kill_group(pid: i32) {
let pgid = unsafe { libc::getpgid(pid) };
if pgid == pid {
unsafe { libc::kill(-pid, libc::SIGKILL) };
} else {
unsafe { libc::kill(pid, libc::SIGKILL) };
}
}
#[cfg(not(unix))]
fn terminate_group(_pid: i32) {}
#[cfg(not(unix))]
fn kill_group(_pid: i32) {}
fn wait_exit(pid: i32, grace: Duration) -> bool {
let deadline = Instant::now() + grace;
while Instant::now() < deadline {
if !proc_exists(pid) {
return true;
}
std::thread::sleep(STOP_POLL_INTERVAL);
}
!proc_exists(pid)
}
fn require_managed_platform() -> Result<(), ServiceError> {
if cfg!(any(target_os = "linux", target_os = "macos")) {
Ok(())
} else {
Err(ServiceError::UnsupportedPlatform)
}
}
pub fn ensure_running(name: &str, cfg: &ServiceConfig) -> Result<bool, ServiceError> {
if !cfg.auto_start || cfg.model.is_none() {
return Ok(false);
}
require_managed_platform()?;
if service_http_ready(cfg) {
return Ok(false);
}
if service_port_open(cfg) {
return Ok(wait_ready(cfg, cfg.start_timeout_secs));
}
clean_stale_pidfile(name, cfg)?;
match StartGuard::acquire(name)? {
Some(_guard) => start_locked(name, cfg).map(|_| true),
None => {
if wait_ready(cfg, cfg.start_timeout_secs) {
Ok(false)
} else {
Err(ServiceError::Concurrent(name.to_string()))
}
}
}
}
pub fn start(name: &str, cfg: &ServiceConfig) -> Result<bool, ServiceError> {
require_managed_platform()?;
if service_http_ready(cfg) {
return Ok(false);
}
if service_port_open(cfg) {
if !wait_ready(cfg, cfg.start_timeout_secs) {
return Err(ServiceError::StartTimeout {
name: name.to_string(),
endpoint: cfg.base_url(),
timeout_secs: cfg.start_timeout_secs,
log: log_path(name)?,
});
}
return Ok(false);
}
clean_stale_pidfile(name, cfg)?;
let Some(_guard) = StartGuard::acquire(name)? else {
return Err(ServiceError::Concurrent(name.to_string()));
};
start_locked(name, cfg).map(|_| true)
}
fn start_locked(name: &str, cfg: &ServiceConfig) -> Result<(), ServiceError> {
if cfg.model.is_none() {
return Err(ServiceError::NoModel(name.to_string()));
}
let pid = spawn_detached(name, cfg)?;
write_pidfile(name, pid)?;
if let Err(e) = wait_healthy(name, cfg, pid) {
if proc_exists(pid) {
cleanup_failed_start(pid);
}
remove_pidfile(name);
return Err(e);
}
Ok(())
}
#[cfg(unix)]
fn cleanup_failed_start(pid: i32) {
terminate_group(pid);
if !wait_exit(pid, STOP_GRACE) {
kill_group(pid);
let _ = wait_exit(pid, STOP_GRACE);
}
}
#[cfg(not(unix))]
fn cleanup_failed_start(_pid: i32) {}
pub fn stop(name: &str, cfg: &ServiceConfig) -> Result<StopOutcome, ServiceError> {
require_managed_platform()?;
let Some(pid) = read_pidfile(name)? else {
return Ok(StopOutcome::AlreadyDown);
};
if !proc_exists(pid) {
remove_pidfile(name);
return Ok(StopOutcome::StalePidfileRemoved);
}
if !pid_is_ours(pid, cfg) {
return Err(ServiceError::NotOurs(name.to_string(), pid));
}
terminate_group(pid);
if !wait_exit(pid, STOP_GRACE) {
kill_group(pid);
let _ = wait_exit(pid, STOP_GRACE);
}
remove_pidfile(name);
Ok(StopOutcome::Stopped(pid))
}
pub fn status(name: &str, cfg: &ServiceConfig) -> Result<ServiceStatus, ServiceError> {
clean_stale_pidfile(name, cfg)?;
let pid = read_pidfile(name)?.filter(|pid| pid_is_ours(*pid, cfg));
Ok(ServiceStatus {
name: name.to_string(),
pid,
port_open: service_port_open(cfg),
http_ready: service_http_ready(cfg),
endpoint: cfg.base_url(),
model: cfg.model.clone(),
served_model: cfg.served_name(),
auto_start: cfg.auto_start,
})
}
pub fn restart(name: &str, cfg: &ServiceConfig) -> Result<bool, ServiceError> {
stop(name, cfg)?;
start(name, cfg)
}
#[cfg(test)]
mod test {
use super::*;
struct HomeGuard {
_lock: std::sync::MutexGuard<'static, ()>,
home: PathBuf,
}
impl HomeGuard {
fn new() -> Self {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
let lock = LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner()); let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
unsafe { std::env::set_var(crate::config::LEINDEX_HOME_ENV, &home) };
std::mem::forget(tmp); Self { _lock: lock, home }
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(crate::config::LEINDEX_HOME_ENV) };
}
}
#[cfg(unix)]
fn fake_server(home: &std::path::Path, port: u16) -> ServiceConfig {
let script = home.join("fake-server.sh");
std::fs::write(&script, "#!/bin/sh\nsleep 300\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
ServiceConfig {
binary: script.display().to_string(),
model: Some("/models/fake.gguf".to_string()),
alias: None,
host: "127.0.0.1".to_string(),
port,
extra_args: Vec::new(),
auto_start: true,
start_timeout_secs: 2,
}
}
fn sh_service(port: u16) -> ServiceConfig {
ServiceConfig {
binary: "/bin/sh".to_string(),
model: None,
alias: None,
host: "127.0.0.1".to_string(),
port,
extra_args: Vec::new(),
auto_start: false,
start_timeout_secs: 2,
}
}
#[test]
fn test_parse_macos_procargs_preserves_spaces_and_excludes_environment() {
let mut bytes = 4i32.to_ne_bytes().to_vec();
bytes.extend_from_slice(
b"/usr/bin/server\0\0\0server\0-m\0/models/my model.gguf\0\0PORT=9000\0",
);
assert_eq!(
parse_macos_procargs(&bytes).unwrap(),
["server", "-m", "/models/my model.gguf", ""]
);
bytes[..4].copy_from_slice(&(-1i32).to_ne_bytes());
assert!(parse_macos_procargs(&bytes).is_none());
assert!(parse_macos_procargs(b"bad").is_none());
}
#[test]
fn test_command_matches_requires_binary_and_flag_values() {
let mut cfg = sh_service(9000);
cfg.binary = "/managed/llama-server".to_string();
cfg.model = Some("/models/model.gguf".to_string());
let args: Vec<String> = [
"/managed/llama-server",
"-m",
"/models/model.gguf",
"--host",
"127.0.0.1",
"--port",
"9000",
]
.into_iter()
.map(String::from)
.collect();
assert!(command_matches(&args, &cfg));
let mut foreign = args.clone();
foreign[0] = "/other/llama-server".to_string();
assert!(!command_matches(&foreign, &cfg));
foreign = args.clone();
foreign[3] = "--alias".to_string();
assert!(
!command_matches(&foreign, &cfg),
"host must be a host argument"
);
foreign = args.clone();
foreign[6] = "9001".to_string();
assert!(!command_matches(&foreign, &cfg));
foreign = args.clone();
foreign[2] = "/models/other.gguf".to_string();
assert!(!command_matches(&foreign, &cfg));
cfg.binary = "llama-server".to_string();
assert!(command_matches(&args, &cfg));
}
#[test]
fn test_status_distinguishes_tcp_from_http_readiness() {
use std::io::Write;
let _home = HomeGuard::new();
for code in [200, 503, 404, 2000] {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
drop(listener.accept().unwrap());
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 128];
let _ = stream.read(&mut request);
write!(stream, "HTTP/1.1 {code} Test\r\n\r\n").unwrap();
});
let st = status("http-status", &sh_service(port)).unwrap();
server.join().unwrap();
assert!(st.port_open);
assert_eq!(st.http_ready, code == 200);
}
}
#[cfg(unix)]
#[test]
fn test_status_preserves_live_unverified_pidfile() {
let _home = HomeGuard::new();
let pid = std::process::id() as i32;
write_pidfile("unverified", pid).unwrap();
let st = status("unverified", &sh_service(9)).unwrap();
assert_eq!(st.pid, None);
assert_eq!(read_pidfile("unverified").unwrap(), Some(pid));
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
#[test]
fn test_unsupported_lifecycle_preserves_pidfile() {
let _home = HomeGuard::new();
let mut cfg = sh_service(9);
cfg.auto_start = true;
cfg.model = Some("model.gguf".to_string());
write_pidfile("unsupported", 123).unwrap();
assert!(matches!(
start("unsupported", &cfg),
Err(ServiceError::UnsupportedPlatform)
));
assert!(matches!(
ensure_running("unsupported", &cfg),
Err(ServiceError::UnsupportedPlatform)
));
assert!(matches!(
stop("unsupported", &cfg),
Err(ServiceError::UnsupportedPlatform)
));
assert_eq!(read_pidfile("unsupported").unwrap(), Some(123));
}
#[test]
fn test_http_ready_accepts_fragmented_status_line() {
use std::io::Write;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 128];
let _ = stream.read(&mut request);
stream.write_all(b"HTTP/1.").unwrap();
std::thread::sleep(Duration::from_millis(30));
let _ = stream.write_all(b"1 200 OK\r\n\r\n");
});
let ready = http_ready("127.0.0.1", port);
server.join().unwrap();
assert!(ready, "a fragmented HTTP 200 status must be ready");
}
#[test]
fn test_http_ready_bounds_slow_status_line() {
use std::io::Write;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0; 128];
let _ = stream.read(&mut request);
for byte in b"HTTP/1.1 200 OK\r\n" {
if stream.write_all(&[*byte]).is_err() {
break;
}
std::thread::sleep(Duration::from_millis(80));
}
});
let began = Instant::now();
let ready = http_ready("127.0.0.1", port);
let elapsed = began.elapsed();
server.join().unwrap();
assert!(!ready);
assert!(elapsed < Duration::from_secs(1), "probe took {elapsed:?}");
}
#[cfg(unix)]
#[test]
fn test_wait_exit_reports_live_process_after_grace() {
let mut child = Command::new("/bin/sh")
.args(["-c", "sleep 300"])
.spawn()
.unwrap();
let pid = child.id() as i32;
let exited = wait_exit(pid, Duration::ZERO);
child.kill().unwrap();
child.wait().unwrap();
assert!(!exited, "a live process must not count as exited");
}
#[test]
fn test_ensure_running_skips_when_port_answers() {
let _home = HomeGuard::new();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let mut cfg = sh_service(port);
cfg.binary = "/nonexistent/fake-server".to_string();
cfg.auto_start = true;
assert!(!ensure_running("x", &cfg).unwrap());
}
#[test]
fn test_ensure_running_requires_auto_start_and_model() {
let _home = HomeGuard::new();
let mut cfg = sh_service(1); cfg.auto_start = false;
assert!(!ensure_running("x", &cfg).unwrap());
cfg.auto_start = true;
assert!(!ensure_running("x", &cfg).unwrap());
}
#[cfg(unix)]
#[test]
fn test_spawn_pidfile_stop_lifecycle() {
let home = HomeGuard::new();
let cfg = fake_server(&home.home, 9); let pid = spawn_detached("life", &cfg).unwrap();
write_pidfile("life", pid).unwrap();
assert!(proc_exists(pid));
let mut ours = false;
for _ in 0..200 {
if pid_is_ours(pid, &cfg) {
ours = true;
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(ours, "child argv never matched the configured binary");
let st = status("life", &cfg).unwrap();
assert_eq!(st.pid, Some(pid));
assert!(!st.port_open);
match stop("life", &cfg).unwrap() {
StopOutcome::Stopped(p) => assert_eq!(p, pid),
other => panic!("expected Stopped, got {other:?}"),
}
assert!(!proc_exists(pid));
assert_eq!(read_pidfile("life").unwrap(), None);
}
#[cfg(unix)]
#[test]
fn test_stop_refuses_foreign_pid() {
let _home = HomeGuard::new();
let cfg = sh_service(9);
write_pidfile("mine", std::process::id() as i32).unwrap();
match stop("mine", &cfg) {
Err(ServiceError::NotOurs(name, pid)) => {
assert_eq!(name, "mine");
assert_eq!(pid, std::process::id() as i32);
}
other => panic!("expected NotOurs, got {other:?}"),
}
assert!(proc_exists(std::process::id() as i32));
}
#[cfg(unix)]
#[test]
fn test_stale_pidfile_cleaned_by_status() {
let _home = HomeGuard::new();
let cfg = sh_service(9);
let mut child = Command::new("/bin/sh")
.args(["-c", "exit 0"])
.spawn()
.unwrap();
let dead_pid = child.id() as i32;
let _ = child.wait();
assert!(!proc_exists(dead_pid));
write_pidfile("stale", dead_pid).unwrap();
let st = status("stale", &cfg).unwrap();
assert_eq!(st.pid, None, "dead pid must not report as running");
assert_eq!(read_pidfile("stale").unwrap(), None, "pidfile must be gone");
}
#[cfg(unix)]
#[test]
fn test_start_times_out_and_cleans_up() {
let home = HomeGuard::new();
let cfg = fake_server(&home.home, 9);
let err = start("t", &cfg).unwrap_err();
assert!(
matches!(err, ServiceError::StartTimeout { .. }),
"got {err:?}"
);
assert_eq!(read_pidfile("t").unwrap(), None);
}
}