From 62b8167df64c041297f93cfa487eec4bb3c21610 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 20:50:13 -0700 Subject: [PATCH] [eric] apps: preview runtime reconnects after a backend bounce, hard reload falls through to start --- .../pages/Dashboard/cards/DashboardViewCard.tsx | 11 ++++++++++- .../src/shared/hooks/useRuntimePreviewUrl.ts | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index d0b16b59..12d6b925 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -592,10 +592,19 @@ const DashboardViewCard: React.FC = ({ const tok = getAuthToken(); const headers: Record = { 'Content-Type': 'application/json' }; if (tok) headers.Authorization = `Bearer ${tok}`; - await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, { + const res = await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/restart?instance=${instance}`, { method: 'POST', headers, }); + // Restart no-ops when the registry lost the runtime (backend bounced); the user is asking + // for a working app, so fall through to a fresh start instead of reloading a dead port. + const status = (await res.json().catch(() => null)) as { running?: boolean } | null; + if (status && status.running === false) { + await fetch(`${API_BASE}/outputs/workspace/${wsId}/runtime/start?instance=${instance}`, { + method: 'POST', + headers, + }); + } } catch { /* failures surface via the runtime log WS */ } } previewRef.current?.reload(); diff --git a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts index c86543b1..f2bcabfc 100644 --- a/frontend/src/shared/hooks/useRuntimePreviewUrl.ts +++ b/frontend/src/shared/hooks/useRuntimePreviewUrl.ts @@ -41,6 +41,7 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie } let cancelled = false; let ws: WebSocket | null = null; + let retryTimer: ReturnType | null = null; setFrontendUrl(null); setIsNewMode(false); setIsHydrating(true); @@ -53,7 +54,8 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie const headers: Record = { 'Content-Type': 'application/json' }; if (auth) headers.Authorization = `Bearer ${auth}`; - (async () => { + const connect = async (): Promise => { + if (cancelled) return; try { await fetch(`${API_BASE}/outputs/workspace/${workspaceId}/runtime/start?instance=${instance}`, { method: 'POST', @@ -85,14 +87,23 @@ export function useRuntimePreviewUrl(opts: RuntimePreviewOptions): RuntimePrevie // Malformed frame; safe to drop. } }; + // A backend restart kills the runtime AND this socket; without re-attaching the card sits + // on a dead port forever, so drop the stale URL (placeholder over a dead webview) and retry. + ws.onclose = () => { + if (cancelled) return; + setFrontendUrl(null); + retryTimer = setTimeout(() => { void connect(); }, 2000); + }; } catch (_) { - // WS construction failed; caller stays in "no preview yet" state. + retryTimer = setTimeout(() => { void connect(); }, 2000); } - })(); + }; + void connect(); return () => { cancelled = true; clearTimeout(hydrationTimer); + if (retryTimer) clearTimeout(retryTimer); try { ws?.close(); } catch (_) {} setFrontendUrl(null); setIsNewMode(false);