use std::net::TcpListener;
#[cfg(unix)]
use std::os::unix::process::CommandExt as _;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use crate::engine_client::EngineClient;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EngineProcessStatus {
NotStarted,
Starting,
Running,
Stopped,
External,
Failed,
}
pub struct EngineManager {
child: Option<Child>,
pub port: u16,
pub status: EngineProcessStatus,
restart_count: u8,
engine_dir: PathBuf,
project_path: Option<PathBuf>,
}
const MAX_RESTARTS: u8 = 3;
impl EngineManager {
pub fn new(workspace_root: &std::path::Path) -> Self {
Self {
child: None,
port: 0,
status: EngineProcessStatus::NotStarted,
restart_count: 0,
engine_dir: workspace_root.join("engine").join("core"),
project_path: None,
}
}
pub fn from_engine_dir(engine_dir: &std::path::Path) -> Self {
Self {
child: None,
port: 0,
status: EngineProcessStatus::NotStarted,
restart_count: 0,
engine_dir: engine_dir.to_path_buf(),
project_path: None,
}
}
pub fn with_project_path(mut self, path: &Path) -> Self {
self.project_path = Some(path.to_path_buf());
self
}
pub const fn external(port: u16) -> Self {
Self {
child: None,
port,
status: EngineProcessStatus::External,
restart_count: 0,
engine_dir: PathBuf::new(),
project_path: None,
}
}
pub fn start(&mut self) -> Result<u16, String> {
self.start_inner(None, false)
}
pub fn start_with_pid(&mut self, pid_path: &Path, watch: bool) -> Result<u16, String> {
self.start_inner(Some(pid_path), watch)
}
fn start_inner(&mut self, pid_path: Option<&Path>, watch: bool) -> Result<u16, String> {
let entry = self.engine_dir.join("src").join("server.ts");
if !entry.exists() {
return Err(format!("Engine not found at {}", entry.display()));
}
let port = find_preferred_port(crate::config::DEFAULT_ENGINE_PORT)
.map_err(|e| format!("Cannot find free port: {e}"))?;
self.port = port;
let mut cmd = Command::new("npx");
cmd.args(["tsx", "src/server.ts"])
.current_dir(&self.engine_dir)
.env("PORT", port.to_string())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
cmd.process_group(0);
if let Some(path) = pid_path {
cmd.env("COMPLIOR_PID_FILE", path.to_string_lossy().as_ref());
}
if let Some(ref pp) = self.project_path {
cmd.env("COMPLIOR_PROJECT_PATH", pp.to_string_lossy().as_ref());
}
if watch {
cmd.env("COMPLIOR_WATCH", "1");
}
let mut forwarded_keys: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let api_key_names = [
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
];
for key in &api_key_names {
if let Ok(val) = std::env::var(key) {
forwarded_keys.insert(key.to_string(), val);
}
}
if let Some(ref pp) = self.project_path {
let env_file = pp.join(".complior").join(".env");
if let Ok(content) = std::fs::read_to_string(&env_file) {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') { continue; }
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
let val = trimmed[eq_pos + 1..].trim().trim_matches(|c| c == '"' || c == '\'');
if api_key_names.contains(&key) {
forwarded_keys.insert(key.to_string(), val.to_string());
}
}
}
}
}
for (key, val) in &forwarded_keys {
cmd.env(key, val);
}
let child = cmd
.spawn()
.map_err(|e| format!("Failed to spawn engine: {e}"))?;
self.child = Some(child);
self.status = EngineProcessStatus::Starting;
Ok(port)
}
pub async fn wait_for_ready(&mut self, client: &EngineClient) -> bool {
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
if let Ok(status) = client.status().await
&& status.ready
{
self.status = EngineProcessStatus::Running;
return true;
}
}
false
}
pub fn is_alive(&mut self) -> bool {
if matches!(self.status, EngineProcessStatus::External) {
return true; }
if matches!(self.status, EngineProcessStatus::Failed) {
return false; }
if let Some(ref mut child) = self.child {
if matches!(child.try_wait(), Ok(None)) {
true } else {
self.status = EngineProcessStatus::Stopped;
false
}
} else {
false
}
}
pub fn try_restart(&mut self) -> Result<u16, String> {
if self.status == EngineProcessStatus::External {
return Err("Cannot restart external engine".to_string());
}
if self.restart_count >= MAX_RESTARTS {
self.status = EngineProcessStatus::Failed;
return Err(format!("Max restarts ({MAX_RESTARTS}) exceeded"));
}
self.restart_count += 1;
self.shutdown();
self.start()
}
pub fn shutdown(&mut self) {
if let Some(ref mut child) = self.child {
#[cfg(unix)]
{
let pid = child.id() as i32;
unsafe { libc::kill(-pid, libc::SIGTERM); }
std::thread::sleep(std::time::Duration::from_millis(200));
unsafe { libc::kill(-pid, libc::SIGKILL); }
}
#[cfg(not(unix))]
{
let _ = child.kill();
}
let _ = child.wait();
}
self.child = None;
if self.status != EngineProcessStatus::External {
self.status = EngineProcessStatus::Stopped;
}
}
pub fn engine_url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
}
impl Drop for EngineManager {
fn drop(&mut self) {
self.shutdown();
}
}
pub fn find_preferred_port(preferred: u16) -> std::io::Result<u16> {
if TcpListener::bind(format!("127.0.0.1:{preferred}")).is_ok() {
return Ok(preferred);
}
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Ok(port)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_engine_manager_creation() {
let mgr = EngineManager::new(std::path::Path::new("/tmp"));
assert_eq!(mgr.status, EngineProcessStatus::NotStarted);
assert_eq!(mgr.port, 0);
}
#[test]
fn test_engine_manager_external_mode() {
let mgr = EngineManager::external(4000);
assert_eq!(mgr.status, EngineProcessStatus::External);
assert_eq!(mgr.port, 4000);
assert_eq!(mgr.engine_url(), "http://127.0.0.1:4000");
}
#[test]
fn test_engine_url_construction() {
let mut mgr = EngineManager::new(std::path::Path::new("/tmp"));
mgr.port = 5555;
assert_eq!(mgr.engine_url(), "http://127.0.0.1:5555");
}
#[test]
fn test_max_restarts_exceeded() {
let mut mgr = EngineManager::new(std::path::Path::new("/tmp"));
mgr.restart_count = MAX_RESTARTS;
let result = mgr.try_restart();
assert!(result.is_err());
assert_eq!(mgr.status, EngineProcessStatus::Failed);
}
#[test]
fn test_external_mode_shutdown_is_safe() {
let mut mgr = EngineManager::external(3099);
mgr.shutdown();
assert_eq!(mgr.status, EngineProcessStatus::External);
}
#[test]
fn test_start_with_pid_missing_engine_returns_error() {
let mut mgr = EngineManager::new(std::path::Path::new("/tmp/nonexistent"));
let result = mgr.start_with_pid(std::path::Path::new("/tmp/test.pid"), true);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Engine not found"));
}
}