Skip to content

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

Open
jamiepine wants to merge 1 commit intomainfrom
fix/startup-and-server-switch
Open

fix: GUI startup with external server + data refresh on server switch#319
jamiepine wants to merge 1 commit intomainfrom
fix/startup-and-server-switch

Conversation

@jamiepine
Copy link
Owner

@jamiepine jamiepine commented Mar 18, 2026

Summary

  • Fixes GUI getting permanently stuck on the loading screen when the backend server is already running externally (via python/uvicorn/Docker)
  • Fixes stale data persisting when switching server URLs in Settings

Fixes #312

Problem 1: Startup loop with external server

The Rust start_server command checks if a process named "voicebox" is listening on port 17493. When users start the backend externally (as python, 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 and serverReady is never set to true, leaving the user permanently stuck on the loading screen.

Fix (two layers):

  • Rust: When the process name doesn't match "voicebox", do an HTTP GET to /health before giving up. If it responds with a valid Voicebox health payload, reuse the server.
  • Frontend: When startServer() fails, fall back to polling /health every 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 serverUrl and retained stale data from the previous server for up to 5 minutes.

Fix: setServerUrl() now calls queryClient.invalidateQueries() when the URL actually changes, forcing all queries to refetch from the new server.

Changes

File Change
tauri/src-tauri/src/main.rs Add check_health() function; use it as fallback on both Unix and Windows when process name doesn't match
app/src/App.tsx Add health-check polling fallback when startServer() fails
app/src/stores/serverStore.ts Invalidate all React Query caches on server URL change
app/src/main.tsx Export queryClient for store-level cache invalidation

Summary by CodeRabbit

  • Bug Fixes

    • Improved startup resilience with automatic server health monitoring when initial startup fails.
    • Enhanced handling of port conflicts with external services.
  • Improvements

    • App now refreshes cached data when switching to a different server.

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
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

The 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

Cohort / File(s) Summary
Frontend Startup Resilience
app/src/App.tsx
Adds health-polling fallback that checks the health endpoint every 2 seconds when server startup fails, marking the server as ready upon successful response or timing out after 2 minutes. Prevents GUI from getting stuck when backend is already running.
React Query Configuration
app/src/main.tsx
Exports queryClient as a module-level constant to enable cache invalidation logic in server state management.
Server State Management
app/src/stores/serverStore.ts
Introduces invalidateAllServerData helper that lazily imports the query client and clears all React Query caches when the server URL changes, ensuring stale data from previous servers is removed.
Backend Port Reuse with Health Check
tauri/src-tauri/src/main.rs
Adds check_health(port) utility function that validates external servers via HTTP health endpoint. Enhanced start_server to differentiate between Voicebox and non-Voicebox processes occupying the port, reusing the port if an external server is healthy.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A bouncy new health-check hops in,
No more startup loops or server slips!
Polls the endpoint with patient cheer,
Clears the cache when switching gear.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the two main changes: fixing GUI startup with external servers and implementing data refresh when switching servers.
Linked Issues check ✅ Passed All code changes comprehensively address both linked issue #312 objectives: external server startup support and data refresh on server switch.
Out of Scope Changes check ✅ Passed All changes directly support the two linked issue objectives; no out-of-scope modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/startup-and-server-switch
📝 Coding Plan
  • Generate coding plan for human review comments

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Don’t swallow real startup failures behind the polling fallback.

This catch now handles every startServer() error. If the failure is a missing sidecar, signing issue, or real port conflict, the timeout only stops polling; serverReady stays false, 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 shared QueryClient into a side-effect-free module.

Importing @/main from a Zustand store couples cache invalidation to the React bootstrap entrypoint and is why this has to stay lazy. A tiny queryClient.ts module would keep the same behavior without depending on ReactDOM.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

📥 Commits

Reviewing files that changed from the base of the PR and between ffc1b54 and eb5869e.

📒 Files selected for processing (4)
  • app/src/App.tsx
  • app/src/main.tsx
  • app/src/stores/serverStore.ts
  • tauri/src-tauri/src/main.rs

Comment on lines +131 to +137
const pollInterval = setInterval(async () => {
try {
await apiClient.getHealth();
console.log('External server detected via health check');
clearInterval(pollInterval);
setServerReady(true);
} catch {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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().

Comment on lines +73 to +75
// Verify the body looks like a Voicebox health response
match resp.text() {
Ok(body) => body.contains("status"),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
// 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GUI Fails to Start When Backend Server Is Already Running + No Way to Reload Content When Switching Servers

1 participant