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 ShinyText from '@/components/ShinyText';
import { TitleBarDragRegion } from '@/components/TitleBarDragRegion'; import { TitleBarDragRegion } from '@/components/TitleBarDragRegion';
import { useAutoUpdater } from '@/hooks/useAutoUpdater'; import { useAutoUpdater } from '@/hooks/useAutoUpdater';
import { apiClient } from '@/lib/api/client';
import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { TOP_SAFE_AREA_PADDING } from '@/lib/constants/ui';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
import { usePlatform } from '@/platform/PlatformContext'; import { usePlatform } from '@/platform/PlatformContext';
@@ -122,6 +123,24 @@ function App() {
serverStartingRef.current = false; serverStartingRef.current = false;
// @ts-expect-error - adding property to window // @ts-expect-error - adding property to window
window.__voiceboxServerStartedByApp = false; 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) // 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 App from './App';
import './index.css'; import './index.css';
const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
+20 -2
View File
@@ -30,11 +30,29 @@ interface ServerStore {
setCustomModelsDir: (dir: string | null) => void; 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>()( export const useServerStore = create<ServerStore>()(
persist( persist(
(set) => ({ (set, get) => ({
serverUrl: 'http://127.0.0.1:17493', 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, isConnected: false,
setIsConnected: (connected) => set({ isConnected: connected }), setIsConnected: (connected) => set({ isConnected: connected }),
+58 -8
View File
@@ -53,6 +53,35 @@ fn find_voicebox_pid_on_port(port: u16) -> Option<u32> {
None 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 { struct ServerState {
child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>, child: Mutex<Option<tauri_plugin_shell::process::CommandChild>>,
server_pid: Mutex<Option<u32>>, server_pid: Mutex<Option<u32>>,
@@ -80,7 +109,8 @@ async fn start_server(
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); 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)] #[cfg(unix)]
{ {
use std::process::Command; use std::process::Command;
@@ -101,6 +131,20 @@ async fn start_server(
*state.server_pid.lock().unwrap() = Some(pid); *state.server_pid.lock().unwrap() = Some(pid);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); 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(), &format!("127.0.0.1:{}", SERVER_PORT).parse().unwrap(),
std::time::Duration::from_secs(1), std::time::Duration::from_secs(1),
).is_ok() { ).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) { if let Some(pid) = find_voicebox_pid_on_port(SERVER_PORT) {
println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid); println!("Found existing voicebox-server on port {} (PID: {}), reusing it", SERVER_PORT, pid);
*state.server_pid.lock().unwrap() = Some(pid); *state.server_pid.lock().unwrap() = Some(pid);
return Ok(format!("http://127.0.0.1:{}", SERVER_PORT)); 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
));
} }
} }