fix: GUI startup with external server + data refresh on server switch#319
fix: GUI startup with external server + data refresh on server switch#319
Conversation
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
📝 WalkthroughWalkthroughThe pull request implements a resilience mechanism for handling already-running backend servers and adds cache invalidation when switching servers. It introduces health-check endpoints in the backend startup logic, polling fallback in the frontend when initial startup fails, and clears cached data when the server URL changes. Changes
Sequence DiagramsequenceDiagram
participant GUI as GUI (App.tsx)
participant Backend as Backend (Tauri)
participant Health as Health Endpoint
participant Cache as React Query Cache
GUI->>Backend: Attempt to start server
alt Port in use by non-Voicebox process
Backend->>Health: check_health(port)
alt Health check passes
Health-->>Backend: Healthy response with status
Backend-->>GUI: Reuse existing server port
else Health check fails
Backend-->>GUI: Port in use error
end
else Port available
Backend-->>GUI: Server started successfully
end
alt Server not ready immediately
GUI->>Health: Poll /health (every 2 sec)
loop Until healthy or 2 min timeout
Health-->>GUI: Health status
alt Healthy response
GUI->>Cache: invalidateAllServerData()
Cache-->>Cache: Clear all queries
GUI->>GUI: Mark server ready
end
end
end
alt Server URL changes
GUI->>Cache: invalidateAllServerData()
Cache-->>Cache: Clear previous server data
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
app/src/App.tsx (1)
121-143:⚠️ Potential issue | 🟠 MajorDon’t swallow real startup failures behind the polling fallback.
This
catchnow handles everystartServer()error. If the failure is a missing sidecar, signing issue, or real port conflict, the timeout only stops polling;serverReadystaysfalse, so the loading screen never exits. Please either gate this fallback to the “external server already running” case or set an explicit startup-error state when the 2-minute window expires.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/App.tsx` around lines 121 - 143, The catch block for startServer() currently always falls back to health-check polling and swallows real startup failures; update it to distinguish "external server already running" errors from genuine startup failures and set an explicit startup-error state if polling times out. Concretely: in the catch for startServer(), inspect the thrown error (from startServer()) and only start the apiClient.getHealth() polling when the error indicates an external/connection-refusal situation (e.g., ECONNREFUSED/connection refused or a well-known status); otherwise set serverStartingRef.current = false and set a new state like startupError (via setStartupError(true)) to surface the error. Additionally, when you do start polling, ensure the 2-minute timeout not only clears the interval but also sets startupError(true) (and serverStartingRef.current = false) so the UI can exit the loading state and show the failure; reference startServer(), serverStartingRef, window.__voiceboxServerStartedByApp, apiClient.getHealth(), setServerReady, and add setStartupError to implement this behavior.
🧹 Nitpick comments (1)
app/src/stores/serverStore.ts (1)
38-42: Move the sharedQueryClientinto a side-effect-free module.Importing
@/mainfrom a Zustand store couples cache invalidation to the React bootstrap entrypoint and is why this has to stay lazy. A tinyqueryClient.tsmodule would keep the same behavior without depending onReactDOM.createRoot(...).🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/src/stores/serverStore.ts` around lines 38 - 42, The store's invalidateAllServerData currently lazy-imports '@/main' which couples the Zustand store to the React bootstrap; instead create a side-effect-free module (e.g., export a shared QueryClient instance from a new queryClient.ts) and use that directly: move the QueryClient creation into the new module (exporting a named queryClient), update invalidateAllServerData to import that queryClient and call queryClient.invalidateQueries(), and remove any dependency on '@/main' so invalidateAllServerData and the store remain side-effect-free and safe to import at module-evaluation time.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/src/App.tsx`:
- Around line 131-137: The poll callback in App.tsx currently treats any
successful apiClient.getHealth() as a ready signal; change it to validate the
parsed health payload for Voicebox-specific fields (e.g., a version,
serviceName, or expected capability flag) before calling setServerReady(true)
and clearInterval(pollInterval). After awaiting apiClient.getHealth(), inspect
the returned JSON (from apiClient.getHealth), confirm the required keys/values
are present and match expected formats, only then log success and
setServerReady; otherwise log the mismatch and keep polling. Target symbols: the
setInterval handler using pollInterval, apiClient.getHealth(), and
setServerReady().
In `@tauri/src-tauri/src/main.rs`:
- Around line 73-75: Replace the fragile substring check on resp.text() with
proper JSON deserialization into the expected health shape: parse resp.text()
(or resp.json()) into the HealthResponse-like struct used by the backend
(referencing the HealthResponse shape in backend/models.py) and validate key
fields (e.g., status and any stable version/id fields) before accepting the port
as Voicebox; update the match arm that currently uses body.contains("status") to
attempt deserialization, handle errors by treating non-conforming responses as
invalid, and only return true when the parsed object matches the expected
fields/values.
---
Outside diff comments:
In `@app/src/App.tsx`:
- Around line 121-143: The catch block for startServer() currently always falls
back to health-check polling and swallows real startup failures; update it to
distinguish "external server already running" errors from genuine startup
failures and set an explicit startup-error state if polling times out.
Concretely: in the catch for startServer(), inspect the thrown error (from
startServer()) and only start the apiClient.getHealth() polling when the error
indicates an external/connection-refusal situation (e.g.,
ECONNREFUSED/connection refused or a well-known status); otherwise set
serverStartingRef.current = false and set a new state like startupError (via
setStartupError(true)) to surface the error. Additionally, when you do start
polling, ensure the 2-minute timeout not only clears the interval but also sets
startupError(true) (and serverStartingRef.current = false) so the UI can exit
the loading state and show the failure; reference startServer(),
serverStartingRef, window.__voiceboxServerStartedByApp, apiClient.getHealth(),
setServerReady, and add setStartupError to implement this behavior.
---
Nitpick comments:
In `@app/src/stores/serverStore.ts`:
- Around line 38-42: The store's invalidateAllServerData currently lazy-imports
'@/main' which couples the Zustand store to the React bootstrap; instead create
a side-effect-free module (e.g., export a shared QueryClient instance from a new
queryClient.ts) and use that directly: move the QueryClient creation into the
new module (exporting a named queryClient), update invalidateAllServerData to
import that queryClient and call queryClient.invalidateQueries(), and remove any
dependency on '@/main' so invalidateAllServerData and the store remain
side-effect-free and safe to import at module-evaluation time.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1593773e-6a50-49d2-9952-b6e289c49d11
📒 Files selected for processing (4)
app/src/App.tsxapp/src/main.tsxapp/src/stores/serverStore.tstauri/src-tauri/src/main.rs
| const pollInterval = setInterval(async () => { | ||
| try { | ||
| await apiClient.getHealth(); | ||
| console.log('External server detected via health check'); | ||
| clearInterval(pollInterval); | ||
| setServerReady(true); | ||
| } catch { |
There was a problem hiding this comment.
Validate the health payload before marking the app ready.
apiClient.getHealth() only guarantees response.ok plus JSON parsing in app/src/lib/api/client.ts:58-82. A different service with a generic /health endpoint on that URL will still flip serverReady to true, even though the Tauri startup path may have rejected it. Check for Voicebox-specific fields before accepting the fallback.
Possible fix
const pollInterval = setInterval(async () => {
try {
- await apiClient.getHealth();
+ const health = await apiClient.getHealth();
+ if (
+ health?.status !== 'healthy' ||
+ typeof health.model_loaded !== 'boolean' ||
+ typeof health.gpu_available !== 'boolean'
+ ) {
+ return;
+ }
console.log('External server detected via health check');
clearInterval(pollInterval);
setServerReady(true);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const pollInterval = setInterval(async () => { | |
| try { | |
| await apiClient.getHealth(); | |
| console.log('External server detected via health check'); | |
| clearInterval(pollInterval); | |
| setServerReady(true); | |
| } catch { | |
| const pollInterval = setInterval(async () => { | |
| try { | |
| const health = await apiClient.getHealth(); | |
| if ( | |
| health?.status !== 'healthy' || | |
| typeof health.model_loaded !== 'boolean' || | |
| typeof health.gpu_available !== 'boolean' | |
| ) { | |
| return; | |
| } | |
| console.log('External server detected via health check'); | |
| clearInterval(pollInterval); | |
| setServerReady(true); | |
| } catch { |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/src/App.tsx` around lines 131 - 137, The poll callback in App.tsx
currently treats any successful apiClient.getHealth() as a ready signal; change
it to validate the parsed health payload for Voicebox-specific fields (e.g., a
version, serviceName, or expected capability flag) before calling
setServerReady(true) and clearInterval(pollInterval). After awaiting
apiClient.getHealth(), inspect the returned JSON (from apiClient.getHealth),
confirm the required keys/values are present and match expected formats, only
then log success and setServerReady; otherwise log the mismatch and keep
polling. Target symbols: the setInterval handler using pollInterval,
apiClient.getHealth(), and setServerReady().
| // Verify the body looks like a Voicebox health response | ||
| match resp.text() { | ||
| Ok(body) => body.contains("status"), |
There was a problem hiding this comment.
Parse the health body instead of searching for "status".
Any HTTP service that returns 200 and happens to include "status" in the body can be reused here. That means a conventional /health response from another app on port 17493 can masquerade as Voicebox and the GUI will attach to the wrong backend. The backend already has a stable HealthResponse shape in backend/models.py:163-175; validate a few real fields/values before treating the port as reusable.
Possible fix
- match resp.text() {
- Ok(body) => body.contains("status"),
- Err(_) => false,
- }
+ match resp.json::<serde_json::Value>() {
+ Ok(body) => {
+ body.get("status").and_then(|v| v.as_str()) == Some("healthy")
+ && body.get("model_loaded").map(|v| v.is_boolean()).unwrap_or(false)
+ && body.get("gpu_available").map(|v| v.is_boolean()).unwrap_or(false)
+ }
+ Err(_) => false,
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Verify the body looks like a Voicebox health response | |
| match resp.text() { | |
| Ok(body) => body.contains("status"), | |
| // Verify the body looks like a Voicebox health response | |
| match resp.json::<serde_json::Value>() { | |
| Ok(body) => { | |
| body.get("status").and_then(|v| v.as_str()) == Some("healthy") | |
| && body.get("model_loaded").map(|v| v.is_boolean()).unwrap_or(false) | |
| && body.get("gpu_available").map(|v| v.is_boolean()).unwrap_or(false) | |
| } | |
| Err(_) => false, | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@tauri/src-tauri/src/main.rs` around lines 73 - 75, Replace the fragile
substring check on resp.text() with proper JSON deserialization into the
expected health shape: parse resp.text() (or resp.json()) into the
HealthResponse-like struct used by the backend (referencing the HealthResponse
shape in backend/models.py) and validate key fields (e.g., status and any stable
version/id fields) before accepting the port as Voicebox; update the match arm
that currently uses body.contains("status") to attempt deserialization, handle
errors by treating non-conforming responses as invalid, and only return true
when the parsed object matches the expected fields/values.
Summary
Fixes #312
Problem 1: Startup loop with external server
The Rust
start_servercommand checks if a process named "voicebox" is listening on port 17493. When users start the backend externally (aspython,uvicorn, etc.), the process name check fails. On Windows this returns an immediate error; on Unix it falls through and tries to spawn a sidecar on an occupied port. Either way,startServer()rejects andserverReadyis never set totrue, leaving the user permanently stuck on the loading screen.Fix (two layers):
/healthbefore giving up. If it responds with a valid Voicebox health payload, reuse the server.startServer()fails, fall back to polling/healthevery 2s (up to 2 minutes). This covers edge cases where the Rust detection fails but the server is reachable.Problem 2: No data refresh on server switch
When changing the server URL in Settings, only the Zustand store value was updated. React Query caches (profiles, history, stories, models) were keyed without
serverUrland retained stale data from the previous server for up to 5 minutes.Fix:
setServerUrl()now callsqueryClient.invalidateQueries()when the URL actually changes, forcing all queries to refetch from the new server.Changes
tauri/src-tauri/src/main.rscheck_health()function; use it as fallback on both Unix and Windows when process name doesn't matchapp/src/App.tsxstartServer()failsapp/src/stores/serverStore.tsapp/src/main.tsxqueryClientfor store-level cache invalidationSummary by CodeRabbit
Bug Fixes
Improvements