Merge pull request #272 from jamiepine/windows-support

Windows support: CUDA detection, cross-platform justfile, clean server shutdown
This commit is contained in:
Jamie Pine
2026-03-15 06:20:46 -07:00
committed by GitHub
7 changed files with 367 additions and 339 deletions
+36 -98
View File
@@ -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
+16 -5
View File
@@ -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
+82 -4
View File
@@ -10,6 +10,7 @@ import PyInstaller.__main__
import argparse
import os
import platform
import sys
from pathlib import Path
@@ -62,6 +63,10 @@ def build_server(cuda=False):
'--hidden-import', 'backend.utils.hf_progress',
'--hidden-import', 'backend.utils.validation',
'--hidden-import', 'backend.cuda_download',
'--hidden-import', 'backend.effects',
'--hidden-import', 'backend.utils.effects',
'--hidden-import', 'backend.versions',
'--hidden-import', 'pedalboard',
'--hidden-import', 'torch',
'--hidden-import', 'transformers',
'--hidden-import', 'fastapi',
@@ -91,9 +96,10 @@ def build_server(cuda=False):
'--hidden-import', 'torch.backends.cudnn',
])
else:
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary under 4GB.
# On Linux, pip may pull CUDA-enabled PyTorch by default which includes ~3GB
# of NVIDIA shared libraries that PyInstaller would bundle.
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
# When building from a venv with CUDA torch installed, PyInstaller would
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
# modules and the binary DLLs.
nvidia_packages = [
'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc',
'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand',
@@ -127,7 +133,12 @@ def build_server(cuda=False):
elif not cuda:
print("Building for non-Apple Silicon platform - PyTorch only")
dist_dir = str(backend_dir / 'dist')
build_dir = str(backend_dir / 'build')
args.extend([
'--distpath', dist_dir,
'--workpath', build_dir,
'--noconfirm',
'--clean',
])
@@ -135,12 +146,79 @@ def build_server(cuda=False):
# Change to backend directory
os.chdir(backend_dir)
# For CPU builds on Windows, ensure we're using CPU-only torch.
# If CUDA torch is installed (local dev), swap to CPU torch before building,
# then restore CUDA torch after. This prevents PyInstaller from bundling
# ~3GB of CUDA DLLs into the CPU binary.
restore_cuda = False
if not cuda and platform.system() == "Windows":
import subprocess
result = subprocess.run(
[sys.executable, "-c", "import torch; print(torch.version.cuda or '')"],
capture_output=True, text=True
)
has_cuda_torch = bool(result.stdout.strip())
if has_cuda_torch:
print("CUDA torch detected — installing CPU torch for CPU build...")
subprocess.run(
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
"--index-url", "https://download.pytorch.org/whl/cpu", "--force-reinstall", "-q"],
check=True
)
restore_cuda = True
# Run PyInstaller
PyInstaller.__main__.run(args)
try:
PyInstaller.__main__.run(args)
finally:
# Restore CUDA torch if we swapped it out (even on build failure)
if restore_cuda:
print("Restoring CUDA torch...")
import subprocess
subprocess.run(
[sys.executable, "-m", "pip", "install", "torch", "torchvision", "torchaudio",
"--index-url", "https://download.pytorch.org/whl/cu126", "--force-reinstall", "-q"],
check=True
)
print(f"Binary built in {backend_dir / 'dist' / binary_name}")
def _get_cuda_dll_excludes():
"""Get list of CUDA DLL filenames to exclude from CPU builds.
When building locally with CUDA torch installed, PyInstaller bundles ~3GB of
CUDA DLLs from torch/lib/. Returns a list of DLL filenames to exclude.
"""
try:
import torch
torch_lib = Path(torch.__file__).parent / 'lib'
except ImportError:
return []
cuda_prefixes = (
'torch_cuda', 'cublas', 'cublasLt', 'cudnn', 'cusparse', 'cufft',
'cusolver', 'cusolverMg', 'curand', 'nvrtc', 'nvJitLink', 'nccl',
'nvperf', 'nvrtc-builtins',
)
exclude_dlls = []
if torch_lib.exists():
for f in torch_lib.iterdir():
if f.suffix == '.dll' and any(f.name.startswith(p) for p in cuda_prefixes):
exclude_dlls.append(f.name)
if exclude_dlls:
total_mb = sum(
(torch_lib / dll).stat().st_size
for dll in exclude_dlls
if (torch_lib / dll).exists()
) / 1024 / 1024
print(f"CPU build: will exclude {len(exclude_dlls)} CUDA DLLs ({total_mb:.0f} MB)")
return exclude_dlls
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
parser.add_argument(
+9
View File
@@ -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()]
@@ -142,6 +143,14 @@ async def shutdown():
return {"message": "Shutting down..."}
@app.post("/watchdog/disable")
async def watchdog_disable():
"""Disable the parent process watchdog so the server keeps running."""
from backend.server import disable_watchdog
disable_watchdog()
return {"message": "Watchdog disabled"}
@app.get("/health", response_model=models.HealthResponse)
async def health():
"""Health check endpoint."""
+119 -5
View File
@@ -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,100 @@ except Exception as e:
logger.error(f"Failed to import required modules: {e}", exc_info=True)
sys.exit(1)
_watchdog_disabled = False
def disable_watchdog():
"""Disable the parent watchdog so the server keeps running after parent exits."""
global _watchdog_disabled
_watchdog_disabled = True
def _start_parent_watchdog(parent_pid, data_dir=None):
"""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
# Set up a file logger so we can debug in production
watchdog_logger = logging.getLogger("watchdog")
if data_dir:
try:
log_dir = os.path.join(data_dir, "logs")
os.makedirs(log_dir, exist_ok=True)
fh = logging.FileHandler(os.path.join(log_dir, "watchdog.log"))
fh.setFormatter(logging.Formatter('%(asctime)s - %(message)s'))
watchdog_logger.addHandler(fh)
except Exception:
pass
watchdog_logger.setLevel(logging.INFO)
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
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if handle:
# Check if process has actually exited
STILL_ACTIVE = 259
exit_code = ctypes.c_ulong()
result = kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))
kernel32.CloseHandle(handle)
if result and exit_code.value == STILL_ACTIVE:
return True
watchdog_logger.info(f"PID {pid}: exited with code {exit_code.value}")
return False
# OpenProcess failed — check if it's an access error (process exists
# but we can't open it) vs process not found
error = ctypes.GetLastError()
ACCESS_DENIED = 5
if error == ACCESS_DENIED:
return True # process exists, we just can't open it
watchdog_logger.info(f"PID {pid}: OpenProcess failed, error={error}")
return False
else:
os.kill(pid, 0)
return True
except (OSError, PermissionError):
return False
def _watch():
watchdog_logger.info(f"Parent watchdog started, monitoring PID {parent_pid}, server PID {os.getpid()}")
# Verify parent is alive before starting the loop
alive = _is_pid_alive(parent_pid)
watchdog_logger.info(f"Parent PID {parent_pid} initial check: alive={alive}")
if not alive:
watchdog_logger.warning(f"Parent PID {parent_pid} not found on first check — disabling watchdog")
return
while True:
if _watchdog_disabled:
watchdog_logger.info("Watchdog disabled (keep server running), stopping monitor")
return
if not _is_pid_alive(parent_pid):
watchdog_logger.info(f"Parent process {parent_pid} gone, shutting down server...")
if sys.platform == "win32":
# sys.exit triggers SystemExit, allowing uvicorn to run
# shutdown handlers. os.kill(SIGTERM) on Windows calls
# TerminateProcess which hard-kills without cleanup.
os._exit(0)
else:
os.kill(os.getpid(), signal.SIGTERM)
return
time.sleep(2)
t = threading.Thread(target=_watch, daemon=True)
t.start()
if __name__ == "__main__":
try:
parser = argparse.ArgumentParser(description="voicebox backend server")
@@ -64,17 +166,21 @@ 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)
if args.parent_pid is not None and args.parent_pid <= 0:
parser.error("--parent-pid must be a positive integer")
# Detect backend variant from binary name
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
@@ -87,6 +193,14 @@ if __name__ == "__main__":
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
logger.info("Backend variant: CPU")
# Register parent watchdog to start after server is fully ready
if args.parent_pid is not None:
_parent_pid = args.parent_pid
_data_dir = args.data_dir
@app.on_event("startup")
async def _on_startup():
_start_parent_watchdog(_parent_pid, _data_dir)
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
# Set data directory if provided
+58 -13
View File
@@ -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) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
# 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) { taskkill /PID $backendJob.Id /T /F 2>$null | Out-Null } }
# Kill all dev processes
[unix]
@@ -175,23 +182,56 @@ 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
$ErrorActionPreference = "Stop"; \
$env:PATH = "{{ venv_bin }};$env:PATH"; \
& "{{ python }}" backend/build_binary.py; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py failed with exit code $LASTEXITCODE" }; \
$triple = (rustc --print host-tuple); \
New-Item -ItemType Directory -Path "{{ tauri_dir }}/src-tauri/binaries" -Force | Out-Null; \
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
$ErrorActionPreference = "Stop"; \
$env:PATH = "{{ venv_bin }};$env:PATH"; \
& "{{ python }}" backend/build_binary.py --cuda; \
if ($LASTEXITCODE -ne 0) { throw "build_binary.py --cuda failed with exit code $LASTEXITCODE" }; \
$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,8 +253,13 @@ fix:
# ─── Database ─────────────────────────────────────────────────────────
# Initialize SQLite database
[unix]
db-init: _ensure-venv
cd {{ backend_dir }} && {{ python }} -c "from database import init_db; init_db()"
{{ python }} -c "from backend.database import init_db; init_db()"
[windows]
db-init: _ensure-venv
& "{{ python }}" -c "from backend.database import init_db; init_db()"
# Reset database (delete + reinit)
[unix]
+47 -214
View File
@@ -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::<u32>() {
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)");
}
}
@@ -798,9 +708,17 @@ pub fn run() {
play_audio_to_devices,
stop_audio_playback
])
.on_window_event(|window, event| {
.on_window_event({
let closing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
move |window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
// Prevent automatic close
// If we're already in the close flow, let it proceed
if closing.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
closing.store(true, std::sync::atomic::Ordering::SeqCst);
// Prevent automatic close so frontend can clean up
api.prevent_close();
// Emit event to frontend to check setting and stop server if needed
@@ -808,162 +726,77 @@ pub fn run() {
if let Err(e) = app_handle.emit("window-close-requested", ()) {
eprintln!("Failed to emit window-close-requested event: {}", e);
// If event emission fails, allow close anyway
window.close().ok();
return;
}
// Set up listener for frontend response
let window_for_close = window.clone();
let closing_for_timeout = closing.clone();
let (tx, mut rx) = mpsc::unbounded_channel::<()>();
// Listen for response from frontend using window's listen method
let listener_id = window.listen("window-close-allowed", move |_| {
// Frontend has checked setting and stopped server if needed
// Signal that we can close
let _ = tx.send(());
});
// Wait for frontend response or timeout
// Use tauri::async_runtime::spawn instead of tokio::spawn to avoid
// panics when the Tokio runtime is being dropped during app shutdown
tauri::async_runtime::spawn(async move {
tokio::select! {
_ = rx.recv() => {
// Frontend responded, close window
window_for_close.close().ok();
}
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
// Timeout - close anyway
eprintln!("Window close timeout, closing anyway");
window_for_close.close().ok();
}
}
// Clean up listener
window_for_close.unlisten(listener_id);
closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst);
});
}
})
}})
.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::<ServerState>();
let keep_running = *state.keep_running_on_close.lock().unwrap();
println!("keep_running_on_close = {}", keep_running);
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)]
{
if keep_running {
// Tell the server to disable its watchdog so it survives
// after this process exits.
println!("Keep server running: disabling watchdog...");
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
let _ = client
.post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT))
.send();
} else {
// Server will self-terminate via parent-pid watchdog when
// this process exits. On Unix, also send SIGTERM for
// immediate cleanup.
println!("RunEvent::Exit - server will self-terminate via watchdog");
#[cfg(unix)]
{
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");