mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
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:
@@ -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';
|
||||
};
|
||||
Reference in New Issue
Block a user