fix: GUI startup with external server + data refresh on server switch

Two fixes for issue #312:

1. GUI stuck on loading screen when backend is already running externally
   (e.g. via python/uvicorn/Docker):

   - Rust: add HTTP health check fallback when the process on the port
     doesn't have 'voicebox' in its name. If /health responds with a
     valid Voicebox response, reuse the server instead of erroring.
   - Frontend: when startServer() fails, fall back to polling the
     health endpoint every 2s instead of permanently blocking.

2. No data refresh when switching server URLs in settings:

   - serverStore.setServerUrl() now invalidates all React Query caches
     when the URL actually changes, so profiles/history/models/stories
     are re-fetched from the new server.
   - Export queryClient from main.tsx for store-level cache invalidation.

Fixes #312
This commit is contained in:
James Pine
2026-03-18 10:59:59 -07:00
parent ffc1b54812
commit eb5869e59f
4 changed files with 98 additions and 11 deletions
+19
View File
@@ -4,6 +4,7 @@ import voiceboxLogo from '@/assets/voicebox-logo.png';
import ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { apiClient } from '@/lib/api/client';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext';
@@ -122,6 +123,24 @@ function App() {
serverStartingRef.current = false;
// @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false;
// Fall back to polling: the server may already be running externally
// (e.g. started via python/uvicorn/Docker). Poll the health endpoint
// until it responds, then transition to the main UI.
console.log('Falling back to health-check polling...');
const pollInterval = setInterval(async () => {
try {
await apiClient.getHealth();
console.log('External server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
// Server not ready yet, keep polling
}
}, 2000);
// Stop polling after 2 minutes to avoid polling forever
setTimeout(() => clearInterval(pollInterval), 120_000);
});
// Cleanup: stop server on actual unmount (not StrictMode remount)
+1 -1
View File
@@ -5,7 +5,7 @@ import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
+20 -2
View File
@@ -30,11 +30,29 @@ interface ServerStore {
setCustomModelsDir: (dir: string | null) => void;
}
/**
* Invalidate all React Query caches and reset UI selection state.
* Called when the server URL changes so stale data from the previous
* server is not shown.
*/
function invalidateAllServerData() {
// Lazy import to avoid circular dependency (main.tsx -> serverStore -> main.tsx)
import('@/main').then(({ queryClient }) => {
queryClient.invalidateQueries();
});
}
export const useServerStore = create<ServerStore>()(
persist(
(set) => ({
(set, get) => ({
serverUrl: 'http://127.0.0.1:17493',
setServerUrl: (url) => set({ serverUrl: url }),
setServerUrl: (url) => {
const prev = get().serverUrl;
set({ serverUrl: url });
if (url !== prev) {
invalidateAllServerData();
}
},
isConnected: false,
setIsConnected: (connected) => set({ isConnected: connected }),
+58 -8
View File
@@ -53,6 +53,35 @@ fn find_voicebox_pid_on_port(port: u16) -> Option<u32> {
None
}
/// Check if a Voicebox server is responding on the given port.
///
/// Sends an HTTP GET to `/health` and returns `true` if the response
/// contains the expected JSON field (`"status"`), confirming it's
/// a Voicebox backend rather than an unrelated service.
#[allow(dead_code)] // Used in platform-specific cfg blocks
fn check_health(port: u16) -> bool {
let url = format!("http://127.0.0.1:{}/health", port);
match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(3))
.build()
{
Ok(client) => match client.get(&url).send() {
Ok(resp) => {
if !resp.status().is_success() {
return false;
}
// Verify the body looks like a Voicebox health response
match resp.text() {
Ok(body) => body.contains("status"),
Err(_) => false,
}
}
Err(_) => false,
},
Err(_) => false,
}
}
struct ServerState {
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
server_pid: Mutex<Option<u32>>,
@@ -80,7 +109,8 @@ async fn start_server(
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
// Check if a voicebox server is already running on our port (from previous session with keep_running=true)
// Check if a voicebox server is already running on our port (from previous session with keep_running=true,
// or an externally started server e.g. via `python`, `uvicorn`, Docker, etc.)
#[cfg(unix)]
{
use std::process::Command;
@@ -101,6 +131,20 @@ async fn start_server(
*state.server_pid.lock().unwrap() = Some(pid);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
} else {
// Process name doesn't contain "voicebox" — could be an external
// Python/uvicorn/Docker server. Verify via HTTP health check.
println!("Port {} in use by '{}' (PID: {}), checking if it's a Voicebox server...", SERVER_PORT, command, pid_str);
if check_health(SERVER_PORT) {
println!("Health check passed — reusing external server on port {}", SERVER_PORT);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
println!("Health check failed — port is occupied by a non-Voicebox process");
return Err(format!(
"Port {} is already in use by another application ({}). \
Close it or change the Voicebox server port.",
SERVER_PORT, command
));
}
}
}
@@ -114,18 +158,24 @@ async fn start_server(
&format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(),
std::time::Duration::from_secs(1),
).is_ok() {
// Port is in use — check if it's a voicebox process
// Port is in use — check if it's a voicebox process by name first
if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) {
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
*state.server_pid.lock().unwrap() = Some(pid);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
} else {
return Err(format!(
"Port {} is already in use by another application. \
Close the other application or change the Voicebox port.",
SERVER_PORT
));
}
// Process name doesn't match — could be an external Python/Docker server.
// Verify via HTTP health check before giving up.
println!("Port {} in use by unknown process, checking if it's a Voicebox server...", SERVER_PORT);
if check_health(SERVER_PORT) {
println!("Health check passed — reusing external server on port {}", SERVER_PORT);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT));
}
return Err(format!(
"Port {} is already in use by another application. \
Close the other application or change the Voicebox port.",
SERVER_PORT
));
}
}