feat(desktop): #77 B5 port auto-increment si 17890 occupe (pick_free_port) + 3 tests Rust

This commit is contained in:
2026-09-07 23:55:36 -04:00
parent ab795ec9e0
commit c066b2c82a
+69 -8
View File
@@ -9,11 +9,40 @@ use log::{error, info, warn};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::{self, OpenOptions}; use std::fs::{self, OpenOptions};
use std::io::Write; use std::io::Write;
use std::net::TcpListener;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::{Child, Command, Stdio}; use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU16, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::Duration; 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")] #[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt; use std::os::windows::process::CommandExt;
use tauri::Manager; use tauri::Manager;
@@ -172,12 +201,15 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
cmd.env(format!("DIR_{}_PATH", n), &dir.path); cmd.env(format!("DIR_{}_PATH", n), &dir.path);
} }
let port = pick_free_port();
BACKEND_PORT.store(port, Ordering::Relaxed);
let child = cmd let child = cmd
.args([ .args([
"-m", "uvicorn", "-m", "uvicorn",
"backend.main:app", "backend.main:app",
"--host", "127.0.0.1", "--host", "127.0.0.1",
"--port", "17890", "--port", &port.to_string(),
"--log-level", "info", "--log-level", "info",
]) ])
.stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?)) .stdout(Stdio::from(log.try_clone().map_err(|e| format!("{}", e))?))
@@ -191,10 +223,10 @@ fn spawn_backend(exe_dir: &PathBuf) -> Result<Child, String> {
async fn wait_for_backend() -> Result<(), String> { async fn wait_for_backend() -> Result<(), String> {
let client = reqwest::Client::new(); 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 { 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() => { Ok(resp) if resp.status().is_success() => {
info!("Backend ready (attempt {})", i + 1); info!("Backend ready (attempt {})", i + 1);
return Ok(()); return Ok(());
@@ -235,7 +267,7 @@ fn kill_backend(child: &mut Child) {
#[tauri::command] #[tauri::command]
fn get_backend_url() -> String { fn get_backend_url() -> String {
"http://127.0.0.1:17890".to_string() backend_url()
} }
#[tauri::command] #[tauri::command]
@@ -390,7 +422,7 @@ async fn check_backend_health(state: tauri::State<'_, BackendProcess>) -> Result
} }
// Health check HTTP // 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(resp) if resp.status().is_success() => Ok("healthy".to_string()),
_ => Ok("unhealthy".to_string()), _ => Ok("unhealthy".to_string()),
} }
@@ -570,7 +602,7 @@ fn main() {
let _ = window.eval( let _ = window.eval(
r#"if (window.__setBootStatus) window.__setBootStatus("Backend prêt — chargement de l'interface…")"#, 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"), Ok(_) => info!("Redirect to backend OK"),
Err(e) => error!("Redirect eval failed: {}", e), Err(e) => error!("Redirect eval failed: {}", e),
} }
@@ -580,8 +612,9 @@ fn main() {
error!("Backend failed: {}", e); error!("Backend failed: {}", e);
if let Some(window) = handle.get_webview_window("main") { if let Some(window) = handle.get_webview_window("main") {
let _ = window.eval(&format!( let _ = window.eval(&format!(
r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: http://127.0.0.1:17890 | Logs: {}</p></div>'"#, r#"document.body.innerHTML = '<div style="padding:40px;text-align:center;font-family:sans-serif"><h2 style="color:#c0392b">ObsiGate n\'a pas pu démarrer</h2><p style="color:#666">{}</p><p><button onclick="location.reload()" style="padding:10px 20px;font-size:16px;cursor:pointer;background:#7C3AED;color:white;border:none;border-radius:6px">Réessayer</button></p><p style="margin-top:20px;font-size:12px;color:#999">Backend: {} | Logs: {}</p></div>'"#,
e.replace('\'', "\\'").replace('"', "\\\""), e.replace('\'', "\\'").replace('"', "\\\""),
backend_url(),
backend_log_path().display().to_string().replace('\\', "\\\\"), backend_log_path().display().to_string().replace('\\', "\\\\"),
)); ));
} }
@@ -934,7 +967,35 @@ mod tests {
#[test] #[test]
fn test_backend_url() { 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] #[test]