From fd14cb3bdb1f89af6b259dfd34cd5a1dc078fb77 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 19 Aug 2026 14:07:30 -0700 Subject: [PATCH] [eric] apps: a preview whose dev server never binds shows an honest failure card with retry instead of spinning Starting preview forever (Alex's report) --- backend/apps/outputs/outputs.py | 1 + backend/apps/outputs/runtime.py | 4 ++++ backend/main.py | 1 + .../Dashboard/cards/DashboardViewCard.tsx | 20 ++++++++++++++++++- .../src/shared/hooks/useRuntimePreviewUrl.ts | 6 +++++- 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index fa7c445d..c40571b4 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -497,6 +497,7 @@ def runtime_status_payload(workspace_id: str, instance: int = 1) -> dict: "running": rt.running, # 'spawned' vs 'serving': ready flips only once the primary port answered the bind poll (and un-flips when the process dies or is frozen). "ready": rt.ready, + "boot_failed": bool(getattr(rt, "boot_failed", False)), # True when the app is served as a built bundle with no dev-server process (ENG-209). "serve_static": rt.serve_static, "port": rt.port, diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index fb84b9cd..5cd042f4 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -113,6 +113,8 @@ class AppRuntime: self.serve_static: bool = False # New-mode only: flips True once something is actually listening on frontend_port (we kick off a background poll task in p_start_new_mode). frontend_url returns null until this flips, so the preview pane doesn't try to navigate to an unbound port and show a "Site can't be reached" error mid-npm-install. self.p_frontend_ready: bool = False + # Set when the bind poll gave up; the status payload carries it so the card can stop spinning honestly. + self.boot_failed = False # True only while a live bind-poll task owns the global vite boot lock; start() releases it otherwise. self.p_boot_lock_handed_off: bool = False # True while the process tree is SIGSTOP'd in the idle pool. A frozen vite still holds its port but can't answer it, so frontend_url must stay null while suspended (else the webview loads a dead port = the ERR_FAILED on fast app-switching). @@ -427,6 +429,8 @@ class AppRuntime: pass await asyncio.sleep(FRONTEND_BIND_POLL_INTERVAL) # Timed out; keep the runtime up (Terminal might show useful errors) but surface why the preview never appeared. + # boot_failed reaches the CARD: the log line lands in a Terminal most users never open, so the spinner span "Starting preview" forever (Alex's report). + self.boot_failed = True self.p_broadcast(LogLine( "runtime", f"[runtime] frontend did NOT bind on port {port} after " diff --git a/backend/main.py b/backend/main.py index bf613c2d..2dcfdc8b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -315,6 +315,7 @@ async def websocket_runtime_logs(websocket: WebSocket, workspace_id: str, instan # The property is the one honest gate (crashed/suspended vite -> None, static -> serve URL); a duplicate running check here nulled every serve-static app's URL, whose card then waited forever on a process that never exists (the "Starting preview" wedge). "frontend_url": rt.frontend_url, "is_new_mode": rt.is_new_mode, + "boot_failed": bool(getattr(rt, "boot_failed", False)), }, } diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 4d3193c2..3c3e2776 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -87,6 +87,21 @@ interface Props { } // The app card's loading state while its runtime spins up. One soft pulse, calm copy, and an honest hint only after 9s, a freshly-imported app installs its deps on first open, which is the slow case worth explaining instead of leaving the user staring at a dead screen. +const BootFailedBody: React.FC<{ onRetry: () => void }> = ({ onRetry }) => { + const c = useClaudeTokens(); + return ( + + Preview didn't start + + The app's dev server never came up. The Terminal tab has the exact error. + + + Try again + + + ); +}; + const BootingBody: React.FC = () => { const c = useClaudeTokens(); const [slow, setSlow] = useState(false); @@ -1012,7 +1027,7 @@ const DashboardOutputPreview: React.FC<{ const tokens = useClaudeTokens(); const dispatch = useAppDispatch(); const workspaceId = output.workspace_id ?? null; - const { frontendUrl, isNewMode, isHydrating } = useRuntimePreviewUrl({ + const { frontendUrl, isNewMode, isHydrating, bootFailed } = useRuntimePreviewUrl({ workspaceId, enabled: !!workspaceId, onLog: onRuntimeLog, @@ -1111,6 +1126,9 @@ const DashboardOutputPreview: React.FC<{ } if (isBooting) { + if (bootFailed) { + return { const tok = getAuthToken(); void fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/restart?instance=${instance}`, { method: 'POST', headers: tok ? { Authorization: `Bearer ${tok}` } : {} }).catch(() => {}); }} />; + } return ; } diff --git a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts index 4186ba7e..6b0993ac 100644 --- a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts +++ b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts @@ -15,6 +15,8 @@ export interface RuntimePreviewState { isNewMode: boolean; // True until the runtime:status frame lands; prevents placeholder flash on remount when Vite is up. isHydrating: boolean; + // The bind poll gave up: the spinner must become an honest failure state, not spin forever. + bootFailed: boolean; } export interface RuntimePreviewOptions { @@ -31,6 +33,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie const [frontendUrl, setFrontendUrl] = useState(null); const [isNewMode, setIsNewMode] = useState(false); const [isHydrating, setIsHydrating] = useState(true); + const [bootFailed, setBootFailed] = useState(false); // Pin latest onLog so callback identity changes don't tear down/respawn the runtime. const onLogRef = useRef(onLog); onLogRef.current = onLog; @@ -80,6 +83,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie const fu = msg.data?.frontend_url ?? null; setFrontendUrl(fu || null); setIsNewMode(!!msg.data?.is_new_mode); + setBootFailed(!!msg.data?.boot_failed && !fu); setIsHydrating(false); } else if (msg.event === 'runtime:log') { const stream = msg.data?.stream || 'stdout'; @@ -120,7 +124,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie }; }, [workspaceId, enabled, instance]); - return { frontendUrl, isNewMode, isHydrating }; + return { frontendUrl, isNewMode, isHydrating, bootFailed }; } export interface PickPreviewUrlOptions {