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.
This commit is contained in:
rcourtman
2026-07-22 12:02:02 +01:00
parent fb3d60e475
commit faaf505eb4
3 changed files with 167 additions and 54 deletions
@@ -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<boolean> => {
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));
}
@@ -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');
});
});
@@ -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';
};