huskies: merge 1129 story find_free_port fallback returns unbindable port silently when range is exhausted

This commit is contained in:
dave
2026-05-17 19:19:15 +00:00
parent e2ea1af4c8
commit d8204ab7ed
+41 -15
View File
@@ -431,8 +431,13 @@ async fn handle_adopt_project(
}
// ── Allocate ports and launch container ──────────────────────────────────
let port = find_free_port(3100);
let ssh_port = find_free_port(2200);
let Some(port) = find_free_port(3100) else {
return "No free port in range 31003200. Stop unused containers and retry.".to_string();
};
let Some(ssh_port) = find_free_port(2200) else {
return "No free SSH port in range 22002300. Stop unused containers and retry."
.to_string();
};
let container_url = format!("http://127.0.0.1:{port}");
let container_name = format!("huskies-{name}");
@@ -772,8 +777,17 @@ pub async fn handle_new_project(
// ── Allocate ports and launch container ──────────────────────────────────
let port = find_free_port(3100);
let ssh_port = find_free_port(2200);
let Some(port) = find_free_port(3100) else {
let _ = tokio::fs::remove_dir_all(&host_path).await;
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
return "No free port in range 31003200. Stop unused containers and retry.".to_string();
};
let Some(ssh_port) = find_free_port(2200) else {
let _ = tokio::fs::remove_dir_all(&host_path).await;
let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await;
return "No free SSH port in range 22002300. Stop unused containers and retry."
.to_string();
};
let container_url = format!("http://127.0.0.1:{port}");
let container_name = format!("huskies-{name}");
@@ -924,17 +938,21 @@ fn interpret_docker_run_error(stderr: &str, image: &str) -> String {
}
}
/// Scan `start..start+range` for a bindable TCP port on 127.0.0.1.
///
/// Returns `Some(port)` for the first port that can be bound, or `None` if all
/// ports in the range are occupied.
fn find_free_port_in_range(start: u16, range: u16) -> Option<u16> {
(start..start.saturating_add(range))
.find(|&port| std::net::TcpListener::bind(("127.0.0.1", port)).is_ok())
}
/// Find a free TCP port by attempting to bind starting from `start`.
///
/// Scans up to 100 ports above `start` and returns the first available one.
/// Falls back to `start` if none are found (unlikely in practice).
fn find_free_port(start: u16) -> u16 {
for port in start..start + 100 {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
start
/// Scans up to 100 ports above `start` and returns `Some(port)` for the first
/// bindable one, or `None` when the entire range `start..start+100` is exhausted.
fn find_free_port(start: u16) -> Option<u16> {
find_free_port_in_range(start, 100)
}
// ---------------------------------------------------------------------------
@@ -1148,13 +1166,21 @@ mod tests {
#[test]
fn find_free_port_returns_bindable_port() {
let port = find_free_port(2200);
// The returned port must be in range and actually bindable.
let port = find_free_port(2200).expect("expected Some(port) in range 2200..2300");
assert!((2200..2300).contains(&port));
let listener = std::net::TcpListener::bind(("127.0.0.1", port));
assert!(listener.is_ok(), "returned port {port} is not bindable");
}
#[test]
fn find_free_port_exhausted_range_returns_none() {
// Bind the single port in a 1-wide scan window, then verify None is returned.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
// The range [port, port+1) contains only `port`, which is already bound.
assert_eq!(find_free_port_in_range(port, 1), None);
}
#[test]
fn detect_stack_go_mod() {
let dir = tempfile::tempdir().unwrap();