synth-ai-core 0.3.0

Rust core for Synth AI SDKs - API client, streaming, tracing, and tunnels
Documentation
use std::net::TcpListener;
use std::process::Command;
use std::thread;
use std::time::Duration;

use crate::tunnels::errors::TunnelError;

#[derive(Debug, Clone, Copy)]
pub enum PortConflictBehavior {
    Fail,
    Evict,
    FindNew,
}

pub fn is_port_available(port: u16, host: &str) -> bool {
    let hosts = if host == "0.0.0.0" {
        vec!["0.0.0.0", "127.0.0.1"]
    } else {
        vec![host]
    };
    for h in hosts {
        let addr = format!("{h}:{port}");
        if TcpListener::bind(addr).is_err() {
            return false;
        }
    }
    true
}

pub fn find_available_port(
    start_port: u16,
    host: &str,
    max_attempts: u16,
) -> Result<u16, TunnelError> {
    for offset in 0..max_attempts {
        let port = start_port.saturating_add(offset);
        if is_port_available(port, host) {
            return Ok(port);
        }
    }
    Err(TunnelError::config(format!(
        "no available port found starting from {start_port}"
    )))
}

pub fn kill_port(port: u16) -> Result<bool, TunnelError> {
    if is_port_available(port, "0.0.0.0") {
        return Ok(false);
    }
    if cfg!(target_os = "windows") {
        let output = Command::new("netstat")
            .arg("-ano")
            .output()
            .map_err(|e| TunnelError::process(format!("netstat failed: {e}")))?;
        let stdout = String::from_utf8_lossy(&output.stdout);
        for line in stdout.lines() {
            if line.contains(&format!(":{port}")) && line.contains("LISTENING") {
                if let Some(pid) = line.split_whitespace().last() {
                    let _ = Command::new("taskkill").args(["/F", "/PID", pid]).output();
                    return Ok(true);
                }
            }
        }
        return Ok(false);
    }

    let output = Command::new("lsof")
        .args(["-ti", &format!(":{port}")])
        .output()
        .map_err(|e| TunnelError::process(format!("lsof failed: {e}")))?;
    let stdout = String::from_utf8_lossy(&output.stdout);
    if stdout.trim().is_empty() {
        return Ok(false);
    }
    for pid in stdout.split_whitespace() {
        let _ = Command::new("kill").args(["-9", pid]).output();
    }
    Ok(true)
}

pub fn acquire_port(
    port: u16,
    on_conflict: PortConflictBehavior,
    host: &str,
    max_search: u16,
) -> Result<u16, TunnelError> {
    if is_port_available(port, host) {
        return Ok(port);
    }
    match on_conflict {
        PortConflictBehavior::Fail => Err(TunnelError::config(format!(
            "port {port} is already in use"
        ))),
        PortConflictBehavior::Evict => {
            let killed = kill_port(port)?;
            if killed {
                thread::sleep(Duration::from_millis(500));
                if is_port_available(port, host) {
                    return Ok(port);
                }
            }
            Err(TunnelError::process(format!(
                "failed to evict process on port {port}"
            )))
        }
        PortConflictBehavior::FindNew => find_available_port(port, host, max_search),
    }
}