use std::time::Duration;
const PROBE_TIMEOUT: Duration = Duration::from_millis(300);
const STAGE_TIMEOUT: Duration = Duration::from_secs(20);
pub fn split_host_port(value: &str, default_port: u16) -> (String, u16) {
if let Some(rest) = value.strip_prefix('[')
&& let Some((host, tail)) = rest.split_once(']')
{
let port = tail
.strip_prefix(':')
.and_then(|p| p.parse().ok())
.unwrap_or(default_port);
return (host.to_string(), port);
}
match value.rsplit_once(':') {
Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) && !host.is_empty() => {
(host.to_string(), port.parse().unwrap_or(default_port))
}
_ => (value.to_string(), default_port),
}
}
pub async fn stage_timeout<T, E>(
label: &str,
future: impl std::future::Future<Output = Result<T, E>>,
) -> Result<T, String>
where
E: std::fmt::Display,
{
match tokio::time::timeout(STAGE_TIMEOUT, future).await {
Ok(Ok(v)) => Ok(v),
Ok(Err(e)) => Err(format!("{label}: {e}")),
Err(_) => Err(format!(
"{label}: timed out after {} seconds",
STAGE_TIMEOUT.as_secs()
)),
}
}
pub fn normalize_listen(raw: &str) -> Result<String, String> {
let s = raw.trim();
if s.is_empty() {
return Err("listen address is empty (expected host:port)".to_string());
}
if let Ok(addr) = s.parse::<std::net::SocketAddr>() {
if addr.port() == 0 {
return Err(port_zero_error());
}
return Ok(addr.to_string());
}
let Some((host, port)) = s.rsplit_once(':') else {
return Err(format!(
"invalid listen address \"{s}\": expected host:port"
));
};
let host = host.trim();
if host.is_empty() {
return Err(format!("invalid listen address \"{s}\": host is empty"));
}
let port: u16 = port
.trim()
.parse()
.map_err(|_| format!("invalid listen address \"{s}\": port must be 1-65535"))?;
if port == 0 {
return Err(port_zero_error());
}
Ok(format!("{host}:{port}"))
}
fn port_zero_error() -> String {
"port 0 is not allowed (the kernel would pick a different port than the one shown)".to_string()
}
pub fn base_url(listen: &str) -> String {
let (host, port) = match listen.rsplit_once(':') {
Some((host, port)) => (host, port),
None => (listen, ""),
};
let host = match host {
"0.0.0.0" | "*" => "127.0.0.1",
"[::]" | "::" => "[::1]",
h => h,
};
format!("http://{host}:{port}/v1")
}
pub fn is_loopback_host(host: &str) -> bool {
let trimmed = host
.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.to_ascii_lowercase();
matches!(
trimmed.as_str(),
"localhost" | "127.0.0.1" | "::1" | "0:0:0:0:0:0:0:1"
)
}
pub fn url_is_loopback(url: &str) -> bool {
url::Url::parse(url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.is_some_and(|host| is_loopback_host(&host))
}
pub fn pick_free_port() -> Result<u16, String> {
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))
.map_err(|e| format!("cannot find a free loopback port: {e}"))?;
listener
.local_addr()
.map(|addr| addr.port())
.map_err(|e| format!("cannot read the port the kernel assigned: {e}"))
}
pub async fn is_port_listening(host: &str, port: u16) -> bool {
matches!(
tokio::time::timeout(PROBE_TIMEOUT, tokio::net::TcpStream::connect((host, port))).await,
Ok(Ok(_))
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
fn loopback_host_detection() {
for host in [
"localhost",
"LOCALHOST",
"127.0.0.1",
"::1",
"[::1]",
"0:0:0:0:0:0:0:1",
] {
assert!(is_loopback_host(host), "{host} 应判为回环");
}
for host in ["", "example.com", "10.0.0.1", "0.0.0.0", "127.0.0.2"] {
assert!(!is_loopback_host(host), "{host} 不应判为回环");
}
}
#[test]
fn url_loopback_detection() {
assert!(url_is_loopback("ws://127.0.0.1:9222/devtools/browser/x"));
assert!(url_is_loopback("ws://[::1]:9222/devtools/browser/x"));
assert!(!url_is_loopback("ws://example.com:9222/devtools/browser/x"));
assert!(!url_is_loopback("ws://10.0.0.5:9222/x"));
assert!(!url_is_loopback("not a url"));
}
#[test]
fn normalize_listen_accepts_host_port_forms() {
assert_eq!(
normalize_listen("127.0.0.1:8765").unwrap(),
"127.0.0.1:8765"
);
assert_eq!(normalize_listen(" 0.0.0.0:80 ").unwrap(), "0.0.0.0:80");
assert_eq!(
normalize_listen("localhost:8765").unwrap(),
"localhost:8765"
);
assert_eq!(normalize_listen("[::1]:8765").unwrap(), "[::1]:8765");
}
#[test]
fn splits_host_port() {
assert_eq!(split_host_port("gpu:2202", 22), ("gpu".into(), 2202));
assert_eq!(split_host_port("gpu", 22), ("gpu".into(), 22));
assert_eq!(split_host_port("[::1]:2222", 22), ("::1".into(), 2222));
assert_eq!(split_host_port("dash-name", 22), ("dash-name".into(), 22));
}
#[test]
fn base_url_derives_openai_compatible_endpoint() {
assert_eq!(base_url("127.0.0.1:8765"), "http://127.0.0.1:8765/v1");
assert_eq!(base_url("localhost:8765"), "http://localhost:8765/v1");
assert_eq!(base_url("[::1]:8765"), "http://[::1]:8765/v1");
assert_eq!(base_url("0.0.0.0:8765"), "http://127.0.0.1:8765/v1");
assert_eq!(base_url("[::]:8765"), "http://[::1]:8765/v1");
}
#[test]
fn normalize_listen_rejects_bad_forms() {
for bad in [
"",
"8765",
"127.0.0.1",
":8765",
"127.0.0.1:",
"127.0.0.1:0",
"127.0.0.1:99999",
] {
assert!(normalize_listen(bad).is_err(), "{bad} 应被拒绝");
}
}
#[test]
fn pick_free_port_returns_a_bindable_port() {
let port = pick_free_port().unwrap();
assert!(port > 0);
std::net::TcpListener::bind(("127.0.0.1", port)).unwrap();
}
#[tokio::test]
async fn is_port_listening_follows_the_socket() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
assert!(is_port_listening("127.0.0.1", port).await);
drop(listener);
let deadline = Instant::now() + Duration::from_secs(10);
while is_port_listening("127.0.0.1", port).await {
assert!(
Instant::now() < deadline,
"端口 {port} 已松开却仍显示在监听"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}