mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-26 13:45:16 -07:00
Merge pull request #272 from jamiepine/windows-support
Windows support: CUDA detection, cross-platform justfile, clean server shutdown
This commit is contained in:
+36
-98
@@ -33,101 +33,41 @@ Thank you for your interest in contributing to Voicebox! This document provides
|
|||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
**Using `just` (recommended):**
|
Install [just](https://github.com/casey/just) (`brew install just`, `cargo install just`, or `winget install Casey.Just`), then:
|
||||||
|
|
||||||
Install [just](https://github.com/casey/just) (`brew install just` or `cargo install just`), then:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
git clone https://github.com/YOUR_USERNAME/voicebox.git
|
||||||
|
cd voicebox
|
||||||
|
|
||||||
just setup # creates venv, installs Python + JS deps
|
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:
|
Other useful commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
just dev-web # backend + web app (no Tauri/Rust build)
|
just dev-web # backend + web app (no Tauri/Rust build)
|
||||||
just dev-backend # backend only
|
just dev-backend # backend only
|
||||||
|
just dev-frontend # Tauri app only (backend must be running)
|
||||||
just kill # stop all dev processes
|
just kill # stop all dev processes
|
||||||
just clean-all # nuke everything and start fresh
|
just clean-all # nuke everything and start fresh
|
||||||
just --list # see all available commands
|
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**
|
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.
|
||||||
```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`
|
|
||||||
|
|
||||||
### Model Downloads
|
### Model Downloads
|
||||||
|
|
||||||
@@ -139,25 +79,30 @@ First-time usage will be slower due to model downloads, but subsequent runs will
|
|||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
**Build everything (recommended):**
|
**Build production app:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run build
|
just build # Build CPU server binary + Tauri installer
|
||||||
```
|
```
|
||||||
This automatically:
|
|
||||||
1. Builds the Python server binary (`./scripts/build-server.sh`)
|
On Windows, to build with CUDA support for local testing:
|
||||||
2. Builds the Tauri desktop app (`cd tauri && bun run tauri build`)
|
|
||||||
|
```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/`.
|
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
|
```bash
|
||||||
bun run build:server
|
just build-server # CPU server binary only
|
||||||
# or
|
just build-server-cuda # CUDA server binary only (Windows)
|
||||||
./scripts/build-server.sh
|
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:**
|
**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
|
```bash
|
||||||
export QWEN_TTS_PATH=~/path/to/your/Qwen3-TTS
|
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`).
|
This makes PyInstaller use your local qwen-tts version instead of the pip-installed package.
|
||||||
|
|
||||||
**Build web app:**
|
|
||||||
```bash
|
|
||||||
cd web
|
|
||||||
bun run build
|
|
||||||
```
|
|
||||||
Output in `web/dist/`
|
|
||||||
|
|
||||||
### Generate OpenAPI Client
|
### Generate OpenAPI Client
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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:**
|
| Platform | GPU Backend | Notes |
|
||||||
- **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)
|
| 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
|
### Project Structure
|
||||||
|
|
||||||
|
|||||||
+82
-4
@@ -10,6 +10,7 @@ import PyInstaller.__main__
|
|||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +63,10 @@ def build_server(cuda=False):
|
|||||||
'--hidden-import', 'backend.utils.hf_progress',
|
'--hidden-import', 'backend.utils.hf_progress',
|
||||||
'--hidden-import', 'backend.utils.validation',
|
'--hidden-import', 'backend.utils.validation',
|
||||||
'--hidden-import', 'backend.cuda_download',
|
'--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', 'torch',
|
||||||
'--hidden-import', 'transformers',
|
'--hidden-import', 'transformers',
|
||||||
'--hidden-import', 'fastapi',
|
'--hidden-import', 'fastapi',
|
||||||
@@ -91,9 +96,10 @@ def build_server(cuda=False):
|
|||||||
'--hidden-import', 'torch.backends.cudnn',
|
'--hidden-import', 'torch.backends.cudnn',
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary under 4GB.
|
# Exclude NVIDIA CUDA packages from CPU-only builds to keep binary small.
|
||||||
# On Linux, pip may pull CUDA-enabled PyTorch by default which includes ~3GB
|
# When building from a venv with CUDA torch installed, PyInstaller would
|
||||||
# of NVIDIA shared libraries that PyInstaller would bundle.
|
# bundle ~3GB of NVIDIA shared libraries. We exclude both the Python
|
||||||
|
# modules and the binary DLLs.
|
||||||
nvidia_packages = [
|
nvidia_packages = [
|
||||||
'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc',
|
'nvidia', 'nvidia.cublas', 'nvidia.cuda_cupti', 'nvidia.cuda_nvrtc',
|
||||||
'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand',
|
'nvidia.cuda_runtime', 'nvidia.cudnn', 'nvidia.cufft', 'nvidia.curand',
|
||||||
@@ -127,7 +133,12 @@ def build_server(cuda=False):
|
|||||||
elif not cuda:
|
elif not cuda:
|
||||||
print("Building for non-Apple Silicon platform - PyTorch only")
|
print("Building for non-Apple Silicon platform - PyTorch only")
|
||||||
|
|
||||||
|
dist_dir = str(backend_dir / 'dist')
|
||||||
|
build_dir = str(backend_dir / 'build')
|
||||||
|
|
||||||
args.extend([
|
args.extend([
|
||||||
|
'--distpath', dist_dir,
|
||||||
|
'--workpath', build_dir,
|
||||||
'--noconfirm',
|
'--noconfirm',
|
||||||
'--clean',
|
'--clean',
|
||||||
])
|
])
|
||||||
@@ -135,12 +146,79 @@ def build_server(cuda=False):
|
|||||||
# Change to backend directory
|
# Change to backend directory
|
||||||
os.chdir(backend_dir)
|
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
|
# 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}")
|
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__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
|
parser = argparse.ArgumentParser(description="Build voicebox-server binary")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ _default_origins = [
|
|||||||
"http://127.0.0.1:17493",
|
"http://127.0.0.1:17493",
|
||||||
"tauri://localhost", # Tauri webview (macOS)
|
"tauri://localhost", # Tauri webview (macOS)
|
||||||
"https://tauri.localhost", # Tauri webview (Windows/Linux)
|
"https://tauri.localhost", # Tauri webview (Windows/Linux)
|
||||||
|
"http://tauri.localhost", # Tauri webview (Windows, some builds)
|
||||||
]
|
]
|
||||||
_env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
|
_env_origins = os.environ.get("VOICEBOX_CORS_ORIGINS", "")
|
||||||
_cors_origins = _default_origins + [o.strip() for o in _env_origins.split(",") if o.strip()]
|
_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..."}
|
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)
|
@app.get("/health", response_model=models.HealthResponse)
|
||||||
async def health():
|
async def health():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
|
|||||||
+119
-5
@@ -6,6 +6,14 @@ absolute imports instead of relative imports.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
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
|
import logging
|
||||||
|
|
||||||
# Set up logging FIRST, before any imports that might fail
|
# 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)
|
logger.error(f"Failed to import required modules: {e}", exc_info=True)
|
||||||
sys.exit(1)
|
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__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
parser = argparse.ArgumentParser(description="voicebox backend server")
|
parser = argparse.ArgumentParser(description="voicebox backend server")
|
||||||
@@ -64,17 +166,21 @@ if __name__ == "__main__":
|
|||||||
default=None,
|
default=None,
|
||||||
help="Data directory for database, profiles, and generated audio",
|
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(
|
parser.add_argument(
|
||||||
"--version",
|
"--version",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Print version and exit",
|
help="Print version and exit (handled above, kept for argparse help)",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.version:
|
if args.parent_pid is not None and args.parent_pid <= 0:
|
||||||
from backend import __version__
|
parser.error("--parent-pid must be a positive integer")
|
||||||
print(f"voicebox-server {__version__}")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
# Detect backend variant from binary name
|
# Detect backend variant from binary name
|
||||||
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
|
# voicebox-server-cuda → sets VOICEBOX_BACKEND_VARIANT=cuda
|
||||||
@@ -87,6 +193,14 @@ if __name__ == "__main__":
|
|||||||
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
|
os.environ["VOICEBOX_BACKEND_VARIANT"] = "cpu"
|
||||||
logger.info("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}")
|
logger.info(f"Parsed arguments: host={args.host}, port={args.port}, data_dir={args.data_dir}")
|
||||||
|
|
||||||
# Set data directory if provided
|
# Set data directory if provided
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ setup-python:
|
|||||||
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
{{ pip }} install -r {{ backend_dir }}/requirements-mlx.txt
|
||||||
fi
|
fi
|
||||||
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
{{ pip }} install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
{{ pip }} install pyinstaller -q
|
||||||
echo "Python environment ready."
|
echo "Python environment ready."
|
||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
@@ -74,6 +75,7 @@ setup-python:
|
|||||||
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
|
& "{{ pip }}" install -r {{ backend_dir }}/requirements.txt
|
||||||
& "{{ pip }}" install --no-deps chatterbox-tts
|
& "{{ pip }}" install --no-deps chatterbox-tts
|
||||||
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
|
& "{{ pip }}" install git+https://github.com/QwenLM/Qwen3-TTS.git
|
||||||
|
& "{{ pip }}" install pyinstaller -q
|
||||||
Write-Host "Python environment ready."
|
Write-Host "Python environment ready."
|
||||||
|
|
||||||
# Install JavaScript dependencies
|
# Install JavaScript dependencies
|
||||||
@@ -105,14 +107,14 @@ dev: _ensure-venv _ensure-sidecar
|
|||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
dev: _ensure-venv _ensure-sidecar
|
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 { \
|
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 ..."; \
|
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; \
|
Start-Sleep -Seconds 2; \
|
||||||
}
|
}; \
|
||||||
Write-Host "Starting Tauri desktop app..."
|
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 } }
|
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
|
# Start backend only
|
||||||
[unix]
|
[unix]
|
||||||
@@ -124,9 +126,14 @@ dev-backend: _ensure-venv
|
|||||||
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
|
& "{{ python }}" -m uvicorn backend.main:app --reload --port 17493
|
||||||
|
|
||||||
# Start Tauri desktop app only (backend must be running separately)
|
# Start Tauri desktop app only (backend must be running separately)
|
||||||
|
[unix]
|
||||||
dev-frontend: _ensure-sidecar
|
dev-frontend: _ensure-sidecar
|
||||||
cd {{ tauri_dir }} && bun run tauri dev
|
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)
|
# Start backend (if not already running) + web app (no Tauri)
|
||||||
[unix]
|
[unix]
|
||||||
dev-web: _ensure-venv
|
dev-web: _ensure-venv
|
||||||
@@ -149,14 +156,14 @@ dev-web: _ensure-venv
|
|||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
dev-web: _ensure-venv
|
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 { \
|
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 ..."; \
|
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; \
|
Start-Sleep -Seconds 2; \
|
||||||
}
|
}; \
|
||||||
Write-Host "Starting web app..."
|
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 } }
|
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
|
# Kill all dev processes
|
||||||
[unix]
|
[unix]
|
||||||
@@ -175,23 +182,56 @@ kill:
|
|||||||
# Build everything (server binary + desktop app)
|
# Build everything (server binary + desktop app)
|
||||||
build: build-server build-tauri
|
build: build-server build-tauri
|
||||||
|
|
||||||
# Build Python server binary
|
# Build Python server binary (CPU)
|
||||||
[unix]
|
[unix]
|
||||||
build-server: _ensure-venv
|
build-server: _ensure-venv
|
||||||
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
|
PATH="{{ venv_bin }}:$PATH" ./scripts/build-server.sh
|
||||||
|
|
||||||
[windows]
|
[windows]
|
||||||
build-server: _ensure-venv
|
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
|
# Build Tauri desktop app
|
||||||
|
[unix]
|
||||||
build-tauri:
|
build-tauri:
|
||||||
cd {{ tauri_dir }} && bun run tauri build
|
cd {{ tauri_dir }} && bun run tauri build
|
||||||
|
|
||||||
|
[windows]
|
||||||
|
build-tauri:
|
||||||
|
Set-Location "{{ tauri_dir }}"; bun run tauri build
|
||||||
|
|
||||||
# Build web app
|
# Build web app
|
||||||
|
[unix]
|
||||||
build-web:
|
build-web:
|
||||||
cd {{ web_dir }} && bun run build
|
cd {{ web_dir }} && bun run build
|
||||||
|
|
||||||
|
[windows]
|
||||||
|
build-web:
|
||||||
|
Set-Location "{{ web_dir }}"; bun run build
|
||||||
|
|
||||||
# ─── Code Quality ────────────────────────────────────────────────────
|
# ─── Code Quality ────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Run all checks (lint + format + typecheck)
|
# Run all checks (lint + format + typecheck)
|
||||||
@@ -213,8 +253,13 @@ fix:
|
|||||||
# ─── Database ─────────────────────────────────────────────────────────
|
# ─── Database ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Initialize SQLite database
|
# Initialize SQLite database
|
||||||
|
[unix]
|
||||||
db-init: _ensure-venv
|
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)
|
# Reset database (delete + reinit)
|
||||||
[unix]
|
[unix]
|
||||||
|
|||||||
+47
-214
@@ -282,6 +282,7 @@ async fn start_server(
|
|||||||
.ok_or_else(|| "Invalid data dir path".to_string())?
|
.ok_or_else(|| "Invalid data dir path".to_string())?
|
||||||
.to_string();
|
.to_string();
|
||||||
let port_str = SERVER_PORT.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);
|
let is_remote = remote.unwrap_or(false);
|
||||||
|
|
||||||
// Resolve the custom models directory from the parameter or stored state
|
// 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 {
|
let spawn_result = if let Some(ref cuda_path) = cuda_binary {
|
||||||
println!("Launching CUDA backend: {:?}", cuda_path);
|
println!("Launching CUDA backend: {:?}", cuda_path);
|
||||||
let mut cmd = app.shell().command(cuda_path.to_str().unwrap());
|
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 {
|
if is_remote {
|
||||||
cmd = cmd.args(["--host", "0.0.0.0"]);
|
cmd = cmd.args(["--host", "0.0.0.0"]);
|
||||||
}
|
}
|
||||||
@@ -304,7 +305,7 @@ async fn start_server(
|
|||||||
cmd.spawn()
|
cmd.spawn()
|
||||||
} else {
|
} else {
|
||||||
// Use the bundled CPU sidecar
|
// 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 {
|
if is_remote {
|
||||||
sidecar = sidecar.args(["--host", "0.0.0.0"]);
|
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))
|
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]
|
#[command]
|
||||||
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
||||||
let pid = state.server_pid.lock().unwrap().take();
|
let pid = state.server_pid.lock().unwrap().take();
|
||||||
let _child = state.child.lock().unwrap().take();
|
let _child = state.child.lock().unwrap().take();
|
||||||
|
|
||||||
if let Some(pid) = pid {
|
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)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -569,62 +516,25 @@ async fn stop_server(state: State<'_, ServerState>) -> Result<(), String> {
|
|||||||
let _ = Command::new("kill")
|
let _ = Command::new("kill")
|
||||||
.args(["-9", &pid.to_string()])
|
.args(["-9", &pid.to_string()])
|
||||||
.output();
|
.output();
|
||||||
|
|
||||||
|
println!("stop_server: Process group kill completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
// Layer 1: Try graceful HTTP shutdown first
|
// Send graceful shutdown via HTTP — the server's parent-pid watchdog
|
||||||
println!("Attempting graceful shutdown via HTTP...");
|
// will also handle cleanup if this app process exits.
|
||||||
|
println!("Sending graceful shutdown via HTTP...");
|
||||||
let client = reqwest::blocking::Client::builder()
|
let client = reqwest::blocking::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(2))
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
.build()
|
.build()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let shutdown_result = client
|
let _ = client
|
||||||
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
.post(&format!("http://127.0.0.1:{}/shutdown", SERVER_PORT))
|
||||||
.send();
|
.send();
|
||||||
|
|
||||||
if shutdown_result.is_ok() {
|
println!("Shutdown request sent (server watchdog will handle cleanup)");
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,9 +708,17 @@ pub fn run() {
|
|||||||
play_audio_to_devices,
|
play_audio_to_devices,
|
||||||
stop_audio_playback
|
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 {
|
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();
|
api.prevent_close();
|
||||||
|
|
||||||
// Emit event to frontend to check setting and stop server if needed
|
// 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", ()) {
|
if let Err(e) = app_handle.emit("window-close-requested", ()) {
|
||||||
eprintln!("Failed to emit window-close-requested event: {}", e);
|
eprintln!("Failed to emit window-close-requested event: {}", e);
|
||||||
// If event emission fails, allow close anyway
|
|
||||||
window.close().ok();
|
window.close().ok();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up listener for frontend response
|
// Set up listener for frontend response
|
||||||
let window_for_close = window.clone();
|
let window_for_close = window.clone();
|
||||||
|
let closing_for_timeout = closing.clone();
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<()>();
|
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 |_| {
|
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(());
|
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 {
|
tauri::async_runtime::spawn(async move {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = rx.recv() => {
|
_ = rx.recv() => {
|
||||||
// Frontend responded, close window
|
|
||||||
window_for_close.close().ok();
|
window_for_close.close().ok();
|
||||||
}
|
}
|
||||||
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
|
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {
|
||||||
// Timeout - close anyway
|
|
||||||
eprintln!("Window close timeout, closing anyway");
|
eprintln!("Window close timeout, closing anyway");
|
||||||
window_for_close.close().ok();
|
window_for_close.close().ok();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Clean up listener
|
|
||||||
window_for_close.unlisten(listener_id);
|
window_for_close.unlisten(listener_id);
|
||||||
|
closing_for_timeout.store(false, std::sync::atomic::Ordering::SeqCst);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
})
|
}})
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app, event| {
|
.run(|app, event| {
|
||||||
|
let _ = &app; // used on unix
|
||||||
match &event {
|
match &event {
|
||||||
RunEvent::Exit => {
|
RunEvent::Exit => {
|
||||||
println!("=================================================================");
|
|
||||||
println!("RunEvent::Exit received - checking server cleanup");
|
|
||||||
let state = app.state::<ServerState>();
|
let state = app.state::<ServerState>();
|
||||||
let keep_running = *state.keep_running_on_close.lock().unwrap();
|
let keep_running = *state.keep_running_on_close.lock().unwrap();
|
||||||
println!("keep_running_on_close = {}", keep_running);
|
|
||||||
|
if keep_running {
|
||||||
if !keep_running {
|
// Tell the server to disable its watchdog so it survives
|
||||||
// Get the stored PID for process group killing
|
// after this process exits.
|
||||||
let pid = state.server_pid.lock().unwrap().take();
|
println!("Keep server running: disabling watchdog...");
|
||||||
// Also take the child to clean up
|
let client = reqwest::blocking::Client::builder()
|
||||||
let _child = state.child.lock().unwrap().take();
|
.timeout(std::time::Duration::from_secs(2))
|
||||||
|
.build()
|
||||||
if let Some(pid) = pid {
|
.unwrap();
|
||||||
println!("Killing server process group with PID: {}", pid);
|
let _ = client
|
||||||
|
.post(&format!("http://127.0.0.1:{}/watchdog/disable", SERVER_PORT))
|
||||||
// Kill the entire process group on Unix systems
|
.send();
|
||||||
// Using negative PID sends signal to all processes in the group
|
} else {
|
||||||
#[cfg(unix)]
|
// 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;
|
use std::process::Command;
|
||||||
// First try SIGTERM to the process group
|
let _ = Command::new("kill")
|
||||||
let pgid_kill = Command::new("kill")
|
|
||||||
.args(["-TERM", "--", &format!("-{}", pid)])
|
.args(["-TERM", "--", &format!("-{}", pid)])
|
||||||
.output();
|
.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));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
|
||||||
// Force kill with SIGKILL
|
|
||||||
let _ = Command::new("kill")
|
let _ = Command::new("kill")
|
||||||
.args(["-9", "--", &format!("-{}", pid)])
|
.args(["-9", "--", &format!("-{}", pid)])
|
||||||
.output();
|
.output();
|
||||||
let _ = Command::new("kill")
|
let _ = Command::new("kill")
|
||||||
.args(["-9", &pid.to_string()])
|
.args(["-9", &pid.to_string()])
|
||||||
.output();
|
.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, .. } => {
|
RunEvent::ExitRequested { api, .. } => {
|
||||||
println!("RunEvent::ExitRequested received");
|
println!("RunEvent::ExitRequested received");
|
||||||
|
|||||||
Reference in New Issue
Block a user