From faaf505eb491c6424eaacea942aaeb4049c16e85 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 22 Jul 2026 12:02:02 +0100 Subject: [PATCH] Reload after in-app update only once the new version is serving The updater reports 'restarting'/'completed' and then keeps the old process alive for a ~2s grace period before exiting for the service restart. The post-update reload keyed on /api/health alone, so the first probe hit the still-running old process, reloaded the old bundle before the restart happened, and nothing re-triggered afterwards - the page kept showing the old version until a manual refresh. All four reload sites in UpdateProgressModal now probe /api/version (public, no-store) and reload only when the reported version differs from the version that started the update, which also covers rollbacks. A bounded same-version fallback keeps deployments that intentionally never restart (mock/CI) from waiting forever. --- .../src/components/UpdateProgressModal.tsx | 112 +++++++++--------- .../__tests__/updateReadinessModel.test.ts | 70 +++++++++++ .../src/components/updateReadinessModel.ts | 39 ++++++ 3 files changed, 167 insertions(+), 54 deletions(-) create mode 100644 frontend-modern/src/components/__tests__/updateReadinessModel.test.ts create mode 100644 frontend-modern/src/components/updateReadinessModel.ts diff --git a/frontend-modern/src/components/UpdateProgressModal.tsx b/frontend-modern/src/components/UpdateProgressModal.tsx index 277a886bb..546a8c2db 100644 --- a/frontend-modern/src/components/UpdateProgressModal.tsx +++ b/frontend-modern/src/components/UpdateProgressModal.tsx @@ -10,6 +10,8 @@ import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; import { ProgressBar } from '@/components/shared/ProgressBar'; import { apiFetch } from '@/utils/apiClient'; import { logger } from '@/utils/logger'; +import { updateStore } from '@/stores/updates'; +import { resolvePostUpdateReload } from '@/components/updateReadinessModel'; import XIcon from 'lucide-solid/icons/x'; interface UpdateProgressModalProps { @@ -30,6 +32,11 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) { let pollInterval: number | undefined; let healthCheckTimer: number | undefined; let eventSource: EventSource | undefined; + // The version that started this update. The backend keeps serving (and + // answering health checks) for a grace period after reporting 'completed', + // so "different version than this" is the only trustworthy restart signal. + let preUpdateVersion: string | null = null; + let sameVersionHealthyAttempts = 0; const resetModalState = () => { setStatus(null); @@ -38,6 +45,44 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) { setIsRestarting(false); setWsDisconnected(false); setHealthCheckAttempts(0); + preUpdateVersion = updateStore.versionInfo()?.version ?? null; + sameVersionHealthyAttempts = 0; + }; + + // Probe the backend and reload only once it reports a different version + // than the one that started the update (or the bounded fallback in the + // model fires). Returns true when a reload was triggered. + const attemptReadyReload = async (): Promise => { + try { + const response = await apiFetch('/api/version', { cache: 'no-store' }); + if (!response.ok) { + sameVersionHealthyAttempts = 0; + return false; + } + const info = (await response.json()) as { version?: unknown }; + const reportedVersion = typeof info.version === 'string' ? info.version : ''; + const decision = resolvePostUpdateReload({ + preUpdateVersion, + reportedVersion, + sameVersionHealthyAttempts, + }); + if (decision === 'reload') { + logger.info('Backend ready after update, reloading...', { + preUpdateVersion, + reportedVersion, + }); + window.location.reload(); + return true; + } + sameVersionHealthyAttempts += 1; + return false; + } catch (error) { + // Connection refused here usually means the restart is actually + // happening now; the pre-restart healthy answers no longer count. + sameVersionHealthyAttempts = 0; + logger.warn('Version probe failed while waiting for restart, will retry', error); + return false; + } }; const clearHealthCheckTimer = () => { @@ -96,26 +141,13 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) { ) { if (updateStatus.status === 'completed' && !updateStatus.error) { closeSSE(); - // Verify backend health and reload - apiFetch('/api/health', { cache: 'no-store' }) - .then((healthCheck) => { - if (healthCheck.ok) { - logger.info('Update completed, backend healthy, reloading...'); - window.location.reload(); - } else { - // Health check failed, assume restart in progress - setIsRestarting(true); - startHealthCheckPolling(); - } - }) - .catch((error) => { - logger.warn( - 'Update completed but health check failed, assuming restart...', - error, - ); + void attemptReadyReload().then((reloaded) => { + if (!reloaded) { + // Backend not on the new version yet — restart in progress. setIsRestarting(true); startHealthCheckPolling(); - }); + } + }); return; } @@ -175,21 +207,13 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) { currentStatus.status === 'idle' || currentStatus.status === 'error' ) { - // If completed successfully, verify backend health and reload to get new version + // If completed successfully, reload once the new version is serving if (currentStatus.status === 'completed' && !currentStatus.error) { clearPollInterval(); - // Verify backend is healthy and reload - try { - const healthCheck = await apiFetch('/api/health', { cache: 'no-store' }); - if (healthCheck.ok) { - logger.info('Update completed, backend healthy, reloading...'); - window.location.reload(); - return; - } - } catch (error) { - logger.warn('Update completed but health check failed, assuming restart...', error); + if (await attemptReadyReload()) { + return; } - // If health check failed, assume restart in progress + // Backend not on the new version yet — restart in progress. setIsRestarting(true); startHealthCheckPolling(); return; @@ -230,21 +254,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) { setHealthCheckAttempts(0); const checkHealth = async () => { - let isHealthy = false; - - try { - const response = await apiFetch('/api/health', { cache: 'no-store' }); - if (response.ok) { - isHealthy = true; - } - } catch (error) { - logger.warn('Health check request failed, will retry', error); - } - - if (isHealthy) { - // Backend is back! Reload the page to get the new version - logger.info('Backend is healthy again, reloading...'); - window.location.reload(); + if (await attemptReadyReload()) { return; } @@ -277,15 +287,9 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) { // Give it a moment for the backend to fully initialize const reconnectTimer = window.setTimeout(async () => { if (!props.isOpen) return; - try { - const response = await apiFetch('/api/health', { cache: 'no-store' }); - if (response.ok) { - logger.info('Backend healthy after websocket reconnect, reloading...'); - window.location.reload(); - } - } catch (_error) { - logger.warn('Health check failed after websocket reconnect, will keep trying'); - } + // A reconnected websocket almost certainly means the new process is + // up; attemptReadyReload still verifies the version before reloading. + await attemptReadyReload(); }, 1000); onCleanup(() => window.clearTimeout(reconnectTimer)); } diff --git a/frontend-modern/src/components/__tests__/updateReadinessModel.test.ts b/frontend-modern/src/components/__tests__/updateReadinessModel.test.ts new file mode 100644 index 000000000..7bf8b00af --- /dev/null +++ b/frontend-modern/src/components/__tests__/updateReadinessModel.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + MAX_SAME_VERSION_HEALTHY_ATTEMPTS, + resolvePostUpdateReload, +} from '@/components/updateReadinessModel'; + +describe('resolvePostUpdateReload', () => { + it('waits while the pre-update process is still answering with the old version', () => { + // The backend keeps serving for ~2s after reporting 'completed'; a healthy + // old-version answer must not trigger the reload. + expect( + resolvePostUpdateReload({ + preUpdateVersion: '6.1.0-rc.3', + reportedVersion: '6.1.0-rc.3', + sameVersionHealthyAttempts: 0, + }), + ).toBe('wait'); + }); + + it('reloads once the reported version moves off the pre-update version', () => { + expect( + resolvePostUpdateReload({ + preUpdateVersion: '6.1.0-rc.3', + reportedVersion: '6.1.0-rc.4', + sameVersionHealthyAttempts: 0, + }), + ).toBe('reload'); + }); + + it('reloads on a rollback to an older version', () => { + expect( + resolvePostUpdateReload({ + preUpdateVersion: '6.1.0-rc.4', + reportedVersion: '6.0.5', + sameVersionHealthyAttempts: 0, + }), + ).toBe('reload'); + }); + + it('falls back to reloading when the version never changes', () => { + // Mock/CI deployments intentionally never exit; bounded fallback applies. + expect( + resolvePostUpdateReload({ + preUpdateVersion: '6.1.0-rc.3', + reportedVersion: '6.1.0-rc.3', + sameVersionHealthyAttempts: MAX_SAME_VERSION_HEALTHY_ATTEMPTS, + }), + ).toBe('reload'); + }); + + it('waits on a healthy response without a version while a comparison is possible', () => { + expect( + resolvePostUpdateReload({ + preUpdateVersion: '6.1.0-rc.3', + reportedVersion: '', + sameVersionHealthyAttempts: 0, + }), + ).toBe('wait'); + }); + + it('reloads on the first healthy response when no pre-update version is known', () => { + expect( + resolvePostUpdateReload({ + preUpdateVersion: null, + reportedVersion: '6.1.0-rc.4', + sameVersionHealthyAttempts: 0, + }), + ).toBe('reload'); + }); +}); diff --git a/frontend-modern/src/components/updateReadinessModel.ts b/frontend-modern/src/components/updateReadinessModel.ts new file mode 100644 index 000000000..0b76b69e8 --- /dev/null +++ b/frontend-modern/src/components/updateReadinessModel.ts @@ -0,0 +1,39 @@ +// Decision logic for the post-update reload in UpdateProgressModal. +// +// During a self-update the backend emits 'restarting'/'completed' and then +// schedules its own exit a couple of seconds later (see +// internal/updates/manager.go), so the OLD process is still serving — and +// still healthy — when the frontend first probes. Reloading on "healthy" +// alone therefore reloads the old bundle before the restart, and nothing +// re-triggers afterwards, stranding the user on the old version. The only +// trustworthy restart signal is the reported version moving off the version +// that started the update (works for rollbacks too). + +// Healthy-but-same-version responses before giving up and reloading anyway. +// Covers deployments where the process intentionally never exits (mock/CI) +// and re-applies of an identical version. With the modal's backoff schedule +// this allows roughly 30-45 seconds for a real restart to surface. +export const MAX_SAME_VERSION_HEALTHY_ATTEMPTS = 6; + +export type PostUpdateReloadDecision = 'reload' | 'wait'; + +export const resolvePostUpdateReload = (input: { + preUpdateVersion: string | null; + reportedVersion: string; + sameVersionHealthyAttempts: number; +}): PostUpdateReloadDecision => { + // Without a known pre-update version there is nothing to compare against; + // a healthy response is the best remaining signal. + if (!input.preUpdateVersion) { + return 'reload'; + } + if (input.reportedVersion && input.reportedVersion !== input.preUpdateVersion) { + return 'reload'; + } + // Healthy but still the pre-update version: the about-to-exit process is + // answering, or this deployment never restarts. Wait, but not forever. + if (input.sameVersionHealthyAttempts >= MAX_SAME_VERSION_HEALTHY_ATTEMPTS) { + return 'reload'; + } + return 'wait'; +};