Keep the update progress modal moving when the update stream goes quiet

Updating to v6.4.5-rc.5 and then v6.4.5 left the progress modal on
"Downloading update... 10%" even though the update finished in the
background. The status stream could go silent behind a proxy (Tailscale
Serve, nginx buffering) and the modal only advanced on stream events.

Backend: the update SSE endpoint now sends the current status on connect,
writes events through one ordered writer with an explicit flush, sends a
15s heartbeat and sets X-Accel-Buffering: no.

Frontend: a silence watchdog falls back to status polling, the restart
phase is entered only on real restart evidence (restarting status, the
server going away, or a version change) rather than a single failed poll,
and the page never auto-reloads on unconfirmed completion without a
pre-update version baseline to compare against.
This commit is contained in:
courtmanr@gmail.com
2026-09-30 19:33:16 +01:00
parent f3c92a242e
commit 119ac49d10
17 changed files with 1985 additions and 189 deletions
@@ -3452,6 +3452,14 @@ Agent` secondary handoff against the live setup wizard instead of relying
## Current State
### Update progress stream delivery
Server update progress delivery is owned by internal/updates and its API
adapter. Immediate status on connect, ordered flushed events, and the modal's
polling fallback change how progress is observed only. They grant no agent
update, installation or command-execution authority, and a completed server
update is not evidence that any agent was upgraded.
### Hosted runtimes always mint agent install tokens
`POST /api/agent-install-command` mints and embeds an agent install token
@@ -4579,6 +4579,19 @@ auto-register mutation boundary.
## Current State
### Update progress stream delivery
`GET /api/updates/stream` answers `text/event-stream` with
`Cache-Control: no-cache, no-transform`, `X-Accel-Buffering: no`, and no
content encoding. The first flushed frames are a `: connected` comment and a
`data:` event carrying the current `UpdateStatus`, so a connecting client never
waits for the next stage change. Later statuses are `data:` events in emission
order, each flushed on write, with `: heartbeat` comments between them. The
payload shape is the same `UpdateStatus` returned by `/api/updates/status`,
which stays the polling fallback for clients whose stream goes quiet. The
stream handler tests in `internal/api/updates_test.go` cover this transport
contract.
### Hosted install command payloads carry a minted token
`POST /api/agent-install-command` returns a non-empty `token` (and, for
@@ -2527,6 +2527,41 @@ artifact-selection behaviour.
## Current State
### Update progress stream delivery
`GET /api/updates/stream` is the in-app updater's progress feed and must never
look like a stalled update. The handler marks the response `text/event-stream`
with `Cache-Control: no-cache, no-transform` and `X-Accel-Buffering: no` so
reverse proxies and compressing intermediaries do not hold events back, and the
gzip middleware keeps excluding event streams. `SSEBroadcaster.AddClient` in
`internal/updates/sse.go` writes the connection preamble and the current cached
status and flushes them before returning, and every later broadcast reaches a
client through that client's single ordered writer under the client lock, so
stages arrive in emission order and no two goroutines write one
ResponseWriter. Write failures remove the client off the writer goroutine so
removal never waits on the broadcaster lock while holding a client lock.
Comment heartbeats run every 15 seconds. `UpdateProgressModal` must not trust
an open-but-silent EventSource. It also polls `/api/updates/status` after
`UPDATE_STREAM_SILENCE_FALLBACK_MS` without a stream message, ignores polled
stages that would move progress backwards or are update-check chatter, keeps
reloading only once `/api/version` reports a different version, and after
`UPDATE_PROGRESS_STALL_TIMEOUT_MS` without movement shows a reload-to-check
state instead of an endless spinner. A failed status poll is never restart
evidence on its own. The modal enters its restart phase only when the backend
reports `restarting` or `completed`, or, unconfirmed, when the stream has
closed and consecutive polls fail after the update reached `applying` or
`restoring`. An unconfirmed restart keeps polling so a later progress or
`error` status still wins, and the same-version reload fallback in
`resolvePostUpdateReload` applies only after a confirmed restart. When the
modal opens before the running version is known it fetches `/api/version`
immediately and also adopts the update store's version once it loads, but
only while no restart signal has been seen. With no baseline it never reloads
on unconfirmed completion, and after confirmed completion it reloads only
once a probe has seen the old process go away, or after the bounded fallback. `internal/updates/sse_test.go`, the
stream handler tests in `internal/api/updates_test.go`, and
`frontend-modern/src/components/__tests__/UpdateProgressModal.test.tsx` are
the proof surface.
### Provider MSP upgrades move an install to a new release bundle
`setup.sh` resolves the image pins in `.env` to digests once, so `upgrade.sh`
@@ -2640,6 +2640,13 @@ vdev layout is reported` in
## Current State
### Update progress stream delivery
Update progress delivery changes observation only. Streaming or polling a
`backing-up`, `restoring` or `completed` stage does not create a backup,
perform rollback or establish recovery success. Update history and retained
backups remain the recovery authority, independent of `/api/updates/stream`.
### Hosted install tokens persist like any other workspace token
Hosted client workspaces now mint agent install tokens where they previously
@@ -0,0 +1,348 @@
// Offline browser proof: the update progress modal keeps moving when the
// update stream goes quiet, and reloads only on real restart evidence.
//
// node frontend-modern/browser-tests/update-progress-modal.cjs
//
// Needs Playwright 1.56.1 resolvable (NODE_PATH works) with its Chromium.
// PULSE_BROWSER_ARTIFACTS picks the screenshot directory and
// PULSE_CHROMIUM_PATH an alternative Chromium executable. Every API answer is
// synthetic and off-origin traffic is aborted; no Pulse server is contacted.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chromium } = require('playwright');
const PORT = 5208;
const ORIGIN = `http://127.0.0.1:${PORT}`;
const HARNESS = `${ORIGIN}/browser-tests/update-progress-modal.html`;
const OLD = 'v6.4.4';
const NEW = 'v6.4.5';
const status = (stage, progress, message) => ({
status: stage,
progress,
message,
updatedAt: new Date().toISOString(),
});
const MESSAGES = {
downloading: 'Downloading update...',
verifying: 'Verifying download...',
extracting: 'Extracting update...',
'backing-up': 'Backing up current installation...',
applying: 'Applying update...',
restarting: 'Restarting Pulse...',
completed: 'Update completed',
idle: '',
};
const s = (stage, progress) => status(stage, progress, MESSAGES[stage]);
const ABORT = Symbol('abort');
const UNAVAILABLE = Symbol('unavailable');
async function openHarness(browser, viewport, server) {
const context = await browser.newContext({ viewport });
const page = await context.newPage();
const record = {
pageErrors: [],
documentLoads: 0,
statusRequests: [],
versionRequests: [],
offOrigin: 0,
};
page.on('pageerror', (error) => record.pageErrors.push(error.message));
await page.route('**/*', (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin !== ORIGIN) {
record.offOrigin += 1;
return route.abort();
}
if (url.pathname === '/browser-tests/update-progress-modal.html') {
record.documentLoads += 1;
return route.continue();
}
const answer = (value, log) => {
log.push(value === ABORT ? 'unreachable' : value === UNAVAILABLE ? '503' : value);
if (value === ABORT) return route.abort('connectionrefused');
if (value === UNAVAILABLE) return route.fulfill({ status: 503, body: 'unavailable' });
return route.fulfill({ json: value });
};
if (url.pathname === '/api/updates/status') {
return answer(server.status(), record.statusRequests);
}
if (url.pathname === '/api/version') {
const version = server.version();
return answer(
typeof version === 'string' ? { version, build: 'release' } : version,
record.versionRequests,
);
}
if (url.pathname.startsWith('/api/')) return route.abort();
return route.continue();
});
await page.goto(HARNESS, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.waitForFunction(() => window.__updateStream && window.__updateStream.open());
await page.getByRole('dialog').waitFor();
return { context, page, record };
}
const emit = (page, value) => page.evaluate((v) => window.__updateStream.emit(v), value);
const seen = (page) => page.evaluate(() => JSON.parse(JSON.stringify(window.__seen)));
const progressText = (page, value) =>
page.getByRole('dialog').getByText(`${value}%`, { exact: true });
async function layoutCheck(page) {
const dimensions = await page.evaluate(() => {
const panel = document.querySelector('[role="dialog"]');
const rect = panel ? panel.getBoundingClientRect() : null;
return {
scroll: document.documentElement.scrollWidth,
inner: innerWidth,
panelLeft: rect ? Math.round(rect.left) : null,
panelRight: rect ? Math.round(rect.right) : null,
};
});
assert.ok(dimensions.scroll <= dimensions.inner + 1, `horizontal overflow ${JSON.stringify(dimensions)}`);
if (dimensions.panelLeft !== null) {
assert.ok(dimensions.panelLeft >= 0 && dimensions.panelRight <= dimensions.inner + 1,
`dialog outside viewport ${JSON.stringify(dimensions)}`);
}
return dimensions;
}
async function shot(page, artifacts, name, width) {
await page.waitForTimeout(350); // let the dialog entrance settle
const file = path.join(artifacts, `${name}-${width}.png`);
await page.screenshot({ path: file });
return file;
}
const scenarios = {
// (a) One downloading event, then a held-open silent stream.
async 'stalled-stream-fallback'(browser, viewport, artifacts) {
const polls = [s('downloading', 35), s('downloading', 70), s('extracting', 80), s('applying', 90)];
let pollIndex = 0;
const server = {
status: () => polls[Math.min(pollIndex++, polls.length - 1)],
version: () => OLD,
};
const { context, page, record } = await openHarness(browser, viewport, server);
await emit(page, s('downloading', 10));
const silentSince = Date.now();
await progressText(page, 10).waitFor();
await page.getByText('Downloading update...').waitFor();
const screenshots = [await shot(page, artifacts, 'a-stream-10pct', viewport.width)];
await progressText(page, 35).waitFor({ timeout: 15_000 });
const firstPollAfterMs = Date.now() - silentSince;
await progressText(page, 90).waitFor({ timeout: 20_000 });
await page.getByText('Applying update...').waitFor();
const observed = await seen(page);
assert.ok(firstPollAfterMs >= 5_000, `polling began before the silence window (${firstPollAfterMs}ms)`);
assert.deepEqual(observed.progress, [10, 35, 70, 80, 90]);
assert.equal(observed.restarting, false);
assert.equal(await page.evaluate(() => window.__updateStream.open()), true, 'stream stays open alongside polling');
screenshots.push(await shot(page, artifacts, 'a-polled-90pct', viewport.width));
const dimensions = await layoutCheck(page);
assert.deepEqual(record.pageErrors, []);
await context.close();
return { firstPollAfterMs, progress: observed.progress, statusPolls: record.statusRequests.length, dimensions, screenshots };
},
// (b) One failed poll mid-download must not look like a restart.
async 'transient-poll-failure'(browser, viewport, artifacts) {
const polls = [s('downloading', 40), ABORT, s('downloading', 60), s('downloading', 80)];
let pollIndex = 0;
const server = {
status: () => polls[Math.min(pollIndex++, polls.length - 1)],
version: () => OLD,
};
const { context, page, record } = await openHarness(browser, viewport, server);
await emit(page, s('downloading', 20));
await progressText(page, 20).waitFor();
await page.evaluate(() => window.__updateStream.fail()); // stream closed, polling only
await progressText(page, 80).waitFor({ timeout: 20_000 });
await page.waitForTimeout(3_000); // one more poll cycle at the same stage
const observed = await seen(page);
assert.ok(record.statusRequests.includes('unreachable'), 'a poll failed');
assert.equal(observed.restarting, false, 'restart phase entered on a single failed poll');
assert.deepEqual(observed.progress, [20, 40, 60, 80]);
await page.getByText('Please do not close this window or refresh the page during the update.').waitFor();
assert.equal(record.documentLoads, 1);
const screenshots = [await shot(page, artifacts, 'b-after-failed-poll-80pct', viewport.width)];
const dimensions = await layoutCheck(page);
assert.deepEqual(record.pageErrors, []);
await context.close();
return { statusRequests: record.statusRequests.map((r) => (typeof r === 'string' ? r : `${r.status} ${r.progress}`)), progress: observed.progress, dimensions, screenshots };
},
// (c) restarting status, then the server goes away, then the new version.
async 'restart-evidence-reload'(browser, viewport, artifacts) {
let restartServed = false;
let versionAfterRestart = 0;
const polls = [s('applying', 90), s('restarting', 100)];
let pollIndex = 0;
const server = {
status: () => {
if (restartServed) return ABORT;
const next = polls[Math.min(pollIndex++, polls.length - 1)];
if (next.status === 'restarting') restartServed = true;
return next;
},
version: () => {
if (!restartServed) return OLD;
versionAfterRestart += 1;
return versionAfterRestart <= 2 ? ABORT : NEW;
},
};
const { context, page, record } = await openHarness(browser, viewport, server);
await emit(page, s('downloading', 10));
await progressText(page, 10).waitFor();
await page.getByText('Pulse is restarting...').waitFor({ timeout: 20_000 });
const screenshots = [await shot(page, artifacts, 'c-restarting', viewport.width)];
const restartDimensions = await layoutCheck(page);
await page.getByTestId('reloaded').waitFor({ timeout: 30_000 });
await page.waitForTimeout(4_000); // no second reload
assert.equal(record.documentLoads, 2, 'reloaded exactly once');
const afterRestart = record.versionRequests.slice(record.versionRequests.findIndex((v) => v === 'unreachable'));
assert.deepEqual(afterRestart.slice(0, 3), ['unreachable', 'unreachable', { version: NEW, build: 'release' }]);
screenshots.push(await shot(page, artifacts, 'c-reloaded', viewport.width));
assert.deepEqual(record.pageErrors, []);
await context.close();
return {
documentLoads: record.documentLoads,
versionRequests: record.versionRequests.map((v) => (typeof v === 'string' ? v : v.version)),
dimensions: restartDimensions,
screenshots,
};
},
// (d) No pre-update version; requests fail late and a new version answers,
// but the backend never confirmed completion.
async 'no-baseline-no-autoreload'(browser, viewport, artifacts) {
let failuresStarted = false;
const polls = [s('applying', 95), ABORT, ABORT, ABORT, ABORT, s('idle', 0)];
let pollIndex = 0;
const server = {
status: () => {
const next = polls[Math.min(pollIndex++, polls.length - 1)];
if (next === ABORT) failuresStarted = true;
return next;
},
version: () => (failuresStarted ? NEW : UNAVAILABLE),
};
const { context, page, record } = await openHarness(browser, viewport, server);
await emit(page, s('applying', 90));
await progressText(page, 90).waitFor();
await page.evaluate(() => window.__updateStream.fail());
await page.getByText('Pulse is restarting...').waitFor({ timeout: 20_000 });
// Past the idle answers and several healthy new-version probes.
const deadline = Date.now() + 25_000;
while (Date.now() < deadline && !(record.statusRequests.filter((r) => r.status === 'idle').length >= 2
&& record.versionRequests.filter((v) => v.version === NEW).length >= 3)) {
await page.waitForTimeout(500);
}
await page.waitForTimeout(2_000);
const idleAnswers = record.statusRequests.filter((r) => r.status === 'idle').length;
const newVersionAnswers = record.versionRequests.filter((v) => v.version === NEW).length;
assert.ok(idleAnswers >= 2, `idle answers ${idleAnswers}`);
assert.ok(newVersionAnswers >= 3, `new-version answers ${newVersionAnswers}`);
assert.ok(record.versionRequests.includes('503'), 'baseline probe was unavailable');
assert.equal(record.documentLoads, 1, 'auto-reloaded without a baseline or confirmation');
assert.equal(await page.getByTestId('reloaded').count(), 0);
const observed = await seen(page);
assert.equal(observed.completed, false, 'claimed completion without confirmation');
const screenshots = [await shot(page, artifacts, 'd-unconfirmed-waiting', viewport.width)];
const dimensions = await layoutCheck(page);
assert.deepEqual(record.pageErrors, []);
await context.close();
return { documentLoads: record.documentLoads, idleAnswers, newVersionAnswers, dimensions, screenshots };
},
// (e) The stream delivers every stage through completion.
async 'stream-happy-path'(browser, viewport, artifacts) {
let completedEmitted = false;
let versionAfterComplete = 0;
const server = {
status: () => s('applying', 95),
version: () => {
if (!completedEmitted) return OLD;
versionAfterComplete += 1;
if (versionAfterComplete === 1) return OLD; // old process still answering
if (versionAfterComplete === 2) return ABORT; // it exits
return NEW;
},
};
const { context, page, record } = await openHarness(browser, viewport, server);
const stages = [
s('downloading', 10), s('downloading', 45), s('downloading', 80), s('verifying', 85),
s('extracting', 88), s('backing-up', 90), s('applying', 95),
];
const screenshots = [];
for (const stage of stages) {
await emit(page, stage);
await progressText(page, stage.progress).waitFor();
if (stage.status === 'applying') screenshots.push(await shot(page, artifacts, 'e-stream-applying', viewport.width));
await page.waitForTimeout(300);
}
const midDimensions = await layoutCheck(page);
completedEmitted = true;
await emit(page, s('completed', 100));
await page.getByText('Pulse is restarting...').waitFor({ timeout: 10_000 });
screenshots.push(await shot(page, artifacts, 'e-restarting', viewport.width));
await layoutCheck(page);
await page.getByTestId('reloaded').waitFor({ timeout: 30_000 });
await page.waitForTimeout(3_000);
assert.equal(record.documentLoads, 2, 'reloaded exactly once');
assert.equal(record.statusRequests.length, 0, 'fallback polling ran while the stream was live');
screenshots.push(await shot(page, artifacts, 'e-reloaded', viewport.width));
assert.deepEqual(record.pageErrors, []);
await context.close();
return {
documentLoads: record.documentLoads,
statusPolls: record.statusRequests.length,
versionRequests: record.versionRequests.map((v) => (typeof v === 'string' ? v : v.version)),
dimensions: midDimensions,
screenshots,
};
},
};
(async () => {
const root = path.resolve(__dirname, '..');
const artifacts = process.env.PULSE_BROWSER_ARTIFACTS || path.join(root, 'browser-tests', 'update-progress-modal-proof');
fs.mkdirSync(artifacts, { recursive: true });
process.chdir(root);
const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js'));
const server = await createServer({
root,
configFile: path.join(root, 'vite.config.ts'),
server: { host: '127.0.0.1', port: PORT, strictPort: true },
logLevel: 'warn',
});
let browser;
try {
await server.listen();
browser = await chromium.launch({
headless: true,
args: ['--no-sandbox'],
// Optional: a locally cached Chromium when Playwright's own build is absent.
...(process.env.PULSE_CHROMIUM_PATH ? { executablePath: process.env.PULSE_CHROMIUM_PATH } : {}),
});
const results = [];
for (const viewport of [{ width: 1365, height: 900 }, { width: 390, height: 844 }]) {
for (const [name, run] of Object.entries(scenarios)) {
const started = Date.now();
const detail = await run(browser, viewport, artifacts);
results.push({ viewport: `${viewport.width}x${viewport.height}`, scenario: name, result: 'passed', ms: Date.now() - started, ...detail });
console.error(`passed ${name} @ ${viewport.width}x${viewport.height}`);
}
}
console.log(JSON.stringify({ result: 'passed', browser: browser.version(), playwright: require('playwright/package.json').version, results }, null, 2));
} finally {
if (browser) await browser.close();
await server.close();
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/browser-tests/update-progress-modal.tsx"></script>
</body>
</html>
@@ -0,0 +1,101 @@
// Mount the production UpdateProgressModal against a scripted update stream.
// HTTP (/api/updates/status, /api/version) is scripted by the Playwright
// runner; the SSE transport is replaced here so the runner can hold the
// stream open and silent, which a fulfilled HTTP response cannot do.
import { render } from 'solid-js/web';
import { UpdateProgressModal } from '../src/components/UpdateProgressModal';
import '../src/index.css';
type Listener = ((event: MessageEvent | Event) => void) | null;
class ScriptedEventSource {
static instances: ScriptedEventSource[] = [];
readonly url: string;
readyState = 0;
onopen: Listener = null;
onmessage: Listener = null;
onerror: Listener = null;
constructor(url: string) {
this.url = url;
ScriptedEventSource.instances.push(this);
setTimeout(() => {
if (this.readyState === 2) return;
this.readyState = 1;
this.onopen?.(new Event('open'));
}, 0);
}
close() {
this.readyState = 2;
}
addEventListener() {}
removeEventListener() {}
}
const live = () =>
ScriptedEventSource.instances.filter((instance) => instance.readyState !== 2).at(-1);
const w = window as unknown as Record<string, unknown>;
w.EventSource = ScriptedEventSource;
w.__updateStream = {
emit(status: unknown) {
const stream = live();
if (!stream) return false;
stream.onmessage?.(new MessageEvent('message', { data: JSON.stringify(status) }));
return true;
},
fail() {
const stream = live();
if (!stream) return false;
stream.onerror?.(new Event('error'));
return true;
},
opened: () => ScriptedEventSource.instances.length,
open: () => Boolean(live()),
};
// Everything the modal ever showed, so a transient restart claim that was
// later corrected still fails the run.
const seen = { restarting: false, completed: false, progress: [] as number[] };
w.__seen = seen;
new MutationObserver(() => {
const text = document.body.innerText;
if (text.includes('Pulse is restarting')) seen.restarting = true;
if (text.includes('Update Completed Successfully')) seen.completed = true;
const match = /Progress\s+(\d+)%/.exec(text);
if (match) {
const value = Number(match[1]);
if (seen.progress.at(-1) !== value) seen.progress.push(value);
}
}).observe(document.body, { subtree: true, childList: true, characterData: true });
let boots = 1;
try {
boots = Number(sessionStorage.getItem('update-progress-boots') || '0') + 1;
sessionStorage.setItem('update-progress-boots', String(boots));
} catch {
// Storage unavailable: treat as a first boot.
}
render(
() =>
boots > 1 ? (
<main class="p-6">
<p data-testid="reloaded">Reloaded after update (boot {boots})</p>
</main>
) : (
<main class="p-6">
<h1 class="text-lg font-semibold">Update progress harness</h1>
<UpdateProgressModal
isOpen={true}
onClose={() => undefined}
onViewHistory={() => undefined}
connected={() => true}
reconnecting={() => false}
/>
</main>
),
document.getElementById('root')!,
);
+97 -31
View File
@@ -1,16 +1,18 @@
{
"version": 1,
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
"verified_at": "2026-09-30T20:12:09Z",
"base_sha": "f3c92a242ebf9b7062dfc9f6ff711a56639f536c",
"verified_at": "2026-09-30T22:39:35Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
"frontend-modern/src/components/UpdateProgressModal.tsx",
"frontend-modern/src/components/updateReadinessModel.ts"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
"frontend-modern/src/components/UpdateProgressModal.tsx": "d51df7a98a379e313f306b68529c8db0687ff91176af23869411c4b46feb2599",
"frontend-modern/src/components/updateReadinessModel.ts": "b25bf82441fc6572b95891af48a1ed2a0e8ea1f4e583c171ea9352a1e67d76ce"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)"
"/browser-tests/update-progress-modal.html (production UpdateProgressModal with a scripted update stream and scripted /api/updates/status and /api/version)"
],
"viewports": [
{
@@ -23,38 +25,102 @@
}
],
"states": [
"corroborated host with seven observed metric paths",
"uncached range replacement pending with no old paths",
"new range loaded before the old range response",
"withdrawn host link with replacement target pending",
"replacement target returns 503 with no former-host history",
"service-only response retains CPU/memory, not host disk/network/I/O",
"locked 14-day range issues no history read"
"stream delivers one downloading 10% event then stays open and silent; fallback polling advances the modal through 35, 70, 80 and 90% (applying)",
"stream closed during download with a single unreachable status poll between two successful ones; download progress continues and the restart phase is never entered",
"restarting status from polling, then /api/version unreachable twice, then the new version reported; Pulse is restarting shown, then exactly one page reload",
"pre-update version unavailable (503), late polls failing and a new version plus idle answering without a restarting or completed status; modal stays on Pulse is restarting with no reload and no completion claim",
"stream delivers downloading, verifying, extracting, backing-up, applying and completed; old version answers once, becomes unreachable, then the new version; exactly one reload and no fallback polls"
],
"interactions": [
"desktop keyboard Enter / phone row click to open PBS details",
"select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request",
"withdraw third host link while service GET is held, then return 503",
"select 6h after failure and inspect the service-only response",
"select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors"
"emit scripted SSE statuses and hold the stream silent past the 6s silence window; assert the first fallback poll lands after it and the stream stays open",
"fail the stream and abort one status poll mid-download; assert no restart text ever rendered (MutationObserver) and the do-not-close warning remains",
"count document loads of the harness to assert a single reload after real restart evidence, and none without a baseline or backend confirmation",
"inspect screenshots at desktop and phone widths; assert no page errors, no horizontal overflow and the dialog inside the viewport"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"command": "NODE_PATH=<playwright 1.56.1 node_modules> PULSE_BROWSER_ARTIFACTS=<proof dir> PULSE_CHROMIUM_PATH=<cached chrome-headless-shell 151.0.7922.34> node frontend-modern/browser-tests/update-progress-modal.cjs from the worktree root; Vite dev server on 127.0.0.1:5208, Playwright 1.56.1, Chromium headless shell 151.0.7922.34",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png"
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/harness-rebased-3.log",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-polled-90pct-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/a-stream-10pct-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/b-after-failed-poll-80pct-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-reloaded-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/c-restarting-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/d-unconfirmed-waiting-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-reloaded-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-restarting-390.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-1365.png",
"/private/tmp/claude-501/-Users-rcourtman-Development-pulse-repos-pulse/6fb259b1-7b46-484b-80c9-554dd254295e/scratchpad/update-progress-modal-proof-rebased/e-stream-applying-390.png"
],
"notes": [
"Mock-backed UI proof of the production modal; all five scenarios passed at 1365x900 and 390x844 and the screenshots were inspected. The same harness run against the pre-fix UpdateProgressModal and updateReadinessModel (fa1d9eb36) fails scenario (a), staying on 10% (pre-fix/run.log). This does not exercise the backend SSE endpoint, a real proxy, or an installed update. Re-run after rebasing onto 8d5969fb3 (lockfile-only brace-expansion/DOMPurify floors); both full runs passed all five scenarios at both viewports with unchanged component content hashes.",
"Re-run after rebasing onto f3c92a242; all five scenarios passed at both viewports with unchanged component content hashes."
],
"notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately.",
"prior_verifications": [
{
"version": 1,
"base_sha": "21a35494aecb335755de773dd099e1364e65b3e0",
"verified_at": "2026-09-30T20:12:09Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx"
],
"content_sha256": {
"frontend-modern/src/components/Workloads/GuestDrawerHistory.tsx": "b2b31441b19c2e50283ceedf319cd09a1f94791750cd424c052717a4957fdc67"
},
"routes": [
"/browser-tests/pbs-identity-boundary.html (production PBS table and real resource History renderer; synthetic API data)"
],
"viewports": [
{
"width": 1365,
"height": 900
},
{
"width": 390,
"height": 844
}
],
"states": [
"corroborated host with seven observed metric paths",
"uncached range replacement pending with no old paths",
"new range loaded before the old range response",
"withdrawn host link with replacement target pending",
"replacement target returns 503 with no former-host history",
"service-only response retains CPU/memory, not host disk/network/I/O",
"locked 14-day range issues no history read"
],
"interactions": [
"desktop keyboard Enter / phone row click to open PBS details",
"select 6h while its GET is held; select 1h, then fulfil the obsolete 6h request",
"withdraw third host link while service GET is held, then return 503",
"select 6h after failure and inspect the service-only response",
"select locked 14d; check all ten screenshots, retained History tab, zero horizontal overflow and no page errors"
],
"command": "pulse-worker-browser frontend-modern/browser-tests/pbs-history-source-isolation.cjs from assigned workspace root; Vite 6.4.3, Chromium 141.0.7390.37, Playwright 1.56.1",
"artifacts": [
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/receipts/history-browser-final.log",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/result.json",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/host-loaded-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/range-loading-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/service-loaded-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-failed-390.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-1365.png",
"/var/lib/pulse-maintainer/worker-outputs/web-product-j4h_mbv3/history-browser-final/target-loading-390.png"
],
"notes": "Ten final screenshots inspected. The superseded GET is observed ERR_ABORTED at both widths; the unit regression also resolves mocks that ignore cancellation. Mock-backed proof establishes source/range isolation, not installed PBS collection, VirtualBox telemetry, all three #1723 drawers or release availability. Earlier unconfigured-CSS red/attempt-one results are retained separately."
},
{
"version": 1,
"base_sha": "6ade01cbd4d02cd25b691f9ff5919206cceae764",
@@ -1,4 +1,4 @@
import { createSignal, Show, onCleanup, createEffect } from 'solid-js';
import { createSignal, Show, onCleanup, createEffect, untrack } from 'solid-js';
import { UpdatesAPI, type UpdateStatus } from '@/api/updates';
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
import CheckCircleIcon from 'lucide-solid/icons/check-circle';
@@ -11,7 +11,16 @@ 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 {
RESTART_SUSPECT_POLL_FAILURES,
UPDATE_PROGRESS_STALL_TIMEOUT_MS,
UPDATE_STATUS_POLL_INTERVAL_MS,
UPDATE_STREAM_SILENCE_FALLBACK_MS,
isLateUpdateStage,
isUpdateInProgressStage,
resolvePostUpdateReload,
shouldApplyUpdateStatus,
} from '@/components/updateReadinessModel';
import XIcon from 'lucide-solid/icons/x';
interface UpdateProgressModalProps {
@@ -29,14 +38,37 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
const [isRestarting, setIsRestarting] = createSignal(false);
const [wsDisconnected, setWsDisconnected] = createSignal(false);
const [healthCheckAttempts, setHealthCheckAttempts] = createSignal(0);
const [progressStalled, setProgressStalled] = createSignal(false);
let pollInterval: number | undefined;
let healthCheckTimer: number | undefined;
let streamSilenceTimer: number | undefined;
let progressStallTimer: 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;
// Set while a terminal status is being resolved (version probe in flight),
// so SSE and polling reporting the same terminal status act on it once.
let resolvingTerminalStatus = false;
// Bumped on every open/close so late async results from a previous open
// cannot drive the current one.
let session = 0;
// True once the backend itself reported 'restarting' or 'completed'. Only
// then may the modal stop listening for status and, as a last resort,
// reload on an unchanged version. A restart inferred from failing
// requests stays unconfirmed: the feeds keep running so a late progress
// or error event can still correct it.
let restartConfirmed = false;
let consecutivePollFailures = 0;
// Identifies the live health-check loop. Clearing the timer alone cannot
// stop a probe already in flight from re-arming itself.
let healthCheckLoop = 0;
// Set when a version probe fails after confirmed completion: the old
// process was seen going away, so the next healthy answer is the new one.
let restartObserved = false;
let baselineFetchInFlight = false;
const resetModalState = () => {
setStatus(null);
@@ -45,8 +77,62 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
setIsRestarting(false);
setWsDisconnected(false);
setHealthCheckAttempts(0);
setProgressStalled(false);
preUpdateVersion = updateStore.versionInfo()?.version ?? null;
sameVersionHealthyAttempts = 0;
resolvingTerminalStatus = false;
restartConfirmed = false;
consecutivePollFailures = 0;
restartObserved = false;
baselineFetchInFlight = false;
};
// The pre-update version is only trustworthy while the old process is
// certainly the one answering: before any restart signal, and before a
// terminal or fresh-process status.
const canAdoptBaseline = () => {
if (preUpdateVersion || restartConfirmed || isRestarting() || resolvingTerminalStatus) {
return false;
}
const current = status()?.status;
return current !== 'restarting' && current !== 'completed' && current !== 'idle';
};
const adoptBaseline = (version: string, source: string) => {
if (!version || !canAdoptBaseline()) {
return;
}
preUpdateVersion = version;
logger.info('Captured pre-update version for restart detection', { version, source });
};
// The global watcher can open the modal before the update store has loaded
// the running version. Without that baseline an old-process answer looks
// like the new one, so fetch it directly while the old process is serving.
const fetchBaseline = () => {
if (baselineFetchInFlight || !canAdoptBaseline()) {
return;
}
baselineFetchInFlight = true;
const fetchSession = session;
void (async () => {
try {
const response = await apiFetch('/api/version', { cache: 'no-store' });
if (fetchSession !== session || !response.ok) {
return;
}
const info = (await response.json()) as { version?: unknown };
if (fetchSession === session && typeof info.version === 'string') {
adoptBaseline(info.version, 'version-probe');
}
} catch (error) {
logger.warn('Could not read the pre-update version, will retry', error);
} finally {
if (fetchSession === session) {
baselineFetchInFlight = false;
}
}
})();
};
// Probe the backend and reload only once it reports a different version
@@ -57,6 +143,9 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
const response = await apiFetch('/api/version', { cache: 'no-store' });
if (!response.ok) {
sameVersionHealthyAttempts = 0;
if (restartConfirmed) {
restartObserved = true;
}
return false;
}
const info = (await response.json()) as { version?: unknown };
@@ -65,6 +154,8 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
preUpdateVersion,
reportedVersion,
sameVersionHealthyAttempts,
completionConfirmed: restartConfirmed,
restartObserved,
});
if (decision === 'reload') {
logger.info('Backend ready after update, reloading...', {
@@ -80,12 +171,16 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
// Connection refused here usually means the restart is actually
// happening now; the pre-restart healthy answers no longer count.
sameVersionHealthyAttempts = 0;
if (restartConfirmed) {
restartObserved = true;
}
logger.warn('Version probe failed while waiting for restart, will retry', error);
return false;
}
};
const clearHealthCheckTimer = () => {
healthCheckLoop += 1;
if (healthCheckTimer !== undefined) {
clearTimeout(healthCheckTimer);
healthCheckTimer = undefined;
@@ -99,7 +194,22 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
}
};
const clearStreamSilenceTimer = () => {
if (streamSilenceTimer !== undefined) {
clearTimeout(streamSilenceTimer);
streamSilenceTimer = undefined;
}
};
const clearProgressStallTimer = () => {
if (progressStallTimer !== undefined) {
clearTimeout(progressStallTimer);
progressStallTimer = undefined;
}
};
const closeSSE = () => {
clearStreamSilenceTimer();
if (!eventSource) {
return;
}
@@ -108,6 +218,172 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
logger.info('SSE connection closed');
};
// Stop the status feeds (stream and poll); restart detection and the stall
// timer are managed separately.
const stopStatusFeeds = () => {
closeSSE();
clearPollInterval();
};
const stopEverything = () => {
stopStatusFeeds();
clearHealthCheckTimer();
clearProgressStallTimer();
};
// Any real movement (new stage, new progress, entering the restart) pushes
// the "may have finished" fallback back out. Repeated identical answers do
// not, so a stream or poll that keeps echoing one stage still times out.
const armProgressStallTimer = () => {
clearProgressStallTimer();
setProgressStalled(false);
progressStallTimer = window.setTimeout(() => {
progressStallTimer = undefined;
if (!isComplete()) {
logger.warn('Update progress stalled, offering a manual reload');
setProgressStalled(true);
}
}, UPDATE_PROGRESS_STALL_TIMEOUT_MS);
};
// A quiet stream is not a closed stream. If nothing arrives for a while,
// poll as well rather than trusting the open EventSource.
const armStreamSilenceWatchdog = () => {
clearStreamSilenceTimer();
streamSilenceTimer = window.setTimeout(() => {
streamSilenceTimer = undefined;
if (!eventSource || isComplete() || restartConfirmed) {
return;
}
logger.warn('Update progress stream went quiet, polling status as well');
startPolling();
}, UPDATE_STREAM_SILENCE_FALLBACK_MS);
};
// The backend reported the restart itself: stop listening for status and
// wait for the new version to serve.
const enterConfirmedRestartPhase = () => {
restartConfirmed = true;
stopStatusFeeds();
armProgressStallTimer();
// A suspected restart already has a health-check loop running; it reads
// the confirmation on its next probe.
if (!isRestarting()) {
setIsRestarting(true);
startHealthCheckPolling();
}
};
// Requests are failing late in the update, which usually means the old
// process just exited. Probe for the new version, but keep the status
// feeds running so a status from a still-running old process wins.
const enterSuspectedRestartPhase = () => {
if (isRestarting()) {
return;
}
logger.warn('Status requests failing late in the update, probing for the restarted backend');
setIsRestarting(true);
startHealthCheckPolling();
};
const leaveSuspectedRestartPhase = () => {
if (!isRestarting() || restartConfirmed) {
return;
}
setIsRestarting(false);
clearHealthCheckTimer();
setHealthCheckAttempts(0);
};
const finishWithResult = (failed: boolean) => {
stopStatusFeeds();
clearHealthCheckTimer();
clearProgressStallTimer();
setProgressStalled(false);
// A failure reported while a restart was only suspected ends that too.
setIsRestarting(false);
setIsComplete(true);
if (failed) {
setHasError(true);
}
};
// Single entry point for statuses from the SSE stream and from polling.
const handleStatus = (next: UpdateStatus) => {
if (isComplete() || restartConfirmed || resolvingTerminalStatus) {
return;
}
const previous = status();
if (!shouldApplyUpdateStatus(previous, next)) {
return;
}
if (next.status === 'idle') {
handleIdleStatus(previous);
return;
}
setStatus(next);
if (!previous || previous.status !== next.status || previous.progress !== next.progress) {
armProgressStallTimer();
}
// Still no baseline: retry while the old process is known to be serving.
fetchBaseline();
if (next.status === 'restarting') {
enterConfirmedRestartPhase();
return;
}
if (next.status === 'error' || (next.status === 'completed' && next.error)) {
finishWithResult(true);
return;
}
if (next.status === 'completed') {
// Reported by the old process just before it exits. Reload once the
// new version is serving.
restartConfirmed = true;
resolvingTerminalStatus = true;
stopStatusFeeds();
const probeSession = session;
void attemptReadyReload().then((reloaded) => {
if (reloaded || probeSession !== session) {
return;
}
resolvingTerminalStatus = false;
// Backend not on the new version yet — restart in progress.
enterConfirmedRestartPhase();
});
return;
}
// Any other live status means the old process is still answering.
leaveSuspectedRestartPhase();
};
// 'idle' is what a freshly started process reports, so it is the restart
// signal when the stream was held open and never delivered 'completed'.
// It is also what an update check reports, so it only ends the update
// once the version has actually moved; otherwise the feeds keep running.
const handleIdleStatus = (previous: UpdateStatus | null) => {
const updateWasRunning = previous !== null && isUpdateInProgressStage(previous.status);
resolvingTerminalStatus = true;
if (!updateWasRunning) {
stopStatusFeeds();
}
const probeSession = session;
void attemptReadyReload().then((reloaded) => {
if (reloaded || probeSession !== session) {
return;
}
resolvingTerminalStatus = false;
if (!updateWasRunning) {
finishWithResult(false);
}
});
};
const setupSSE = () => {
// Close existing connection if any
closeSSE();
@@ -115,48 +391,18 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
try {
// Create EventSource connection to SSE endpoint
eventSource = new EventSource('/api/updates/stream');
// Armed before the connection opens: a request that never connects is
// as silent as one that connects and then stops delivering.
armStreamSilenceWatchdog();
eventSource.onopen = () => {
logger.info('SSE connection established');
};
eventSource.onmessage = (event) => {
armStreamSilenceWatchdog();
try {
const updateStatus = JSON.parse(event.data) as UpdateStatus;
setStatus(updateStatus);
// Check if restarting
if (updateStatus.status === 'restarting') {
setIsRestarting(true);
closeSSE();
startHealthCheckPolling();
return;
}
// Check if complete or error
if (
updateStatus.status === 'completed' ||
updateStatus.status === 'idle' ||
updateStatus.status === 'error'
) {
if (updateStatus.status === 'completed' && !updateStatus.error) {
closeSSE();
void attemptReadyReload().then((reloaded) => {
if (!reloaded) {
// Backend not on the new version yet — restart in progress.
setIsRestarting(true);
startHealthCheckPolling();
}
});
return;
}
setIsComplete(true);
if (updateStatus.status === 'error' || updateStatus.error) {
setHasError(true);
}
closeSSE();
}
handleStatus(JSON.parse(event.data) as UpdateStatus);
} catch (error) {
logger.error('Failed to parse SSE update status', error);
}
@@ -178,73 +424,43 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
const startPolling = () => {
// Don't start polling if already polling
if (pollInterval !== undefined) {
if (pollInterval !== undefined || isComplete() || restartConfirmed) {
return;
}
logger.info('Starting status polling (SSE not available)');
pollStatus();
pollInterval = setInterval(pollStatus, 2000) as unknown as number;
logger.info('Starting update status polling');
void pollStatus();
pollInterval = setInterval(pollStatus, UPDATE_STATUS_POLL_INTERVAL_MS) as unknown as number;
};
const pollStatus = async () => {
const pollSession = session;
try {
const currentStatus = await UpdatesAPI.getUpdateStatus();
setStatus(currentStatus);
// Check if restarting
if (currentStatus.status === 'restarting') {
setIsRestarting(true);
clearPollInterval();
// Start health check polling
startHealthCheckPolling();
if (pollSession !== session) {
return;
}
// Check if complete or error
if (
currentStatus.status === 'completed' ||
currentStatus.status === 'idle' ||
currentStatus.status === 'error'
) {
// If completed successfully, reload once the new version is serving
if (currentStatus.status === 'completed' && !currentStatus.error) {
clearPollInterval();
if (await attemptReadyReload()) {
return;
}
// Backend not on the new version yet — restart in progress.
setIsRestarting(true);
startHealthCheckPolling();
return;
}
setIsComplete(true);
if (currentStatus.status === 'error' || currentStatus.error) {
setHasError(true);
}
clearPollInterval();
}
consecutivePollFailures = 0;
handleStatus(currentStatus);
} catch (error) {
logger.error('Failed to poll update status', error);
// If we get errors during update, assume we're restarting
const currentStatus = status();
const shouldAssumeRestart =
!isRestarting() &&
(!currentStatus || (currentStatus.status !== 'idle' && currentStatus.status !== 'error'));
if (shouldAssumeRestart) {
if (!currentStatus) {
setStatus({
status: 'restarting',
progress: 95,
message: 'Restarting service...',
updatedAt: new Date().toISOString(),
});
}
setIsRestarting(true);
clearPollInterval();
startHealthCheckPolling();
if (pollSession !== session || isComplete() || restartConfirmed || resolvingTerminalStatus) {
return;
}
consecutivePollFailures += 1;
logger.warn('Failed to poll update status, will retry', {
consecutiveFailures: consecutivePollFailures,
error,
});
// One failed poll proves nothing: a transient network error or a 500
// during a long download must not end the session. Treat failures as
// restart evidence only when the stream has also closed, several polls
// in a row failed, and the update had reached the stage that restarts.
if (
!eventSource &&
consecutivePollFailures >= RESTART_SUSPECT_POLL_FAILURES &&
isLateUpdateStage(status()?.status)
) {
enterSuspectedRestartPhase();
}
}
};
@@ -252,16 +468,25 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
const startHealthCheckPolling = () => {
clearHealthCheckTimer();
setHealthCheckAttempts(0);
// clearHealthCheckTimer (also run on close and on leaving a suspected
// restart) moves the loop id on, retiring this loop even mid-probe.
const loop = healthCheckLoop;
const checkHealth = async () => {
healthCheckTimer = undefined;
if (loop !== healthCheckLoop) {
return;
}
if (await attemptReadyReload()) {
return;
}
if (loop !== healthCheckLoop) {
return;
}
const attempt = Math.min(healthCheckAttempts(), 3);
const nextDelay = Math.min(2000 * Math.pow(2, attempt), 15000);
setHealthCheckAttempts((current) => current + 1);
clearHealthCheckTimer();
healthCheckTimer = window.setTimeout(checkHealth, nextDelay);
};
@@ -295,24 +520,37 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
}
});
// Start/stop SSE or polling based on modal visibility
// Start/stop SSE or polling based on modal visibility. Only isOpen is
// tracked: the setup reads other signals (versionInfo, isComplete), and a
// change in any of them must not tear down and restart a live session.
createEffect(() => {
if (props.isOpen) {
resetModalState();
// Try SSE first, will fall back to polling if it fails
setupSSE();
} else {
// Stop everything when modal closes
closeSSE();
clearPollInterval();
clearHealthCheckTimer();
const open = props.isOpen;
untrack(() => {
session += 1;
stopEverything();
if (open) {
resetModalState();
fetchBaseline();
armProgressStallTimer();
// Try SSE first; a stream error or prolonged silence adds polling.
setupSSE();
}
});
});
// Take the baseline from the store as soon as it loads, if the modal opened
// first. canAdoptBaseline refuses it once a restart may be under way.
createEffect(() => {
const version = updateStore.versionInfo()?.version;
if (!props.isOpen || !version) {
return;
}
untrack(() => adoptBaseline(version, 'update-store'));
});
onCleanup(() => {
closeSSE();
clearPollInterval();
clearHealthCheckTimer();
session += 1;
stopEverything();
});
const getStageIcon = () => {
@@ -466,7 +704,7 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
</Show>
</CalloutCard>
</Show>
<Show when={!isRestarting()}>
<Show when={!isRestarting() && !progressStalled()}>
<CalloutCard
tone="warning"
scale="compact"
@@ -480,6 +718,32 @@ export function UpdateProgressModal(props: UpdateProgressModalProps) {
}
/>
</Show>
<Show when={progressStalled()}>
<CalloutCard
tone="warning"
scale="compact"
padding="md"
class="mt-6"
icon={<AlertTriangleIcon class="h-5 w-5" aria-hidden="true" />}
title="No progress reported for a while"
description={
<span class="text-sm">
The update may have finished already. Reload the page to check. Pulse keeps
updating on the server either way.
</span>
}
>
<Button
onClick={() => window.location.reload()}
variant="primary"
size="sm"
class="mt-2"
type="button"
>
Reload to check
</Button>
</CalloutCard>
</Show>
</Show>
</div>
@@ -0,0 +1,514 @@
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { UpdateStatus } from '@/api/updates';
import {
MAX_SAME_VERSION_HEALTHY_ATTEMPTS,
UPDATE_PROGRESS_STALL_TIMEOUT_MS,
UPDATE_STATUS_POLL_INTERVAL_MS,
UPDATE_STREAM_SILENCE_FALLBACK_MS,
} from '@/components/updateReadinessModel';
const getUpdateStatusMock = vi.hoisted(() => vi.fn());
const apiFetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/api/updates', () => ({
UpdatesAPI: { getUpdateStatus: getUpdateStatusMock },
}));
vi.mock('@/utils/apiClient', () => ({
apiFetch: apiFetchMock,
}));
vi.mock('@/stores/updates', async () => {
const { createSignal } = await import('solid-js');
const [versionInfo, setVersionInfo] = createSignal<{ version: string } | null>({
version: '6.4.5-rc.5',
});
return { updateStore: { versionInfo }, __setStoreVersionInfo: setVersionInfo };
});
vi.mock('@/utils/logger', () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
import { UpdateProgressModal } from '@/components/UpdateProgressModal';
import * as updatesStoreModule from '@/stores/updates';
const setStoreVersionInfo = (
updatesStoreModule as unknown as {
__setStoreVersionInfo: (value: { version: string } | null) => void;
}
).__setStoreVersionInfo;
class MockEventSource {
static instances: MockEventSource[] = [];
readonly url: string;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
readyState = 0;
closed = false;
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
close() {
this.closed = true;
this.readyState = 2;
}
emitOpen() {
this.readyState = 1;
this.onopen?.(new Event('open'));
}
emitError() {
this.onerror?.(new Event('error'));
}
emitStatus(status: Partial<UpdateStatus>) {
this.onmessage?.({
data: JSON.stringify({ message: '', updatedAt: '', ...status }),
} as MessageEvent);
}
}
const statusOf = (status: string, progress: number, message = ''): UpdateStatus => ({
status,
progress,
message,
updatedAt: new Date().toISOString(),
});
const versionResponse = (version: string) => ({
ok: true,
json: async () => ({ version }),
});
const renderModal = () =>
render(() => (
<UpdateProgressModal
isOpen={true}
onClose={() => undefined}
onViewHistory={() => undefined}
connected={() => true}
reconnecting={() => false}
/>
));
describe('UpdateProgressModal progress feed', () => {
const originalEventSource = globalThis.EventSource;
const originalLocation = window.location;
let reloadMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
MockEventSource.instances = [];
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource =
MockEventSource as unknown as typeof EventSource;
reloadMock = vi.fn();
// jsdom marks location.reload as non-configurable, so replace the whole object
Object.defineProperty(window, 'location', {
value: { ...originalLocation, reload: reloadMock },
writable: true,
configurable: true,
});
getUpdateStatusMock.mockReset();
apiFetchMock.mockReset();
setStoreVersionInfo({ version: '6.4.5-rc.5' });
});
afterEach(() => {
cleanup();
vi.useRealTimers();
(globalThis as unknown as { EventSource: typeof EventSource }).EventSource =
originalEventSource;
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});
it('polls when the stream opens and then goes silent, and reloads once the new version serves', async () => {
// The stream delivers the first stage and then nothing, without erroring:
// the "stuck at Downloading 10%" report.
const polled = [
statusOf('applying', 80, 'Applying update...'),
statusOf('restarting', 95, 'Restarting service...'),
];
getUpdateStatusMock.mockImplementation(async () => polled.shift() ?? polled[0]);
apiFetchMock.mockResolvedValue(versionResponse('6.4.5'));
renderModal();
const stream = MockEventSource.instances[0];
expect(stream.url).toBe('/api/updates/stream');
stream.emitOpen();
stream.emitStatus({ status: 'downloading', progress: 10, message: 'Downloading update...' });
expect(await screen.findByText('Downloading update...')).toBeInTheDocument();
expect(screen.getByText('10%')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(UPDATE_STREAM_SILENCE_FALLBACK_MS - 1);
expect(getUpdateStatusMock).not.toHaveBeenCalled();
// The EventSource is still open and never errored; silence alone starts polling.
await vi.advanceTimersByTimeAsync(1);
expect(stream.closed).toBe(false);
expect(getUpdateStatusMock).toHaveBeenCalledTimes(1);
expect(await screen.findByText('Applying update...')).toBeInTheDocument();
expect(screen.getByText('80%')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS);
expect(screen.getByText('Pulse is restarting...')).toBeInTheDocument();
expect(stream.closed).toBe(true);
// The first health check is scheduled immediately (fake timers treat a
// zero delay scheduled mid-tick as 1ms).
await vi.advanceTimersByTimeAsync(1);
expect(apiFetchMock).toHaveBeenCalledWith('/api/version', { cache: 'no-store' });
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('keeps probing through the restart and reloads only when a different version is served', async () => {
const versions = [
Promise.resolve(versionResponse('6.4.5-rc.5')),
Promise.reject(new Error('connection refused')),
Promise.resolve(versionResponse('6.4.5')),
];
// Avoid unhandled-rejection noise for the pre-built rejected promise.
versions[1].catch(() => undefined);
apiFetchMock.mockImplementation(() => versions.shift() ?? versions[versions.length - 1]);
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({
status: 'completed',
progress: 100,
message: 'Update completed, restarting...',
});
// First probe still answers with the old process's version: wait.
await vi.advanceTimersByTimeAsync(0);
expect(reloadMock).not.toHaveBeenCalled();
expect(await screen.findByText('Pulse is restarting...')).toBeInTheDocument();
// Health check during the restart window fails, then the new version answers.
await vi.advanceTimersByTimeAsync(0);
expect(reloadMock).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(reloadMock).toHaveBeenCalledTimes(1);
expect(getUpdateStatusMock).not.toHaveBeenCalled();
});
it('offers a reload instead of an endless spinner when progress stops moving', async () => {
getUpdateStatusMock.mockResolvedValue(statusOf('downloading', 10, 'Downloading update...'));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'downloading', progress: 10, message: 'Downloading update...' });
expect(
screen.getByText('Please do not close this window or refresh the page during the update.'),
).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(UPDATE_PROGRESS_STALL_TIMEOUT_MS - 1);
expect(screen.queryByText('No progress reported for a while')).not.toBeInTheDocument();
// Polling kept echoing the same stage; that is not progress.
expect(getUpdateStatusMock.mock.calls.length).toBeGreaterThan(1);
await vi.advanceTimersByTimeAsync(1);
expect(screen.getByText('No progress reported for a while')).toBeInTheDocument();
expect(
screen.queryByText('Please do not close this window or refresh the page during the update.'),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Reload to check' }));
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('ignores stale polled stages that would move progress backwards', async () => {
getUpdateStatusMock.mockResolvedValue(statusOf('downloading', 10, 'Downloading update...'));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'extracting', progress: 40, message: 'Extracting update...' });
await vi.advanceTimersByTimeAsync(UPDATE_STREAM_SILENCE_FALLBACK_MS);
expect(getUpdateStatusMock).toHaveBeenCalled();
expect(screen.getByText('Extracting update...')).toBeInTheDocument();
expect(screen.queryByText('Downloading update...')).not.toBeInTheDocument();
});
it('rides out a transient poll failure during a download without reloading', async () => {
const polled: Array<UpdateStatus | Error> = [
new Error('Request failed with status 500'),
statusOf('downloading', 10, 'Downloading update...'),
statusOf('extracting', 40, 'Extracting update...'),
];
getUpdateStatusMock.mockImplementation(async () => {
const next = polled.shift() ?? statusOf('extracting', 40, 'Extracting update...');
if (next instanceof Error) {
throw next;
}
return next;
});
apiFetchMock.mockResolvedValue(versionResponse('6.4.5-rc.5'));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'downloading', progress: 10, message: 'Downloading update...' });
// Silence starts polling; the first poll fails with a 500.
await vi.advanceTimersByTimeAsync(UPDATE_STREAM_SILENCE_FALLBACK_MS);
expect(getUpdateStatusMock).toHaveBeenCalledTimes(1);
expect(stream.closed).toBe(false);
expect(screen.queryByText('Pulse is restarting...')).not.toBeInTheDocument();
expect(screen.getByText('Downloading update...')).toBeInTheDocument();
// Polling carries on and progress keeps moving.
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS * 2);
expect(screen.getByText('Extracting update...')).toBeInTheDocument();
expect(screen.getByText('40%')).toBeInTheDocument();
// The stream is still live and later stages from it are still honoured.
stream.emitStatus({ status: 'backing-up', progress: 60, message: 'Creating backup...' });
expect(screen.getByText('Creating backup...')).toBeInTheDocument();
expect(stream.closed).toBe(false);
expect(apiFetchMock).not.toHaveBeenCalled();
expect(reloadMock).not.toHaveBeenCalled();
});
it('keeps polling through repeated failures early in the update, even with the stream closed', async () => {
let failing = true;
getUpdateStatusMock.mockImplementation(async () => {
if (failing) {
throw new Error('network error');
}
return statusOf('verifying', 25, 'Verifying signature...');
});
apiFetchMock.mockResolvedValue(versionResponse('6.4.5-rc.5'));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'downloading', progress: 10, message: 'Downloading update...' });
stream.emitError();
expect(stream.closed).toBe(true);
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS * 6);
expect(getUpdateStatusMock.mock.calls.length).toBeGreaterThanOrEqual(6);
expect(screen.queryByText('Pulse is restarting...')).not.toBeInTheDocument();
expect(apiFetchMock).not.toHaveBeenCalled();
failing = false;
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS);
expect(screen.getByText('Verifying signature...')).toBeInTheDocument();
expect(reloadMock).not.toHaveBeenCalled();
});
it('never reloads on an unchanged version when the restart was only inferred', async () => {
getUpdateStatusMock.mockRejectedValue(new Error('connection refused'));
let serving = '6.4.5-rc.5';
apiFetchMock.mockImplementation(async () => versionResponse(serving));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'applying', progress: 80, message: 'Applying update...' });
stream.emitError();
// Two failed polls late in the update, with the stream gone: probe for
// the restarted backend, but the restart is not confirmed.
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS);
expect(screen.getByText('Pulse is restarting...')).toBeInTheDocument();
// Far more same-version answers than the confirmed-restart fallback allows.
await vi.advanceTimersByTimeAsync(15000 * (MAX_SAME_VERSION_HEALTHY_ATTEMPTS + 4));
expect(apiFetchMock.mock.calls.length).toBeGreaterThan(MAX_SAME_VERSION_HEALTHY_ATTEMPTS + 1);
expect(reloadMock).not.toHaveBeenCalled();
// The user gets the manual check instead.
expect(screen.getByText('No progress reported for a while')).toBeInTheDocument();
// Real evidence (the new version serving) still reloads.
serving = '6.4.5';
await vi.advanceTimersByTimeAsync(15000);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('lets a late failure correct an inferred restart instead of reloading', async () => {
let pollFails = true;
getUpdateStatusMock.mockImplementation(async () => {
if (pollFails) {
throw new Error('connection refused');
}
return { ...statusOf('error', 80, 'Failed to apply update'), error: 'disk full' };
});
apiFetchMock.mockResolvedValue(versionResponse('6.4.5-rc.5'));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'applying', progress: 80, message: 'Applying update...' });
stream.emitError();
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS);
expect(screen.getByText('Pulse is restarting...')).toBeInTheDocument();
pollFails = false;
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS);
expect(screen.getByText('Update Failed')).toBeInTheDocument();
expect(screen.getByText('disk full')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(60000);
expect(reloadMock).not.toHaveBeenCalled();
});
it('reloads after a confirmed restart only once the new version serves', async () => {
getUpdateStatusMock.mockResolvedValue(statusOf('downloading', 10, 'Downloading update...'));
let serving = '6.4.5-rc.5';
apiFetchMock.mockImplementation(async () => versionResponse(serving));
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'restarting', progress: 95, message: 'Restarting service...' });
expect(stream.closed).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(apiFetchMock).toHaveBeenCalled();
expect(reloadMock).not.toHaveBeenCalled();
// A stale status from a poll cannot drag a confirmed restart back.
expect(getUpdateStatusMock).not.toHaveBeenCalled();
serving = '6.4.5';
await vi.advanceTimersByTimeAsync(15000);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
describe('when the modal opens before the running version is known', () => {
beforeEach(() => {
setStoreVersionInfo(null);
});
it('fetches the pre-update version on open and uses it for restart detection', async () => {
let serving = '6.4.5-rc.5';
apiFetchMock.mockImplementation(async () => versionResponse(serving));
renderModal();
expect(apiFetchMock).toHaveBeenCalledWith('/api/version', { cache: 'no-store' });
await vi.advanceTimersByTimeAsync(0);
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'completed', progress: 100, message: 'Update completed' });
// The old process still answers with the captured version: wait.
await vi.advanceTimersByTimeAsync(4000);
expect(reloadMock).not.toHaveBeenCalled();
serving = '6.4.5';
await vi.advanceTimersByTimeAsync(15000);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('takes the baseline from the store once it loads', async () => {
getUpdateStatusMock.mockRejectedValue(new Error('connection refused'));
let serving = '6.4.5-rc.5';
let baselineCall = true;
apiFetchMock.mockImplementation(async () => {
if (baselineCall) {
baselineCall = false;
throw new Error('network blip');
}
return versionResponse(serving);
});
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'downloading', progress: 10, message: 'Downloading update...' });
await vi.advanceTimersByTimeAsync(0);
setStoreVersionInfo({ version: '6.4.5-rc.5' });
stream.emitStatus({ status: 'applying', progress: 80, message: 'Applying update...' });
stream.emitError();
// Suspected restart; old-version answers must not reload.
await vi.advanceTimersByTimeAsync(60000);
expect(screen.getByText('Pulse is restarting...')).toBeInTheDocument();
expect(reloadMock).not.toHaveBeenCalled();
serving = '6.4.5';
await vi.advanceTimersByTimeAsync(15000);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
it('never reloads without a baseline while completion is unconfirmed', async () => {
getUpdateStatusMock.mockRejectedValue(new Error('connection refused'));
let baselineCall = true;
apiFetchMock.mockImplementation(async () => {
if (baselineCall) {
baselineCall = false;
throw new Error('network blip');
}
// The old process, still healthy.
return versionResponse('6.4.5-rc.5');
});
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
stream.emitStatus({ status: 'applying', progress: 80, message: 'Applying update...' });
stream.emitError();
await vi.advanceTimersByTimeAsync(UPDATE_STATUS_POLL_INTERVAL_MS);
expect(screen.getByText('Pulse is restarting...')).toBeInTheDocument();
await vi.advanceTimersByTimeAsync(15000 * (MAX_SAME_VERSION_HEALTHY_ATTEMPTS + 4));
expect(apiFetchMock.mock.calls.length).toBeGreaterThan(MAX_SAME_VERSION_HEALTHY_ATTEMPTS + 1);
expect(reloadMock).not.toHaveBeenCalled();
expect(screen.getByText('No progress reported for a while')).toBeInTheDocument();
});
it('after confirmed completion without a baseline, reloads only once the restart was seen', async () => {
const answers: Array<'blip' | 'down' | string> = [
'blip', // baseline fetch on open fails
'6.4.5-rc.5', // old process still answering after 'completed'
'6.4.5-rc.5',
'down', // old process exits
'6.4.5', // new process
];
apiFetchMock.mockImplementation(async () => {
const next = answers.length > 1 ? answers.shift()! : answers[0];
if (next === 'blip' || next === 'down') {
throw new Error(next);
}
return versionResponse(next);
});
renderModal();
const stream = MockEventSource.instances[0];
stream.emitOpen();
await vi.advanceTimersByTimeAsync(0);
stream.emitStatus({ status: 'completed', progress: 100, message: 'Update completed' });
// Two healthy answers from the old process right after 'completed', then
// the probe at 2s finds it gone.
await vi.advanceTimersByTimeAsync(2000);
expect(apiFetchMock).toHaveBeenCalledTimes(4);
expect(reloadMock).not.toHaveBeenCalled();
expect(screen.getByText('Pulse is restarting...')).toBeInTheDocument();
// The next healthy answer after the restart was seen reloads.
await vi.advanceTimersByTimeAsync(4000);
expect(apiFetchMock).toHaveBeenCalledTimes(5);
expect(reloadMock).toHaveBeenCalledTimes(1);
});
});
});
@@ -58,12 +58,55 @@ describe('resolvePostUpdateReload', () => {
).toBe('wait');
});
it('reloads on the first healthy response when no pre-update version is known', () => {
it('never reloads without a pre-update version while completion is unconfirmed', () => {
// An old process answering healthy is indistinguishable from the new one.
for (const completionConfirmed of [undefined, false]) {
expect(
resolvePostUpdateReload({
preUpdateVersion: null,
reportedVersion: '6.1.0-rc.4',
sameVersionHealthyAttempts: MAX_SAME_VERSION_HEALTHY_ATTEMPTS + 10,
completionConfirmed,
restartObserved: true,
}),
).toBe('wait');
}
});
it('without a pre-update version, reloads after confirmed completion once the restart was seen', () => {
const base = {
preUpdateVersion: null,
reportedVersion: '6.1.0-rc.4',
completionConfirmed: true,
};
// The about-to-exit process still answers right after 'completed'.
expect(resolvePostUpdateReload({ ...base, sameVersionHealthyAttempts: 0 })).toBe('wait');
expect(
resolvePostUpdateReload({ ...base, sameVersionHealthyAttempts: 0, restartObserved: true }),
).toBe('reload');
expect(
resolvePostUpdateReload({
preUpdateVersion: null,
reportedVersion: '6.1.0-rc.4',
...base,
sameVersionHealthyAttempts: MAX_SAME_VERSION_HEALTHY_ATTEMPTS,
}),
).toBe('reload');
});
it('never falls back to a same-version reload while completion is unconfirmed', () => {
expect(
resolvePostUpdateReload({
preUpdateVersion: '6.4.5-rc.5',
reportedVersion: '6.4.5-rc.5',
sameVersionHealthyAttempts: MAX_SAME_VERSION_HEALTHY_ATTEMPTS + 10,
completionConfirmed: false,
}),
).toBe('wait');
expect(
resolvePostUpdateReload({
preUpdateVersion: '6.4.5-rc.5',
reportedVersion: '6.4.5',
sameVersionHealthyAttempts: 0,
completionConfirmed: false,
}),
).toBe('reload');
});
@@ -21,19 +21,101 @@ export const resolvePostUpdateReload = (input: {
preUpdateVersion: string | null;
reportedVersion: string;
sameVersionHealthyAttempts: number;
// Whether the backend itself reported 'restarting' or 'completed'. Without
// that, an unchanged version may simply mean the update is still running,
// so the same-version fallback must never fire.
completionConfirmed?: boolean;
// Whether a probe failed (connection refused or a non-OK answer) after
// completion was confirmed, i.e. the old process was seen going away.
restartObserved?: boolean;
}): PostUpdateReloadDecision => {
// Without a known pre-update version there is nothing to compare against;
// a healthy response is the best remaining signal.
// Without a known pre-update version a healthy answer cannot tell the old
// process from the new one. Never reload before the backend confirms
// completion. After that, reload once the old process was seen going away
// and something healthy answers again, or after the bounded fallback.
if (!input.preUpdateVersion) {
return 'reload';
if (input.completionConfirmed !== true) {
return 'wait';
}
if (input.restartObserved === true) {
return 'reload';
}
return input.sameVersionHealthyAttempts >= MAX_SAME_VERSION_HEALTHY_ATTEMPTS
? 'reload'
: 'wait';
}
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.
// answering, or this deployment never restarts. Wait, but not forever,
// and only once completion is confirmed. Unconfirmed, the modal's stall
// state offers a manual reload instead.
if (input.completionConfirmed === false) {
return 'wait';
}
if (input.sameVersionHealthyAttempts >= MAX_SAME_VERSION_HEALTHY_ATTEMPTS) {
return 'reload';
}
return 'wait';
};
// The progress stream can go quiet without ever erroring: a reverse proxy or
// compressing intermediary can hold events back, and a held connection never
// reports the old process exiting. After this much SSE silence during an
// in-progress update the modal also polls /api/updates/status, while leaving
// the stream open in case it recovers.
export const UPDATE_STREAM_SILENCE_FALLBACK_MS = 6000;
// Cadence of the /api/updates/status fallback poll.
export const UPDATE_STATUS_POLL_INTERVAL_MS = 2500;
// With no new stage, progress, or restart signal for this long, stop implying
// the update is still running and offer a reload so the user can check.
export const UPDATE_PROGRESS_STALL_TIMEOUT_MS = 120_000;
const UPDATE_IN_PROGRESS_STAGES = new Set([
'downloading',
'verifying',
'extracting',
'backing-up',
'applying',
'restoring',
]);
// Consecutive failed status polls, with the stream closed and the update at
// a stage that restarts the service, before the modal starts probing for the
// restarted backend.
export const RESTART_SUSPECT_POLL_FAILURES = 2;
// Stages after which the service restarts on success.
const UPDATE_LATE_STAGES = new Set(['applying', 'restoring', 'restarting', 'completed']);
export const isLateUpdateStage = (status: string | undefined): boolean =>
status !== undefined && UPDATE_LATE_STAGES.has(status);
// Update checks share the status channel with the apply lifecycle.
const UPDATE_CHECK_STATUSES = new Set(['checking', 'available']);
export const isUpdateInProgressStage = (status: string | undefined): boolean =>
status !== undefined && UPDATE_IN_PROGRESS_STAGES.has(status);
// SSE and polling both feed the modal, and a rate-limited or slow poll can
// answer with a stage the stream already moved past. Once an apply is under
// way, ignore update-check chatter and in-progress stages that would move
// the progress backwards.
export const shouldApplyUpdateStatus = (
current: { status: string; progress: number } | null,
next: { status: string; progress: number },
): boolean => {
if (!current || !isUpdateInProgressStage(current.status)) {
return true;
}
if (UPDATE_CHECK_STATUSES.has(next.status)) {
return false;
}
if (isUpdateInProgressStage(next.status) && next.progress < current.progress) {
return false;
}
return true;
};
+11 -5
View File
@@ -481,10 +481,15 @@ func (h *UpdateHandlers) HandleUpdateStream(w http.ResponseWriter, r *http.Reque
return
}
// Set SSE headers
// Set SSE headers. no-transform and X-Accel-Buffering keep reverse
// proxies (nginx, Cloudflare, compressing ingresses) from buffering or
// re-encoding the stream: a buffered stream looks exactly like an update
// that stopped reporting progress. The gzip middleware already skips
// text/event-stream, so this is the only encoding the stream ever has.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Generate client ID
@@ -503,9 +508,10 @@ func (h *UpdateHandlers) HandleUpdateStream(w http.ResponseWriter, r *http.Reque
Str("client_ip", clientIP).
Msg("Update progress SSE stream started")
// Send initial connection message
fmt.Fprintf(w, ": connected\n\n")
client.Flusher.Flush()
// AddSSEClient has already written the connection preamble and the
// current status and flushed them. All further writes go through the
// broadcaster under the client's lock; writing here as well would race
// the broadcaster on a ResponseWriter that is not safe for concurrent use.
// Wait for client disconnect or context cancellation
select {
+162
View File
@@ -1,12 +1,15 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
@@ -645,6 +648,165 @@ func TestHandleUpdateStream(t *testing.T) {
}
}
// streamFlushRecorder is a goroutine-safe ResponseWriter that records what
// had actually been flushed to the wire at each Flush, so tests can tell a
// flushed SSE frame from one sitting in a buffer.
type streamFlushRecorder struct {
mu sync.Mutex
header http.Header
code int
written bytes.Buffer
flushed string
flushCh chan struct{}
}
func newStreamFlushRecorder() *streamFlushRecorder {
return &streamFlushRecorder{header: make(http.Header), flushCh: make(chan struct{}, 64)}
}
func (r *streamFlushRecorder) Header() http.Header { return r.header }
func (r *streamFlushRecorder) WriteHeader(code int) {
r.mu.Lock()
defer r.mu.Unlock()
if r.code == 0 {
r.code = code
}
}
func (r *streamFlushRecorder) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.code == 0 {
r.code = http.StatusOK
}
return r.written.Write(p)
}
func (r *streamFlushRecorder) Flush() {
r.mu.Lock()
r.flushed = r.written.String()
r.mu.Unlock()
select {
case r.flushCh <- struct{}{}:
default:
}
}
func (r *streamFlushRecorder) flushedBody() string {
r.mu.Lock()
defer r.mu.Unlock()
return r.flushed
}
// waitForFlushed blocks until the flushed body contains every needle.
func (r *streamFlushRecorder) waitForFlushed(t *testing.T, needles ...string) string {
t.Helper()
deadline := time.After(2 * time.Second)
for {
body := r.flushedBody()
missing := false
for _, needle := range needles {
if !strings.Contains(body, needle) {
missing = true
break
}
}
if !missing {
return body
}
select {
case <-r.flushCh:
case <-time.After(20 * time.Millisecond):
case <-deadline:
t.Fatalf("timed out waiting for flushed %q, flushed so far:\n%s", needles, body)
}
}
}
// The update modal stalled at "Downloading 10%" when the progress stream
// went quiet. The stream must advertise itself as unbufferable, put the
// current status on the wire the moment a client connects, and flush every
// later stage in order.
func TestHandleUpdateStream_HeadersImmediateStatusAndFlushedStages(t *testing.T) {
broadcaster := updates.NewSSEBroadcaster()
defer broadcaster.Close()
broadcaster.Broadcast(updates.UpdateStatus{Status: "downloading", Progress: 10, Message: "Downloading update..."})
h := NewUpdateHandlers(&MockUpdateManager{
AddSSEClientFunc: broadcaster.AddClient,
RemoveSSEClientFunc: broadcaster.RemoveClient,
}, nil)
rec := newStreamFlushRecorder()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
req := httptest.NewRequest(http.MethodGet, "/api/updates/stream", nil).WithContext(ctx)
done := make(chan struct{})
go func() {
h.HandleUpdateStream(rec, req)
close(done)
}()
first := rec.waitForFlushed(t, ": connected", `"status":"downloading"`, `"progress":10`)
if !strings.Contains(first, "data: {") || !strings.HasSuffix(first, "\n\n") {
t.Fatalf("initial status must be a complete, flushed SSE data frame, got:\n%s", first)
}
if got := rec.Header().Get("Content-Type"); got != "text/event-stream" {
t.Fatalf("Content-Type = %q, want text/event-stream", got)
}
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-cache") || !strings.Contains(got, "no-transform") {
t.Fatalf("Cache-Control = %q, want no-cache and no-transform", got)
}
if got := rec.Header().Get("X-Accel-Buffering"); got != "no" {
t.Fatalf("X-Accel-Buffering = %q, want no", got)
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, the stream must never be compressed", got)
}
stages := []updates.UpdateStatus{
{Status: "verifying", Progress: 25, Message: "Verifying signature..."},
{Status: "verifying", Progress: 30, Message: "Verifying download..."},
{Status: "extracting", Progress: 40, Message: "Extracting update..."},
{Status: "verifying", Progress: 50, Message: "Validating new binary..."},
{Status: "backing-up", Progress: 60, Message: "Creating backup..."},
{Status: "applying", Progress: 80, Message: "Applying update..."},
{Status: "restarting", Progress: 95, Message: "Restarting service..."},
{Status: "completed", Progress: 100, Message: "Update completed, restarting..."},
}
for _, stage := range stages {
broadcaster.Broadcast(stage)
}
body := rec.waitForFlushed(t, `"status":"completed"`)
last := strings.Index(body, `"progress":10,`)
for _, stage := range stages {
marker := fmt.Sprintf(`"progress":%d,`, stage.Progress)
idx := strings.Index(body, marker)
if idx < 0 {
t.Fatalf("stage %s %d never reached the wire:\n%s", stage.Status, stage.Progress, body)
}
if idx < last {
t.Fatalf("stage %s %d was delivered out of order:\n%s", stage.Status, stage.Progress, body)
}
last = idx
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("HandleUpdateStream didn't return after client disconnect")
}
if n := broadcaster.GetClientCount(); n != 0 {
t.Fatalf("expected disconnected client to be removed, %d remain", n)
}
}
func TestHandleUpdateStream_StreamingNotSupported(t *testing.T) {
mockManager := &MockUpdateManager{
AddSSEClientFunc: func(w http.ResponseWriter, clientID string) *updates.SSEClient {
+6 -2
View File
@@ -298,7 +298,7 @@ func NewManager(cfg *config.Config) *Manager {
// Clean up old temp directories and stale update backups from previous runs.
go m.cleanupOldUpdateArtifacts()
// Start heartbeat for SSE connections (every 30 seconds)
// Start heartbeat for SSE connections (see sseHeartbeatInterval)
m.heartbeatWg.Add(1)
go m.sseHeartbeatLoop()
@@ -2280,11 +2280,15 @@ func (m *Manager) updateStatus(status string, progress int, message string, err
}
}
// sseHeartbeatInterval keeps idle update streams under common reverse-proxy
// read timeouts (nginx defaults to 60s) during long download stages.
const sseHeartbeatInterval = 15 * time.Second
// sseHeartbeatLoop sends periodic heartbeats to SSE clients
func (m *Manager) sseHeartbeatLoop() {
defer m.heartbeatWg.Done()
ticker := time.NewTicker(30 * time.Second)
ticker := time.NewTicker(sseHeartbeatInterval)
defer ticker.Stop()
for {
+75 -36
View File
@@ -11,6 +11,10 @@ import (
"github.com/rs/zerolog/log"
)
// sseClientQueueSize bounds how many undelivered statuses a single slow
// client may hold before new ones are dropped for that client.
const sseClientQueueSize = 64
// SSEClient represents a Server-Sent Events client
type SSEClient struct {
ID string
@@ -19,6 +23,9 @@ type SSEClient struct {
Done chan bool
LastActive time.Time
mu sync.Mutex // protects writes to Writer and Flusher
// queue delivers broadcasts to this client in order through a single
// writer goroutine. Nil for clients not registered via AddClient.
queue chan UpdateStatus
}
// SSEBroadcaster manages Server-Sent Events connections for update progress
@@ -77,6 +84,7 @@ func (b *SSEBroadcaster) AddClient(w http.ResponseWriter, clientID string) *SSEC
Flusher: flusher,
Done: make(chan bool, 1),
LastActive: time.Now(),
queue: make(chan UpdateStatus, sseClientQueueSize),
}
b.mu.Lock()
@@ -93,18 +101,59 @@ func (b *SSEBroadcaster) AddClient(w http.ResponseWriter, clientID string) *SSEC
Int("total_clients", clientCount).
Msg("SSE client connected")
// Send the current cached status immediately to the new client
// Write the connection preamble and the current status synchronously,
// before the ordered writer starts, so a (re)connecting client always
// sees where the update is right now instead of waiting for the next
// stage change. The cache is read after registration so a broadcast
// racing this connect is either in the snapshot or queued behind it.
b.statusMu.RLock()
cachedStatus := b.cachedStatus
b.statusMu.RUnlock()
go func() {
b.sendToClient(client, cachedStatus)
}()
if data, err := json.Marshal(cachedStatus); err == nil {
b.writeToClient(client, ": connected\n\n"+formatSSEData(data))
} else {
log.Error().Err(err).Str("client_id", clientID).Msg("Failed to marshal initial status for SSE")
b.writeToClient(client, ": connected\n\n")
}
go b.clientWriteLoop(client)
return client
}
// clientWriteLoop drains a client's queue so broadcasts reach it in the
// order they were emitted. A goroutine per message (the previous design)
// let a later stage overtake an earlier one on the wire.
func (b *SSEBroadcaster) clientWriteLoop(client *SSEClient) {
for {
select {
case <-client.Done:
return
case status := <-client.queue:
b.sendToClient(client, status)
}
}
}
// enqueueForClient hands a status to the client's ordered writer without
// blocking the broadcast loop on a slow connection.
func (b *SSEBroadcaster) enqueueForClient(client *SSEClient, status UpdateStatus) {
if client.queue == nil {
go b.sendToClient(client, status)
return
}
select {
case <-client.Done:
case client.queue <- status:
default:
log.Warn().
Str("client_id", client.ID).
Str("status", status.Status).
Msg("SSE client queue full, dropping update status for this client")
}
}
// RemoveClient unregisters an SSE client
func (b *SSEBroadcaster) RemoveClient(clientID string) {
b.mu.Lock()
@@ -176,9 +225,10 @@ func (b *SSEBroadcaster) broadcastLoop() {
}
b.mu.RUnlock()
// Send to all clients in parallel
// Each client has its own ordered writer, so a slow client
// neither blocks the others nor receives stages out of order.
for _, client := range clients {
go b.sendToClient(client, status)
b.enqueueForClient(client, status)
}
log.Debug().
@@ -206,10 +256,19 @@ func (b *SSEBroadcaster) sendToClient(client *SSEClient, status UpdateStatus) {
return
}
// Write SSE message format
// Format: data: {json}\n\n
message := fmt.Sprintf("data: %s\n\n", string(data))
b.writeToClient(client, formatSSEData(data))
}
// formatSSEData frames a JSON payload as a single SSE data event.
func formatSSEData(data []byte) string {
return fmt.Sprintf("data: %s\n\n", string(data))
}
// writeToClient writes a raw SSE frame and flushes it immediately. Every
// write to a client goes through here under the client lock: the
// ResponseWriter is not safe for concurrent use, and an unflushed frame is
// indistinguishable from a silent stream on the browser side.
func (b *SSEBroadcaster) writeToClient(client *SSEClient, frame string) {
// Acquire client lock to prevent concurrent writes
client.mu.Lock()
defer client.mu.Unlock()
@@ -227,18 +286,19 @@ func (b *SSEBroadcaster) sendToClient(client *SSEClient, status UpdateStatus) {
Str("client_id", client.ID).
Interface("panic", r).
Msg("Recovered from panic while sending to SSE client")
b.RemoveClient(client.ID)
go b.RemoveClient(client.ID)
}
}()
// Write to client
_, err = fmt.Fprint(client.Writer, message)
if err != nil {
if _, err := fmt.Fprint(client.Writer, frame); err != nil {
log.Debug().
Err(err).
Str("client_id", client.ID).
Msg("Failed to write to SSE client, removing")
b.RemoveClient(client.ID)
// Removal takes the broadcaster lock; do it off this goroutine so a
// concurrent cleanup pass holding that lock while waiting on this
// client's lock cannot deadlock.
go b.RemoveClient(client.ID)
return
}
@@ -332,28 +392,7 @@ func (b *SSEBroadcaster) sendHeartbeatToClient(c *SSEClient) {
default:
}
c.mu.Lock()
defer c.mu.Unlock()
select {
case <-c.Done:
return
default:
}
defer func() {
if r := recover(); r != nil {
b.RemoveClient(c.ID)
}
}()
_, err := fmt.Fprint(c.Writer, ": heartbeat\n\n")
if err != nil {
b.RemoveClient(c.ID)
return
}
c.Flusher.Flush()
c.LastActive = time.Now()
b.writeToClient(c, ": heartbeat\n\n")
}
func closeDone(ch chan bool) {
+93
View File
@@ -1,8 +1,11 @@
package updates
import (
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
@@ -239,3 +242,93 @@ func TestSSEBroadcaster_CloseIsIdempotentAndPostCloseSafe(t *testing.T) {
t.Fatal("expected no connected clients after close")
}
}
// A (re)connecting update modal must see the current stage immediately, not
// after the next stage change, which during a long download can be minutes.
func TestSSEBroadcaster_AddClientWritesCurrentStatusBeforeReturning(t *testing.T) {
broadcaster := NewSSEBroadcaster()
defer broadcaster.Close()
broadcaster.Broadcast(UpdateStatus{Status: "extracting", Progress: 40, Message: "Extracting update..."})
w := &mockFlushWriter{ResponseRecorder: httptest.NewRecorder()}
if client := broadcaster.AddClient(w, "client-now"); client == nil {
t.Fatal("AddClient returned nil")
}
body := w.Body.String()
if !strings.HasPrefix(body, ": connected\n\n") {
t.Fatalf("expected connection preamble first, got %q", body)
}
if !strings.Contains(body, `data: {"status":"extracting","progress":40`) {
t.Fatalf("expected current status on connect, got %q", body)
}
if w.flushed < 1 {
t.Fatal("initial status must be flushed, not left buffered")
}
}
// Broadcasts used to be sent from one goroutine per message, so stages
// emitted in quick succession could reach a client out of order.
func TestSSEBroadcaster_DeliversStagesInOrder(t *testing.T) {
broadcaster := NewSSEBroadcaster()
defer broadcaster.Close()
w := &syncFlushWriter{}
if client := broadcaster.AddClient(w, "client-order"); client == nil {
t.Fatal("AddClient returned nil")
}
const stages = 40
for i := 1; i <= stages; i++ {
broadcaster.Broadcast(UpdateStatus{Status: "verifying", Progress: i})
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && !strings.Contains(w.String(), `"progress":40,`) {
time.Sleep(5 * time.Millisecond)
}
body := w.String()
last := -1
for i := 1; i <= stages; i++ {
idx := strings.Index(body, `"progress":`+strconv.Itoa(i)+`,`)
if idx < 0 {
t.Fatalf("stage %d never delivered:\n%s", i, body)
}
if idx < last {
t.Fatalf("stage %d delivered out of order:\n%s", i, body)
}
last = idx
}
}
type syncFlushWriter struct {
mu sync.Mutex
header http.Header
buf strings.Builder
}
func (w *syncFlushWriter) Header() http.Header {
w.mu.Lock()
defer w.mu.Unlock()
if w.header == nil {
w.header = make(http.Header)
}
return w.header
}
func (w *syncFlushWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.Write(p)
}
func (w *syncFlushWriter) WriteHeader(int) {}
func (w *syncFlushWriter) Flush() {}
func (w *syncFlushWriter) String() string {
w.mu.Lock()
defer w.mu.Unlock()
return w.buf.String()
}