fix(updater): verify native host version and report restart failures

Complete #3227 by keeping browser completion polls on the native updater, checking the requested version, and preserving retry access after a failed restart.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 20:38:00 +03:00
parent c82aa7879b
commit f9d6b5c479
6 changed files with 229 additions and 85 deletions
+5 -84
View File
@@ -13,7 +13,7 @@ import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { getCurrentIntlLocale, useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { installWebUpdate, waitForUpdateApplied } from '@/lib/web-update';
type WebUpdateState = 'idle' | 'updating' | 'restarting' | 'reconnecting' | 'error';
@@ -111,85 +111,6 @@ function parseChangelogSections(body: string): ChangelogSection[] {
});
}
type InstallWebUpdateResult = {
success: boolean;
error?: string;
autoRestart?: boolean;
};
const WEB_UPDATE_POLL_INTERVAL_MS = 2000;
const WEB_UPDATE_MAX_WAIT_MS = 10 * 60 * 1000;
async function installWebUpdate(): Promise<InstallWebUpdateResult> {
try {
const response = await runtimeFetch('/api/openchamber/update-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
return { success: false, error: data.error || `Server error: ${response.status}` };
}
const data = await response.json().catch(() => ({}));
return {
success: true,
autoRestart: data.autoRestart !== false,
};
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : undefined };
}
}
async function isServerReachable(): Promise<boolean> {
try {
const response = await runtimeFetch('/health', {
method: 'GET',
headers: { Accept: 'application/json' },
});
return response.ok;
} catch {
return false;
}
}
async function waitForUpdateApplied(
previousVersion?: string,
maxAttempts = Math.ceil(WEB_UPDATE_MAX_WAIT_MS / WEB_UPDATE_POLL_INTERVAL_MS),
intervalMs = WEB_UPDATE_POLL_INTERVAL_MS,
): Promise<boolean> {
for (let i = 0; i < maxAttempts; i++) {
try {
// Status-only poll while waiting for the update to apply; not a usage report.
const response = await runtimeFetch('/api/openchamber/update-check?reportUsage=false', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = await response.json().catch(() => null);
if (data && data.available === false) {
return true;
}
if (
data &&
typeof data.currentVersion === 'string' &&
typeof previousVersion === 'string' &&
data.currentVersion !== previousVersion
) {
return true;
}
} else if ((response.status === 401 || response.status === 403) && await isServerReachable()) {
return true;
}
} catch {
// Server may be restarting
}
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
return false;
}
export const UpdateDialog: React.FC<UpdateDialogProps> = ({
open,
onOpenChange,
@@ -258,13 +179,13 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
setWebUpdateState('reconnecting');
const applied = await waitForUpdateApplied(info?.currentVersion);
const applied = await waitForUpdateApplied(result.target, info?.currentVersion);
if (applied) {
if (applied.status === 'applied') {
window.location.reload();
} else {
setWebUpdateState('error');
setWebError(t('updateDialog.error.takingLonger'));
setWebError(applied.status === 'failed' ? applied.error : t('updateDialog.error.takingLonger'));
}
}, [info?.currentVersion, t]);
@@ -414,7 +335,7 @@ export const UpdateDialog: React.FC<UpdateDialogProps> = ({
)}
{/* Web runtime fallback command */}
{isWebRuntime && webUpdateState === 'error' && (
{isWebRuntime && info?.packageManager !== 'electron' && webUpdateState === 'error' && (
<div className="space-y-2 mt-4">
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
<Icon name="terminal" className="h-4 w-4" />
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, test } from 'bun:test';
import { installWebUpdate, waitForUpdateApplied } from './web-update';
describe('browser host updates', () => {
test('keeps the native target version returned by the install request', async () => {
const result = await installWebUpdate(async () => Response.json({
success: true, updateOwner: 'electron-updater', version: '1.22.3', autoRestart: true,
}));
expect(result).toEqual({ success: true, autoRestart: true, target: { owner: 'electron', version: '1.22.3' } });
});
test('rejects a native success response without its target version', async () => {
expect(await installWebUpdate(async () => Response.json({
success: true, updateOwner: 'electron-updater',
}))).toEqual({ success: false });
});
test('rejects malformed success instead of starting the reconnect loop', async () => {
expect(await installWebUpdate(async () => Response.json({}))).toEqual({ success: false });
});
test('keeps native polling on the web host route until the target is installed', async () => {
let requests = 0;
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
intervalMs: 1, maxWaitMs: 1000,
fetchUpdate: async (url, init) => {
expect(url).toBe('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true');
expect(init?.signal).toBeInstanceOf(AbortSignal);
requests += 1;
// The old package feed can say no update while native installation is
// still pending. That does not prove the requested version is running.
return Response.json({ available: false, currentVersion: requests === 1 ? '1.22.2' : '1.22.3' });
},
});
expect(requests).toBe(2);
expect(result).toEqual({ status: 'applied' });
});
test('reports a rejected native restart without treating it as a transient disconnect', async () => {
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
fetchUpdate: async () => Response.json({
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
}, { status: 503 }),
});
expect(result).toEqual({ status: 'failed', error: 'Signature rejected' });
});
test('does not accept authentication loss as native upgrade completion', async () => {
const requests: string[] = [];
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
intervalMs: 1, maxWaitMs: 20,
fetchUpdate: async (url) => {
requests.push(String(url));
return Response.json({ error: 'Unauthorized' }, { status: 401 });
},
});
expect(result).toEqual({ status: 'timeout' });
expect(requests).not.toContain('/health');
});
test('a blocked poll cannot extend the overall deadline', async () => {
const result = await waitForUpdateApplied({ owner: 'electron', version: '1.22.3' }, '1.22.2', {
maxWaitMs: 20,
fetchUpdate: async (_url, init) => new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(new Error('Timed out')), { once: true });
}),
});
expect(result).toEqual({ status: 'timeout' });
});
test('preserves package-manager completion after a version change', async () => {
const result = await waitForUpdateApplied({ owner: 'package-manager' }, '1.22.2', {
fetchUpdate: async () => Response.json({ available: false, currentVersion: '1.22.3' }),
});
expect(result).toEqual({ status: 'applied' });
});
});
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
import { runtimeFetch } from './runtime-fetch';
const installResponse = z.object({
success: z.literal(true),
autoRestart: z.boolean().optional(),
updateOwner: z.string().optional(),
version: z.string().min(1).optional(),
});
const checkResponse = z.object({
available: z.boolean(),
currentVersion: z.string().optional(),
error: z.string().optional(),
});
const errorResponse = z.object({ error: z.string(), code: z.string().optional() });
type UpdateTarget = { owner: 'electron'; version: string } | { owner: 'package-manager' };
type InstallResult =
| { success: true; autoRestart: boolean; target: UpdateTarget }
| { success: false; error?: string };
type AppliedResult = { status: 'applied' } | { status: 'timeout' } | { status: 'failed'; error: string };
export async function installWebUpdate(fetchUpdate = runtimeFetch): Promise<InstallResult> {
try {
const response = await fetchUpdate('/api/openchamber/update-install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const payload = await response.json();
if (!response.ok) {
const error = errorResponse.safeParse(payload);
return { success: false, error: error.success ? error.data.error : undefined };
}
const parsed = installResponse.safeParse(payload);
if (!parsed.success) return { success: false };
const data = parsed.data;
if (data.updateOwner === 'electron-updater') {
if (!data.version) return { success: false };
return { success: true, autoRestart: data.autoRestart !== false, target: { owner: 'electron', version: data.version } };
}
return { success: true, autoRestart: data.autoRestart !== false, target: { owner: 'package-manager' } };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : undefined };
}
}
export async function waitForUpdateApplied(
target: UpdateTarget,
previousVersion?: string,
{ fetchUpdate = runtimeFetch, maxWaitMs = 10 * 60 * 1000, intervalMs = 2000 } = {},
): Promise<AppliedResult> {
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
const signal = AbortSignal.timeout(Math.max(1, Math.min(10_000, deadline - Date.now())));
try {
const response = await fetchUpdate('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true', {
method: 'GET', headers: { Accept: 'application/json' }, signal,
});
if (response.ok) {
const parsed = checkResponse.safeParse(await response.json());
if (parsed.success && !parsed.data.error) {
const data = parsed.data;
const applied = target.owner === 'electron'
? data.currentVersion === target.version
: data.available === false || (previousVersion !== undefined && data.currentVersion !== undefined && data.currentVersion !== previousVersion);
if (applied) return { status: 'applied' };
}
} else {
const parsed = errorResponse.safeParse(await response.json().catch(() => null));
if (parsed.success && parsed.data.code === 'DESKTOP_UPDATE_RESTART_FAILED') {
return { status: 'failed', error: parsed.data.error };
}
// Package-manager restarts can replace the browser session. A native
// update must still prove its target version after authentication.
if (target.owner === 'package-manager' && (response.status === 401 || response.status === 403)) {
const health = await fetchUpdate('/health', { headers: { Accept: 'application/json' }, signal });
if (health.ok) return { status: 'applied' };
}
}
} catch {
// A restarting host can disconnect or time out; retry within the deadline.
}
const remaining = deadline - Date.now();
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(intervalMs, remaining)));
}
return { status: 'timeout' };
}
@@ -368,6 +368,16 @@ before starting managed OpenCode. The managed custom tool therefore receives
an authoritative loopback callback URL even when OpenChamber binds port `0`.
## Public exports (openchamber-routes.js)
Browser completion checks use `appType=web&updateStatus=true` to stay on the
Desktop Host's native updater. A rejected native restart is retained in the
server process and returned to these polls as `DESKTOP_UPDATE_RESTART_FAILED`;
ordinary availability checks remain usable so a browser reload can offer a
retry. Starting another installation clears the previous restart error.
The shared UI's `lib/web-update.ts` parses install/check responses and waits
for the installed native target version, rather than treating absence of a
newer release as installation success. Poll requests have individual deadlines
within a ten-minute overall deadline.
- `registerOpenChamberRoutes(app, dependencies)`: registers OpenChamber endpoints:
- `GET /api/openchamber/update-check`
- `POST /api/openchamber/update-install`
@@ -32,6 +32,8 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
desktopUpdater,
} = dependencies;
let desktopRestartError = null;
app.get('/api/openchamber/update-check', async (req, res) => {
try {
const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined);
@@ -61,6 +63,12 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
};
let updateInfo;
if (process.env.OPENCHAMBER_RUNTIME === 'desktop' && updateRequest.appType === 'web') {
if (desktopRestartError && req.query.updateStatus === 'true') {
return res.status(503).json({
code: 'DESKTOP_UPDATE_RESTART_FAILED',
error: desktopRestartError,
});
}
if (typeof desktopUpdater?.check !== 'function') {
return res.status(503).json({
available: false,
@@ -97,6 +105,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
});
}
desktopRestartError = null;
const updateInfo = await desktopUpdater.install();
if (!updateInfo?.available) {
return res.status(400).json({ error: 'No update available' });
@@ -115,7 +124,10 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
setImmediate(() => {
Promise.resolve()
.then(() => desktopUpdater.restart())
.catch((error) => console.error('Failed to restart after desktop update:', error));
.catch((error) => {
desktopRestartError = error instanceof Error ? error.message : 'Failed to restart after desktop update';
console.error('Failed to restart after desktop update:', error);
});
});
return;
}
@@ -71,6 +71,43 @@ afterEach(() => {
});
describe('OpenChamber desktop host update route', () => {
it('reports a restart rejection until the user retries installation', async () => {
const desktopUpdater = {
check: vi.fn(async () => ({ available: true, currentVersion: '1.17.0', version: '1.17.1' })),
install: vi.fn(async () => ({ available: true, version: '1.17.1' })),
restart: vi.fn().mockRejectedValueOnce(new Error('Signature rejected')).mockResolvedValue(undefined),
};
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' }, desktopUpdater });
const logError = vi.spyOn(console, 'error').mockImplementation(() => {});
await request(app).post('/api/openchamber/update-install').expect(200);
await new Promise(resolve => setImmediate(resolve));
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false&updateStatus=true').expect(503, {
code: 'DESKTOP_UPDATE_RESTART_FAILED', error: 'Signature rejected',
});
expect(desktopUpdater.check).not.toHaveBeenCalled();
expect(logError).toHaveBeenCalledOnce();
// Availability remains reachable after a browser reload, so users can retry.
await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
await request(app).post('/api/openchamber/update-install').expect(200);
await new Promise(resolve => setImmediate(resolve));
const response = await request(app).get('/api/openchamber/update-check?appType=web&reportUsage=false').expect(200);
expect(response.body.currentVersion).toBe('1.17.0');
expect(response.body.updateOwner).toBe('electron-updater');
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
});
it('rejects native checks without a bridge and preserves explicit non-web checks', async () => {
const { app } = createApp({ environment: { OPENCHAMBER_RUNTIME: 'desktop' } });
await request(app).get('/api/openchamber/update-check?appType=web').expect(503, {
available: false, code: 'DESKTOP_UPDATER_UNAVAILABLE', error: 'The desktop updater is not available.',
});
expect(packageManager.checkForUpdates).not.toHaveBeenCalled();
await request(app).get('/api/openchamber/update-check?appType=desktop-electron').expect(200);
expect(packageManager.checkForUpdates).toHaveBeenCalledOnce();
});
it('uses electron-updater to check for Web client updates', async () => {
const desktopUpdater = {
check: vi.fn(async () => ({