diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index 27569de4..66f373c3 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -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 3100–3200. Stop unused containers and retry.".to_string(); + }; + let Some(ssh_port) = find_free_port(2200) else { + return "No free SSH port in range 2200–2300. 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 3100–3200. 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 2200–2300. 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 { + (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 { + 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();