From c066b2c82a1434011d646d5e4536be542dbea550 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 7 Sep 2026 23:55:36 -0400 Subject: [PATCH] feat(desktop): #77 B5 port auto-increment si 17890 occupe (pick_free_port) + 3 tests Rust --- desktop/src/main.rs | 77 ++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 8 deletions(-) diff --git a/desktop/src/main.rs b/desktop/src/main.rs index 87c751d..d26a923 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -9,11 +9,40 @@ use log::{error, info, warn}; use serde::{Deserialize, Serialize}; use std::fs::{self, OpenOptions}; use std::io::Write; +use std::net::TcpListener; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; +use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::Mutex; use std::time::Duration; +// ── Backend port (ROADMAP #77 B: auto-increment if 17890 is busy) ──── + +const DEFAULT_BACKEND_PORT: u16 = 17890; +const PORT_SCAN_ATTEMPTS: u16 = 10; + +static BACKEND_PORT: AtomicU16 = AtomicU16::new(DEFAULT_BACKEND_PORT); + +fn backend_url() -> String { + format!("http://127.0.0.1:{}", BACKEND_PORT.load(Ordering::Relaxed)) +} + +/// First free TCP port starting at DEFAULT_BACKEND_PORT. +fn pick_free_port() -> u16 { + for offset in 0..PORT_SCAN_ATTEMPTS { + let port = DEFAULT_BACKEND_PORT + offset; + if TcpListener::bind(("127.0.0.1", port)).is_ok() { + if offset > 0 { + info!("Port {} busy — using {} instead", DEFAULT_BACKEND_PORT, port); + } + return port; + } + } + warn!("No free port in {}..{}, falling back to default", + DEFAULT_BACKEND_PORT, DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS - 1); + DEFAULT_BACKEND_PORT +} + #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; use tauri::Manager; @@ -172,12 +201,15 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result { cmd.env(format!("DIR_{}_PATH", n), &dir.path); } + let port = pick_free_port(); + BACKEND_PORT.store(port, Ordering::Relaxed); + let child = cmd .args([ "-m", "uvicorn", "backend.main:app", "--host", "127.0.0.1", - "--port", "17890", + "--port", &port.to_string(), "--log-level", "info", ]) .stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?)) @@ -191,10 +223,10 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result { async fn wait_for_backend() -> Result<(), String> { let client = reqwest::Client::new(); - let url = "http://127.0.0.1:17890/api/health"; + let url = format!("{}/api/health", backend_url()); for i in 0..30 { - match client.get(url).timeout(Duration::from_secs(2)).send().await { + match client.get(&url).timeout(Duration::from_secs(2)).send().await { Ok(resp) if resp.status().is_success() => { info!("Backend ready (attempt {})", i + 1); return Ok(()); @@ -235,7 +267,7 @@ fn kill_backend(child: &mut Child) { #[tauri::command] fn get_backend_url() -> String { - "http://127.0.0.1:17890".to_string() + backend_url() } #[tauri::command] @@ -390,7 +422,7 @@ async fn check_backend_health(state: tauri::State<'_, BackendProcess>) -> Result } // Health check HTTP - match reqwest::get("http://127.0.0.1:17890/api/health").await { + match reqwest::get(format!("{}/api/health", backend_url())).await { Ok(resp) if resp.status().is_success() => Ok("healthy".to_string()), _ => Ok("unhealthy".to_string()), } @@ -570,7 +602,7 @@ fn main() { let _ = window.eval( r#"if (window.__setBootStatus) window.__setBootStatus("Backend prêt — chargement de l'interface…")"#, ); - match window.eval("window.location.href = 'http://127.0.0.1:17890'") { + match window.eval(&format!("window.location.href = '{}'", backend_url())) { Ok(_) => info!("Redirect to backend OK"), Err(e) => error!("Redirect eval failed: {}", e), } @@ -580,8 +612,9 @@ fn main() { error!("Backend failed: {}", e); if let Some(window) = handle.get_webview_window("main") { let _ = window.eval(&format!( - r#"document.body.innerHTML = '

ObsiGate n\'a pas pu démarrer

{}

Backend: http://127.0.0.1:17890 | Logs: {}

'"#, + r#"document.body.innerHTML = '

ObsiGate n\'a pas pu démarrer

{}

Backend: {} | Logs: {}

'"#, e.replace('\'', "\\'").replace('"', "\\\""), + backend_url(), backend_log_path().display().to_string().replace('\\', "\\\\"), )); } @@ -934,7 +967,35 @@ mod tests { #[test] fn test_backend_url() { - assert_eq!(get_backend_url(), "http://127.0.0.1:17890"); + let port = BACKEND_PORT.load(Ordering::Relaxed); + assert_eq!(get_backend_url(), format!("http://127.0.0.1:{}", port)); + } + + #[test] + fn test_pick_free_port_is_free() { + let port = pick_free_port(); + assert!((DEFAULT_BACKEND_PORT..DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS).contains(&port)); + assert!(TcpListener::bind(("127.0.0.1", port)).is_ok()); + } + + #[test] + fn test_pick_free_port_skips_busy_port() { + let blocker = match TcpListener::bind(("127.0.0.1", DEFAULT_BACKEND_PORT)) { + Ok(l) => l, + Err(_) => return, // default port already busy on this machine — skip + }; + let port = pick_free_port(); + assert_ne!(port, DEFAULT_BACKEND_PORT); + assert!(port < DEFAULT_BACKEND_PORT + PORT_SCAN_ATTEMPTS); + drop(blocker); + } + + #[test] + fn test_backend_url_reflects_port() { + let saved = BACKEND_PORT.load(Ordering::Relaxed); + BACKEND_PORT.store(17893, Ordering::Relaxed); + assert_eq!(backend_url(), "http://127.0.0.1:17893"); + BACKEND_PORT.store(saved, Ordering::Relaxed); } #[test]