From 079c8828a6fbe3b88f3f8f06fcf0224c8e2932a9 Mon Sep 17 00:00:00 2001 From: "I. A. Naval" <790279+ianonavy@users.noreply.github.com> Date: Sat, 28 Jun 2025 18:18:20 -0400 Subject: [PATCH] Fix background process lifecycle --- backend/main.py | 2 +- src-tauri/src/lib.rs | 354 +++++++++++++++++++++++++++++++++++++- src-tauri/tauri.conf.json | 3 +- src/lib/backend.ts | 18 ++ 4 files changed, 367 insertions(+), 10 deletions(-) diff --git a/backend/main.py b/backend/main.py index bf9a5dd..2b17e87 100644 --- a/backend/main.py +++ b/backend/main.py @@ -21,7 +21,7 @@ def main(): os.makedirs(os.environ["WEBUI_UPLOAD_DIR"], exist_ok=True) os.makedirs(os.environ["WEBUI_CACHE_DIR"], exist_ok=True) - # Run the FastAPI app using uvicorn + # Run the FastAPI app - this will handle the command line args including "serve" app() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7697770..314913b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +use std::fs; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tauri::Manager; use tauri_plugin_shell::{process::CommandEvent, ShellExt}; @@ -7,6 +9,29 @@ use tauri_plugin_shell::{process::CommandEvent, ShellExt}; struct AppState { backend_port: Arc>>, backend_process: Arc>>, + backend_pid: Arc>>, +} + +// Generate or load a persistent secret key for Open WebUI +fn get_or_create_secret_key(config_dir: &PathBuf) -> Result> { + let secret_file = config_dir.join("webui_secret.txt"); + + // Try to read existing secret + if let Ok(secret) = fs::read_to_string(&secret_file) { + let secret = secret.trim(); + if !secret.is_empty() { + return Ok(secret.to_string()); + } + } + + // Generate a new secure secret key + let secret = generate_secret_key(); + + // Save the secret for future use + fs::create_dir_all(config_dir)?; + fs::write(&secret_file, &secret)?; + + Ok(secret) } // Generate a secure secret key for Open WebUI @@ -51,8 +76,21 @@ async fn start_backend(app: tauri::AppHandle) -> Result { } } - // Find an available port - let port = find_available_port().map_err(|e| e.to_string())?; + // Kill any orphaned backend processes first + let _ = kill_orphaned_backends(); // Ignore errors, just try to clean up + + // Find a persistent port (reuse existing or create new) + let app_config_dir = app + .path() + .app_config_dir() + .map_err(|e| format!("Failed to get app config directory: {}", e))?; + + let port = get_or_create_persistent_port(&app_config_dir) + .map_err(|e| format!("Failed to get persistent port: {}", e))?; + + // Get or create a persistent secret key + let secret_key = get_or_create_secret_key(&app_config_dir) + .map_err(|e| format!("Failed to get persistent secret key: {}", e))?; // Get the app data directory for persistence let app_data_dir = app @@ -69,28 +107,54 @@ async fn start_backend(app: tauri::AppHandle) -> Result { std::fs::create_dir_all(&webui_data_dir) .map_err(|e| format!("Failed to create Open WebUI data directory: {}", e))?; + println!("Using persistent port: {}", port); + println!( + "Using secret key (first 8 chars): {}...", + &secret_key[..8.min(secret_key.len())] + ); + println!( + "Open WebUI data directory: {}", + webui_data_dir.to_string_lossy() + ); + println!("App config directory: {}", app_config_dir.to_string_lossy()); + // Start the backend sidecar with proper environment variables let sidecar_command = app .shell() .sidecar("glowpath-backend") .map_err(|e| format!("Failed to create sidecar command: {}", e))? .args(&["serve", "--host", "127.0.0.1", "--port", &port.to_string()]) - .env("WEBUI_SECRET_KEY", &generate_secret_key()) // Generate a proper secret + .env("WEBUI_SECRET_KEY", &secret_key) // Use persistent secret key .env("DATA_DIR", &webui_data_dir) .env( "WEBUI_DATABASE_URL", format!("sqlite:///{}/webui.db", webui_data_dir.to_string_lossy()), - ); + ) + .env("WEBUI_AUTH", "false") // Disable authentication completely + .env("WEBUI_ENABLE_SIGNUP", "false") // Disable user signup + .env("WEBUI_ENABLE_LOGIN_FORM", "false") // Disable login form + .env("WEBUI_ENABLE_OAUTH_SIGNUP", "false") // Disable OAuth signup + .env("WEBUI_ENABLE_LDAP", "false") // Disable LDAP + .env("WEBUI_DEFAULT_USER_ROLE", "admin") // Default role when auth is disabled + .env("WEBUI_SESSION_COOKIE_SECURE", "false") // Allow insecure cookies for localhost + .env("WEBUI_SESSION_COOKIE_SAME_SITE", "lax"); // More permissive cookie policy let (mut rx, child) = sidecar_command .spawn() .map_err(|e| format!("Failed to spawn backend process: {}", e))?; - // Store the process and port + // Get the PID for process group management + let pid = child.pid(); + + // Store the process, PID, and port if let Ok(mut process_guard) = state.backend_process.lock() { *process_guard = Some(child); } + if let Ok(mut pid_guard) = state.backend_pid.lock() { + *pid_guard = Some(pid); + } + if let Ok(mut port_guard) = state.backend_port.lock() { *port_guard = Some(port); } @@ -119,6 +183,9 @@ async fn start_backend(app: tauri::AppHandle) -> Result { if let Ok(mut process_guard) = state.backend_process.lock() { *process_guard = None; } + if let Ok(mut pid_guard) = state.backend_pid.lock() { + *pid_guard = None; + } } break; } @@ -148,18 +215,69 @@ async fn get_backend_port(app: tauri::AppHandle) -> Result { async fn stop_backend(app: tauri::AppHandle) -> Result<(), String> { let state = app.state::(); - if let Ok(mut process_guard) = state.backend_process.lock() { - if let Some(child) = process_guard.take() { + // Get the stored PID first + let stored_pid = if let Ok(pid_guard) = state.backend_pid.lock() { + *pid_guard + } else { + None + }; + + // Get the child process and PID, then release the lock + let (child_option, pid_option) = { + let mut process_guard = state + .backend_process + .lock() + .map_err(|_| "Failed to acquire process lock".to_string())?; + + let child = process_guard.take(); + let pid = if let Some(ref child) = child { + Some(stored_pid.unwrap_or_else(|| child.pid())) + } else { + stored_pid + }; + + (child, pid) + }; // Lock is released here + + if let Some(child) = child_option { + if let Some(pid) = pid_option { + println!("Stopping backend process gracefully..."); + + // First try graceful termination (SIGTERM) + match graceful_terminate_process_group(pid).await { + Ok(_) => { + println!("Backend process group terminated gracefully"); + } + Err(e) => { + eprintln!("Graceful termination failed: {}, trying forceful kill", e); + // Fallback to forceful kill + child + .kill() + .map_err(|e| format!("Failed to kill backend process: {}", e))?; + + // Also try to kill any remaining processes by name + let _ = kill_orphaned_backends(); // Best effort cleanup + } + } + + println!("Backend process stopped"); + } else { + // No PID available, just kill the child process child .kill() .map_err(|e| format!("Failed to kill backend process: {}", e))?; } } + // Clear all stored state if let Ok(mut port_guard) = state.backend_port.lock() { *port_guard = None; } + if let Ok(mut pid_guard) = state.backend_pid.lock() { + *pid_guard = None; + } + Ok(()) } @@ -173,6 +291,78 @@ fn get_data_directory(app: tauri::AppHandle) -> Result { Ok(app_data_dir.to_string_lossy().to_string()) } +#[tauri::command] +fn reset_backend_port(app: tauri::AppHandle) -> Result<(), String> { + let app_config_dir = app + .path() + .app_config_dir() + .map_err(|e| format!("Failed to get app config directory: {}", e))?; + + let port_file = app_config_dir.join("backend_port.txt"); + + if port_file.exists() { + fs::remove_file(&port_file).map_err(|e| format!("Failed to remove port file: {}", e))?; + } + + Ok(()) +} + +#[tauri::command] +fn reset_session_data(app: tauri::AppHandle) -> Result<(), String> { + let app_config_dir = app + .path() + .app_config_dir() + .map_err(|e| format!("Failed to get app config directory: {}", e))?; + + // Remove port and secret files to force regeneration + let port_file = app_config_dir.join("backend_port.txt"); + let secret_file = app_config_dir.join("webui_secret.txt"); + + if port_file.exists() { + fs::remove_file(&port_file).map_err(|e| format!("Failed to remove port file: {}", e))?; + } + + if secret_file.exists() { + fs::remove_file(&secret_file) + .map_err(|e| format!("Failed to remove secret file: {}", e))?; + } + + Ok(()) +} + +#[tauri::command] +fn kill_orphaned_backends() -> Result { + use std::process::Command; + + // First try graceful termination with SIGTERM + let sigterm_output = Command::new("pkill") + .arg("-TERM") + .arg("-f") + .arg("glowpath-backend") + .output(); + + // Give processes a moment to shut down gracefully + std::thread::sleep(std::time::Duration::from_millis(1000)); + + // Then force kill any remaining processes + let sigkill_output = Command::new("pkill") + .arg("-KILL") + .arg("-f") + .arg("glowpath-backend") + .output(); + + match (sigterm_output, sigkill_output) { + (Ok(term_result), Ok(kill_result)) => { + if term_result.status.success() || kill_result.status.success() { + Ok("Successfully cleaned up orphaned backend processes".to_string()) + } else { + Ok("No orphaned backend processes found".to_string()) + } + } + (Err(e), _) | (_, Err(e)) => Err(format!("Failed to check for orphaned processes: {}", e)), + } +} + fn find_available_port() -> Result> { use std::net::TcpListener; @@ -182,6 +372,140 @@ fn find_available_port() -> Result> { Ok(addr.port()) } +// Load port from config file, or find an available port and save it +fn get_or_create_persistent_port(config_dir: &PathBuf) -> Result> { + let port_file = config_dir.join("backend_port.txt"); + + // Try to read existing port + if let Ok(port_str) = fs::read_to_string(&port_file) { + if let Ok(port) = port_str.trim().parse::() { + // Check if the port is still available + if is_port_available(port) { + return Ok(port); + } + } + } + + // If no saved port or port is not available, find a new one + let port = find_available_port()?; + + // Save the port for future use + fs::create_dir_all(config_dir)?; + fs::write(&port_file, port.to_string())?; + + Ok(port) +} + +fn is_port_available(port: u16) -> bool { + use std::net::TcpListener; + use std::time::Duration; + + // Try to bind to the port + let result = TcpListener::bind(format!("127.0.0.1:{}", port)); + + match result { + Ok(listener) => { + // Successfully bound, port is available + drop(listener); // Close the listener + + // Give a small delay to ensure the port is fully released + std::thread::sleep(Duration::from_millis(100)); + true + } + Err(_) => { + // Failed to bind, port is not available + false + } + } +} + +// Gracefully terminate a process group with timeout +async fn graceful_terminate_process_group(pid: u32) -> Result<(), Box> { + use std::process::Command; + use std::time::Duration; + + // Send SIGTERM to the entire process group + // Using negative PID to target the process group + let sigterm_result = Command::new("kill") + .arg("-TERM") + .arg(format!("-{}", pid)) // Negative PID targets the process group + .output(); + + match sigterm_result { + Ok(output) => { + if !output.status.success() { + return Err(format!( + "Failed to send SIGTERM to process group {}: {}", + pid, + String::from_utf8_lossy(&output.stderr) + ) + .into()); + } + } + Err(e) => { + return Err(format!("Failed to execute kill command: {}", e).into()); + } + } + + // Wait up to 5 seconds for graceful shutdown + for _ in 0..50 { + // Check every 100ms for 5 seconds + let check_result = Command::new("kill") + .arg("-0") // Signal 0 just checks if process exists + .arg(pid.to_string()) + .output(); + + match check_result { + Ok(output) => { + if !output.status.success() { + // Process no longer exists, graceful shutdown succeeded + println!("Process group {} terminated gracefully", pid); + return Ok(()); + } + } + Err(_) => { + // Error usually means process doesn't exist + println!("Process group {} terminated gracefully", pid); + return Ok(()); + } + } + + // Use tauri's async runtime sleep + tauri::async_runtime::spawn(async { + std::thread::sleep(Duration::from_millis(100)); + }) + .await + .unwrap(); + } + + // Timeout occurred, force kill the process group + println!( + "Graceful termination timed out, force killing process group {}", + pid + ); + + let force_kill_result = Command::new("kill") + .arg("-KILL") + .arg(format!("-{}", pid)) // Negative PID targets the process group + .output(); + + match force_kill_result { + Ok(output) => { + if output.status.success() { + Ok(()) + } else { + Err(format!( + "Failed to force kill process group {}: {}", + pid, + String::from_utf8_lossy(&output.stderr) + ) + .into()) + } + } + Err(e) => Err(format!("Failed to execute force kill: {}", e).into()), + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -194,8 +518,22 @@ pub fn run() { start_backend, get_backend_port, stop_backend, - get_data_directory + get_data_directory, + reset_backend_port, + reset_session_data, + kill_orphaned_backends ]) + .on_window_event(|window, event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + println!("App closing, stopping backend..."); + let app_handle = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = stop_backend(app_handle).await { + eprintln!("Failed to stop backend on app exit: {}", e); + } + }); + } + }) .setup(|app| { // Optionally start backend on app startup let app_handle = app.handle().clone(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0dc4dd3..343924d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -19,7 +19,8 @@ ], "security": { "csp": null - } + }, + "withGlobalTauri": false }, "bundle": { "active": true, diff --git a/src/lib/backend.ts b/src/lib/backend.ts index 1f5a039..705d953 100644 --- a/src/lib/backend.ts +++ b/src/lib/backend.ts @@ -79,6 +79,24 @@ class BackendService { return await invoke("get_data_directory"); } + async resetPort(): Promise { + await invoke("reset_backend_port"); + // Clear the current state + this.port = null; + this.baseUrl = null; + } + + async resetSessionData(): Promise { + await invoke("reset_session_data"); + // Clear the current state + this.port = null; + this.baseUrl = null; + } + + async killOrphanedBackends(): Promise { + return await invoke("kill_orphaned_backends"); + } + async fetch(path: string, options?: RequestInit): Promise { if (!this.baseUrl) { throw new Error("Backend service not initialized");