From 4d6c976ad95b8e7ab5b8d892b9627c1847c2b4cd Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Sun, 15 Mar 2026 00:02:13 -0700 Subject: [PATCH] Windows support: CUDA detection, justfile cross-platform, clean server shutdown --- CONTRIBUTING.md | 134 ++++++--------------- README.md | 21 +++- backend/main.py | 1 + backend/server.py | 68 ++++++++++- justfile | 64 ++++++++-- tauri/src-tauri/src/main.rs | 226 ++++-------------------------------- 6 files changed, 191 insertions(+), 323 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b96e514..e9b7bf7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,101 +33,41 @@ Thank you for your interest in contributing to Voicebox! This document provides ### Development Setup -**Using `just` (recommended):** - -Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then: +Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then: ```bash +git clone https://github.com/YOUR_USERNAME/voicebox.git +cd voicebox + just setup # creates venv, installs Python + JS deps -just dev # starts backend + desktop app in one terminal +just dev # starts backend + desktop app ``` +`just setup` handles everything automatically, including: +- Creating a Python virtual environment +- Installing Python dependencies (with CUDA PyTorch on Windows if an NVIDIA GPU is detected) +- Installing MLX dependencies on Apple Silicon +- Installing JavaScript dependencies + +`just dev` starts the backend and desktop app together. If a backend is already running (e.g. from `just dev-backend` in another terminal), it detects it and only starts the frontend. + Other useful commands: ```bash just dev-web # backend + web app (no Tauri/Rust build) just dev-backend # backend only +just dev-frontend # Tauri app only (backend must be running) just kill # stop all dev processes just clean-all # nuke everything and start fresh just --list # see all available commands ``` -**Using the Makefile:** Run `make setup` then `make dev`. See `make help` for all commands. +> **Note:** In dev mode, the app connects to a manually-started Python server. +> The bundled server binary is only used in production builds. -**Manual setup (required for Windows):** +#### Windows Notes -1. **Fork and clone the repository** - ```bash - git clone https://github.com/YOUR_USERNAME/voicebox.git - cd voicebox - ``` - -2. **Install JavaScript dependencies** - ```bash - bun install - ``` - This installs dependencies for: - - `app/` - Shared React frontend - - `tauri/` - Tauri desktop wrapper - - `web/` - Web deployment wrapper - -3. **Set up Python backend** - ```bash - cd backend - - # Create virtual environment - python -m venv venv - - # Activate virtual environment - source venv/bin/activate # On macOS/Linux - # or - venv\Scripts\activate # On Windows - - # Install Python dependencies - pip install -r requirements.txt - - # Install MLX dependencies (Apple Silicon only - for faster inference) - # On Apple Silicon, this enables native Metal acceleration - if [[ $(uname -m) == "arm64" ]]; then - pip install -r requirements-mlx.txt - fi - - # Install Qwen3-TTS (required for voice synthesis) - pip install git+https://github.com/QwenLM/Qwen3-TTS.git - ``` - -4. **Start development servers** - - Development requires two terminals: one for the Python backend, one for the Tauri app. - - **Terminal 1: Backend server** (start this first) - ```bash - cd backend - source venv/bin/activate # Activate venv if not already active - bun run dev:server - # Or manually: uvicorn main:app --reload --port 17493 - ``` - Backend will be available at `http://localhost:17493` - - **Terminal 2: Desktop app** - ```bash - bun run dev - ``` - This will: - - Create a placeholder sidecar binary (for Tauri compilation) - - Start Vite dev server on port 5173 - - Launch Tauri window pointing to localhost:5173 - - Connect to the Python server you started in Terminal 1 - - Enable hot reload - - > **Note:** In dev mode, the app connects to your manually-started Python server. - > The bundled server binary is only used in production builds. - - **Optional: Web app** - ```bash - bun run dev:web - ``` - Web app will be available at `http://localhost:5174` +The justfile works natively on Windows via PowerShell. No WSL or Git Bash required. On Windows with an NVIDIA GPU, `just setup` automatically installs CUDA-enabled PyTorch for GPU acceleration. ### Model Downloads @@ -139,25 +79,30 @@ First-time usage will be slower due to model downloads, but subsequent runs will ### Building -**Build everything (recommended):** +**Build production app:** + ```bash -bun run build +just build # Build CPU server binary + Tauri installer ``` -This automatically: -1. Builds the Python server binary (`./scripts/build-server.sh`) -2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`) + +On Windows, to build with CUDA support for local testing: + +```bash +just build-local # Build CPU + CUDA server binaries + Tauri installer +``` + +This builds the CPU sidecar (bundled with the app), the CUDA binary (placed in `%APPDATA%/com.voicebox.app/backends/` for runtime GPU switching), and the installable Tauri app. Creates platform-specific installers (`.dmg`, `.msi`, `.AppImage`) in `tauri/src-tauri/target/release/bundle/`. -**Note:** The build process detects your platform and includes the appropriate backend (MLX for Apple Silicon, PyTorch for others). +**Individual build targets:** -**Build server binary only:** ```bash -bun run build:server -# or -./scripts/build-server.sh +just build-server # CPU server binary only +just build-server-cuda # CUDA server binary only (Windows) +just build-tauri # Tauri desktop app only +just build-web # Web app only ``` -Creates platform-specific binary in `tauri/src-tauri/binaries/` **Building with local Qwen3-TTS development version:** @@ -165,17 +110,10 @@ If you're actively developing or modifying the Qwen3-TTS library, set the `QWEN_ ```bash export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS -bun run build:server +just build-server ``` -This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. Useful when testing changes to the TTS library before they're published to PyPI or when using an editable install (`pip install -e`). - -**Build web app:** -```bash -cd web -bun run build -``` -Output in `web/dist/` +This makes PyInstaller use your local qwen-tts version instead of the pip-installed package. ### Generate OpenAPI Client diff --git a/README.md b/README.md index b655bfc3..59c855d8 100644 --- a/README.md +++ b/README.md @@ -240,13 +240,24 @@ just dev # starts backend + desktop app Install [just](https://github.com/casey/just): `brew install just` or `cargo install just`. Run `just --list` to see all commands. -Also available via Makefile: `make setup && make dev` (run `make help` for all commands). +**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/), and [XCode](https://developer.apple.com/xcode/) on macOS. -**Prerequisites:** [Bun](https://bun.sh), [Rust](https://rustup.rs), [Python 3.11+](https://python.org), [XCode on macOS](https://developer.apple.com/xcode/), [Tauri Prerequisites](https://v2.tauri.app/start/prerequisites/). +### Platform Notes -**Performance:** -- **Apple Silicon (M1/M2/M3)**: Uses MLX backend with native Metal acceleration for 4-5x faster inference -- **Windows/Linux/Intel Mac**: Uses PyTorch backend (CUDA GPU recommended, CPU supported but slower) +| Platform | GPU Backend | Notes | +|----------|-------------|-------| +| macOS (Apple Silicon) | MLX (Metal) | 4-5x faster inference via Neural Engine | +| Windows (NVIDIA) | PyTorch (CUDA) | `just setup` auto-installs CUDA PyTorch | +| Windows/Linux (no NVIDIA) | PyTorch (CPU) | Works but slower | + +### Building Locally + +```bash +just build # Build CPU server binary + Tauri app +just build-local # (Windows) Build CPU + CUDA server binaries + Tauri app +``` + +`just build-local` produces a production-ready installer with the CUDA binary pre-placed for GPU switching. ### Project Structure diff --git a/backend/main.py b/backend/main.py index 25a9c679..b2c2d06b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -108,6 +108,7 @@ _default_origins = [ "http://127.0.0.1:17493", "tauri://localhost", # Tauri webview (macOS) "https://tauri.localhost", # Tauri webview (Windows/Linux) + "http://tauri.localhost", # Tauri webview (Windows, some builds) ] _env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "") _cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()] diff --git a/backend/server.py b/backend/server.py index 9a26af4a..dc2b0da1 100644 --- a/backend/server.py +++ b/backend/server.py @@ -6,6 +6,14 @@ absolute imports instead of relative imports. """ import sys + +# Fast path: handle --version before any heavy imports so the Rust +# version check doesn't block for 30+ seconds loading torch etc. +if "--version" in sys.argv: + from backend import __version__ + print(f"voicebox-server {__version__}") + sys.exit(0) + import logging # Set up logging FIRST, before any imports that might fail @@ -43,6 +51,49 @@ except Exception as e: logger.error(f"Failed to import required modules: {e}", exc_info=True) sys.exit(1) +def _start_parent_watchdog(parent_pid): + """Monitor parent process and exit if it dies. + + This is the clean shutdown mechanism: instead of the Tauri app trying to + forcefully kill the server (which spawns console windows on Windows), + the server monitors its parent and shuts itself down gracefully. + """ + import os + import signal + import threading + import time + + def _is_pid_alive(pid): + """Check if a process with the given PID exists (cross-platform).""" + try: + if sys.platform == "win32": + import ctypes + kernel32 = ctypes.windll.kernel32 + SYNCHRONIZE = 0x00100000 + handle = kernel32.OpenProcess(SYNCHRONIZE, False, pid) + if handle: + kernel32.CloseHandle(handle) + return True + return False + else: + os.kill(pid, 0) + return True + except (OSError, PermissionError): + return False + + def _watch(): + logger.info(f"Parent watchdog started, monitoring PID {parent_pid}") + while True: + if not _is_pid_alive(parent_pid): + logger.info(f"Parent process {parent_pid} no longer exists, shutting down...") + os.kill(os.getpid(), signal.SIGTERM) + return + time.sleep(1) + + t = threading.Thread(target=_watch, daemon=True) + t.start() + + if __name__ == "__main__": try: parser = argparse.ArgumentParser(description="voicebox backend server") @@ -64,18 +115,19 @@ if __name__ == "__main__": default=None, help="Data directory for database, profiles, and generated audio", ) + parser.add_argument( + "--parent-pid", + type=int, + default=None, + help="PID of parent process to monitor; server exits when parent dies", + ) parser.add_argument( "--version", action="store_true", - help="Print version and exit", + help="Print version and exit (handled above, kept for argparse help)", ) args = parser.parse_args() - if args.version: - from backend import __version__ - print(f"voicebox-server {__version__}") - sys.exit(0) - # Detect backend variant from binary name # voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda import os @@ -87,6 +139,10 @@ if __name__ == "__main__": os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu" logger.info("Backend variant: CPU") + # Start parent process watchdog if requested + if args.parent_pid is not None: + _start_parent_watchdog(args.parent_pid) + logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}") # Set data directory if provided diff --git a/justfile b/justfile index b1a9a1df..108becc8 100644 --- a/justfile +++ b/justfile @@ -52,6 +52,7 @@ setup-python: {{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt fi {{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git + {{ pip }} install pyinstaller -q echo "Python environment ready." [windows] @@ -74,6 +75,7 @@ setup-python: & "{{ pip }}" install -r {{ backend_dir }}/requirements.txt & "{{ pip }}" install --no-deps chatterbox-tts & "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git + & "{{ pip }}" install pyinstaller -q Write-Host "Python environment ready." # Install JavaScript dependencies @@ -105,14 +107,14 @@ dev: _ensure-venv _ensure-sidecar [windows] dev: _ensure-venv _ensure-sidecar - $backendJob = $null + $backendJob = $null; \ try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \ Write-Host "Starting backend on http://localhost:17493 ..."; \ - $backendJob = Start-Job -ScriptBlock { & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 } -WorkingDirectory (Get-Location); \ + $backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \ Start-Sleep -Seconds 2; \ - } - Write-Host "Starting Tauri desktop app..." - try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { Stop-Job $backendJob -ErrorAction SilentlyContinue; Remove-Job $backendJob -Force -ErrorAction SilentlyContinue } } + }; \ + Write-Host "Starting Tauri desktop app..."; \ + try { Set-Location "{{ tauri_dir }}"; bun run tauri dev } finally { if ($backendJob) { Stop-Process -Id $backendJob.Id -Force -ErrorAction SilentlyContinue } } # Start backend only [unix] @@ -124,9 +126,14 @@ dev-backend: _ensure-venv & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 # Start Tauri desktop app only (backend must be running separately) +[unix] dev-frontend: _ensure-sidecar cd {{ tauri_dir }} && bun run tauri dev +[windows] +dev-frontend: _ensure-sidecar + Set-Location "{{ tauri_dir }}"; bun run tauri dev + # Start backend (if not already running) + web app (no Tauri) [unix] dev-web: _ensure-venv @@ -149,14 +156,14 @@ dev-web: _ensure-venv [windows] dev-web: _ensure-venv - $backendJob = $null + $backendJob = $null; \ try { $null = Invoke-WebRequest -Uri "http://127.0.0.1:17493/health" -UseBasicParsing -TimeoutSec 2 -ErrorAction Stop; Write-Host "Backend already running on http://localhost:17493" } catch { \ Write-Host "Starting backend on http://localhost:17493 ..."; \ - $backendJob = Start-Job -ScriptBlock { & "{{ python }}" -m uvicorn backend.main:app --reload --port 17493 } -WorkingDirectory (Get-Location); \ + $backendJob = Start-Process -PassThru -NoNewWindow -FilePath "{{ python }}" -ArgumentList "-m","uvicorn","backend.main:app","--reload","--port","17493"; \ Start-Sleep -Seconds 2; \ - } - Write-Host "Starting web app..." - try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { Stop-Job $backendJob -ErrorAction SilentlyContinue; Remove-Job $backendJob -Force -ErrorAction SilentlyContinue } } + }; \ + Write-Host "Starting web app..."; \ + try { Set-Location "{{ web_dir }}"; bun run dev } finally { if ($backendJob) { Stop-Process -Id $backendJob.Id -Force -ErrorAction SilentlyContinue } } # Kill all dev processes [unix] @@ -175,23 +182,51 @@ kill: # Build everything (server binary + desktop app) build: build-server build-tauri -# Build Python server binary +# Build Python server binary (CPU) [unix] build-server: _ensure-venv PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh [windows] build-server: _ensure-venv - $env:PATH = "{{ venv_bin }};$env:PATH"; & "{{ python }}" -m PyInstaller backend/voicebox-server.spec + $env:PATH = "{{ venv_bin }};$env:PATH"; \ + & "{{ python }}" backend/build_binary.py; \ + $triple = (rustc --print host-tuple); \ + Copy-Item "backend/dist/voicebox-server.exe" "{{ tauri_dir }}/src-tauri/binaries/voicebox-server-$triple.exe" -Force; \ + Write-Host "Copied sidecar: voicebox-server-$triple.exe" + +# Build CUDA server binary and place in app data dir for local testing +[windows] +build-server-cuda: _ensure-venv + $env:PATH = "{{ venv_bin }};$env:PATH"; \ + & "{{ python }}" backend/build_binary.py --cuda; \ + $dest = "$env:APPDATA/com.voicebox.app/backends"; \ + New-Item -ItemType Directory -Path $dest -Force | Out-Null; \ + Copy-Item "backend/dist/voicebox-server-cuda.exe" "$dest/voicebox-server-cuda.exe" -Force; \ + Write-Host "Copied CUDA binary to $dest" + +# Build everything locally: CPU server + CUDA server + installable Tauri app +[windows] +build-local: build-server build-server-cuda build-tauri # Build Tauri desktop app +[unix] build-tauri: cd {{ tauri_dir }} && bun run tauri build +[windows] +build-tauri: + Set-Location "{{ tauri_dir }}"; bun run tauri build + # Build web app +[unix] build-web: cd {{ web_dir }} && bun run build +[windows] +build-web: + Set-Location "{{ web_dir }}"; bun run build + # ─── Code Quality ──────────────────────────────────────────────────── # Run all checks (lint + format + typecheck) @@ -213,9 +248,14 @@ fix: # ─── Database ───────────────────────────────────────────────────────── # Initialize SQLite database +[unix] db-init: _ensure-venv cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()" +[windows] +db-init: _ensure-venv + Set-Location "{{ backend_dir }}"; & "{{ python }}" -c "from database import init_db; init_db()" + # Reset database (delete + reinit) [unix] db-reset: diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index ae0f1ed9..ad1bb126 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -282,6 +282,7 @@ async fn start_server( .ok_or_else(|| "Invalid data dir path".to_string())? .to_string(); let port_str = SERVER_PORT.to_string(); + let parent_pid_str = std::process::id().to_string(); let is_remote = remote.unwrap_or(false); // Resolve the custom models directory from the parameter or stored state @@ -294,7 +295,7 @@ async fn start_server( let spawn_result = if let Some(ref cuda_path) = cuda_binary { println!("Launching CUDA backend: {:?}", cuda_path); let mut cmd = app.shell().command(cuda_path.to_str().unwrap()); - cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str]); + cmd = cmd.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]); if is_remote { cmd = cmd.args(["--host", "0.0.0.0"]); } @@ -304,7 +305,7 @@ async fn start_server( cmd.spawn() } else { // Use the bundled CPU sidecar - sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str]); + sidecar = sidecar.args(["--data-dir", &data_dir_str, "--port", &port_str, "--parent-pid", &parent_pid_str]); if is_remote { sidecar = sidecar.args(["--host", "0.0.0.0"]); } @@ -490,67 +491,13 @@ async fn start_server( Ok(format!("http://127.0.0.1:{}", SERVER_PORT)) } -/// Check if a Windows process is still running -#[cfg(windows)] -fn is_process_running(pid: u32) -> bool { - use std::process::Command; - if let Ok(output) = Command::new("tasklist") - .args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"]) - .output() - { - // If process exists, tasklist returns it in output - let output_str = String::from_utf8_lossy(&output.stdout); - return !output_str.trim().is_empty() && output_str.contains(&pid.to_string()); - } - false -} - -/// Kill entire Windows process tree by enumerating children -#[cfg(windows)] -fn kill_windows_process_tree(parent_pid: u32) -> Result<(), String> { - use std::process::Command; - - // Find all child processes using WMIC - let output = Command::new("wmic") - .args([ - "process", - "where", - &format!("ParentProcessId={}", parent_pid), - "get", - "ProcessId" - ]) - .output(); - - if let Ok(output) = output { - let output_str = String::from_utf8_lossy(&output.stdout); - for line in output_str.lines().skip(1) { // Skip header - if let Ok(child_pid) = line.trim().parse::() { - println!("Found child process: {}", child_pid); - // Recursively kill child's children - let _ = kill_windows_process_tree(child_pid); - // Kill the child - let _ = Command::new("taskkill") - .args(["/PID", &child_pid.to_string(), "/F"]) - .output(); - } - } - } - - // Kill the parent process - let _ = Command::new("taskkill") - .args(["/PID", &parent_pid.to_string(), "/F"]) - .output(); - - Ok(()) -} - #[command] async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> { let pid = state.server_pid.lock().unwrap().take(); let _child = state.child.lock().unwrap().take(); if let Some(pid) = pid { - println!("stop_server: Killing server process group with PID: {}", pid); + println!("stop_server: Stopping server with PID: {}", pid); #[cfg(unix)] { @@ -569,62 +516,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> { let _ = Command::new("kill") .args(["-9", &pid.to_string()]) .output(); + + println!("stop_server: Process group kill completed"); } #[cfg(windows)] { - // Layer 1: Try graceful HTTP shutdown first - println!("Attempting graceful shutdown via HTTP..."); + // Send graceful shutdown via HTTP — the server's parent-pid watchdog + // will also handle cleanup if this app process exits. + println!("Sending graceful shutdown via HTTP..."); let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() .unwrap(); - let shutdown_result = client + let _ = client .post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT)) .send(); - if shutdown_result.is_ok() { - println!("HTTP shutdown sent, waiting for graceful exit..."); - // Wait up to 3 seconds for graceful shutdown - for i in 0..30 { - std::thread::sleep(std::time::Duration::from_millis(100)); - if !is_process_running(pid) { - println!("Process exited gracefully after {}ms", i * 100); - return Ok(()); - } - } - println!("Graceful shutdown timed out, forcing kill..."); - } else { - println!("HTTP shutdown failed, forcing kill..."); - } - - // Layer 2: Kill process tree with enumeration - println!("Killing process tree for wrapper PID {}...", pid); - kill_windows_process_tree(pid)?; - - // Layer 3: Verify and kill by name if still running - std::thread::sleep(std::time::Duration::from_millis(200)); - if is_process_running(pid) { - println!("Process tree kill failed, killing by name..."); - use std::process::Command; - let _ = Command::new("taskkill") - .args(["/IM", "voicebox-server.exe", "/T", "/F"]) - .output(); - } - - // Layer 4: Final verification - std::thread::sleep(std::time::Duration::from_millis(200)); - if is_process_running(pid) { - eprintln!("WARNING: Failed to kill server after all attempts"); - } else { - println!("Server killed successfully"); - } - } - - #[cfg(unix)] - { - println!("stop_server: Process group kill completed"); + println!("Shutdown request sent (server watchdog will handle cleanup)"); } } @@ -847,123 +757,35 @@ pub fn run() { .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| { + let _ = &app; // used on unix match &event { RunEvent::Exit => { - println!("================================================================="); - println!("RunEvent::Exit received - checking server cleanup"); - let state = app.state::(); - let keep_running = *state.keep_running_on_close.lock().unwrap(); - println!("keep_running_on_close = {}", keep_running); + println!("RunEvent::Exit received - server will self-terminate via parent watchdog"); + // The server monitors this process's PID via --parent-pid. + // When this process exits, the server detects it and shuts itself down. + // No need for taskkill/wmic/process tree enumeration. - if !keep_running { - // Get the stored PID for process group killing - let pid = state.server_pid.lock().unwrap().take(); - // Also take the child to clean up - let _child = state.child.lock().unwrap().take(); - - if let Some(pid) = pid { - println!("Killing server process group with PID: {}", pid); - - // Kill the entire process group on Unix systems - // Using negative PID sends signal to all processes in the group - #[cfg(unix)] - { + // On Unix, send SIGTERM to the process group for immediate cleanup. + #[cfg(unix)] + { + let state = app.state::(); + let keep_running = *state.keep_running_on_close.lock().unwrap(); + if !keep_running { + if let Some(pid) = state.server_pid.lock().unwrap().take() { use std::process::Command; - // First try SIGTERM to the process group - let pgid_kill = Command::new("kill") + let _ = Command::new("kill") .args(["-TERM", "--", &format!("-{}", pid)]) .output(); - - match pgid_kill { - Ok(output) => { - if output.status.success() { - println!("SIGTERM sent to process group -{}", pid); - } else { - // Process group kill failed, try direct kill - println!("Process group kill failed, trying direct kill"); - let _ = Command::new("kill") - .args(["-TERM", &pid.to_string()]) - .output(); - } - } - Err(e) => { - eprintln!("Failed to execute kill command: {}", e); - } - } - - // Give it a moment, then force kill if needed std::thread::sleep(std::time::Duration::from_millis(100)); - - // Force kill with SIGKILL let _ = Command::new("kill") .args(["-9", "--", &format!("-{}", pid)]) .output(); let _ = Command::new("kill") .args(["-9", &pid.to_string()]) .output(); - - println!("Server process group kill completed"); } - - #[cfg(windows)] - { - // Layer 1: Try graceful HTTP shutdown first - println!("Attempting graceful shutdown via HTTP..."); - let client = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(2)) - .build() - .unwrap(); - - let shutdown_result = client - .post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT)) - .send(); - - if shutdown_result.is_ok() { - println!("HTTP shutdown sent, waiting for graceful exit..."); - // Wait up to 3 seconds for graceful shutdown - for i in 0..30 { - std::thread::sleep(std::time::Duration::from_millis(100)); - if !is_process_running(pid) { - println!("Process exited gracefully after {}ms", i * 100); - println!("Server process tree kill completed"); - return; - } - } - println!("Graceful shutdown timed out, forcing kill..."); - } else { - println!("HTTP shutdown failed, forcing kill..."); - } - - // Layer 2: Kill process tree with enumeration - println!("Killing process tree for wrapper PID {}...", pid); - let _ = kill_windows_process_tree(pid); - - // Layer 3: Verify and kill by name if still running - std::thread::sleep(std::time::Duration::from_millis(200)); - if is_process_running(pid) { - println!("Process tree kill failed, killing by name..."); - use std::process::Command; - let _ = Command::new("taskkill") - .args(["/IM", "voicebox-server.exe", "/T", "/F"]) - .output(); - } - - // Layer 4: Final verification - std::thread::sleep(std::time::Duration::from_millis(200)); - if is_process_running(pid) { - eprintln!("WARNING: Failed to kill server after all attempts"); - } else { - println!("Server killed successfully"); - } - println!("Server process tree kill completed"); - } - } else { - println!("No server PID found (already stopped or never started)"); } - } else { - println!("Keeping server running per user setting"); } - println!("================================================================="); } RunEvent::ExitRequested { api, .. } => { println!("RunEvent::ExitRequested received");