use std::net::TcpListener;
use crate::error::BrowserError;
pub(crate) async fn find_free_port_near(
host: &str,
base: u16,
tries: u16,
) -> Result<u16, BrowserError> {
let host_owned = host.to_string();
tokio::task::spawn_blocking(move || find_free_port_near_blocking(&host_owned, base, tries))
.await
.map_err(|e| {
BrowserError::Io(std::io::Error::other(format!(
"find_free_port_near task panicked: {e}"
)))
})?
}
fn find_free_port_near_blocking(host: &str, base: u16, tries: u16) -> Result<u16, BrowserError> {
for offset in 0..tries {
let candidate = base.wrapping_add(offset);
if let Ok(listener) = TcpListener::bind((host, candidate)) {
drop(listener);
return Ok(candidate);
}
}
Err(BrowserError::PortConflict { port: base })
}
#[cfg(test)]
mod tests {
use super::*;
fn pick_ephemeral_port() -> u16 {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
port
}
#[cfg(unix)]
fn reserve_contiguous(count: u16) -> (u16, Vec<TcpListener>) {
for _ in 0..100 {
let first = TcpListener::bind("127.0.0.1:0").unwrap();
let base = first.local_addr().unwrap().port();
if base.checked_add(count).is_none() {
continue; }
let mut held = vec![first];
for offset in 1..count {
match TcpListener::bind(("127.0.0.1", base + offset)) {
Ok(listener) => held.push(listener),
Err(_) => break,
}
}
if held.len() == count as usize {
return (base, held);
}
}
panic!("could not reserve {count} contiguous free ports after 100 attempts");
}
#[cfg(unix)]
#[tokio::test]
async fn given_base_free_when_searching_then_returns_base() {
let base = pick_ephemeral_port();
let result = find_free_port_near("127.0.0.1", base, 5)
.await
.expect("base should be free");
assert_eq!(result, base);
}
#[cfg(unix)]
#[tokio::test]
async fn given_base_occupied_when_searching_then_returns_next_free() {
let (base, mut held) = reserve_contiguous(10);
held.truncate(3);
let result = find_free_port_near("127.0.0.1", base, 10)
.await
.expect("a port in range should be free");
assert!(
result >= base + 3,
"must skip occupied base..base+2, got {result} (base={base})"
);
assert!(
result < base + 10,
"must not exceed the search range, got {result} (base={base}, tries=10)"
);
assert_ne!(result, base, "must not return the occupied base");
}
#[cfg(unix)]
#[tokio::test]
async fn given_no_free_port_in_range_when_searching_then_port_conflict() {
let (base, _occupy) = reserve_contiguous(10);
let result = find_free_port_near("127.0.0.1", base, 10).await;
match result {
Err(BrowserError::PortConflict { port }) => {
assert_eq!(port, base, "PortConflict must carry the requested base");
}
other => panic!("expected PortConflict, got {other:?}"),
}
}
#[tokio::test]
async fn given_tries_zero_when_searching_then_port_conflict() {
let base = pick_ephemeral_port();
let result = find_free_port_near("127.0.0.1", base, 0).await;
assert!(
matches!(result, Err(BrowserError::PortConflict { port }) if port == base),
"tries=0 must yield PortConflict immediately, got {result:?}"
);
}
}