fix(desktop): recover the backend update overlay after the remote restarts
The backend Install path set stage:'restart' and stopped — in remote mode no boot-progress events arrive to carry the overlay to done, so it sat on the restarting spinner until a manual reload while the backend had already come back. Poll the backend until it answers again, then clear the overlay and refresh the backend status. Target-aware applying copy explains the remote restart + auto-reconnect instead of the local-updater-window wording. Also switch the apply poll sleeps from window.setTimeout to globalThis.setTimeout so the flow is exercisable off the renderer.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock('@/hermes', () => ({
|
||||
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus } = await import('./updates')
|
||||
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
|
||||
const { setConnection } = await import('./session')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
@@ -155,3 +155,32 @@ describe('checkBackendUpdates', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBackendUpdate recovery', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
checkHermesUpdateSpy.mockReset()
|
||||
updateHermesSpy.mockReset()
|
||||
getActionStatusSpy.mockReset()
|
||||
$backendUpdateApply.set({ applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] })
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => {
|
||||
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
|
||||
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', current_version: '0.16.0', behind: 0, update_available: false, can_apply: true, update_command: 'hermes update', message: null })
|
||||
|
||||
const promise = applyBackendUpdate()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
const result = await promise
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect($backendUpdateApply.get().stage).toBe('idle')
|
||||
expect($backendUpdateApply.get().applying).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -304,6 +304,22 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const BACKEND_RETURN_POLL_MS = 1500
|
||||
const BACKEND_RETURN_MAX_ATTEMPTS = 40
|
||||
|
||||
async function waitForBackendReturn(): Promise<void> {
|
||||
for (let attempt = 0; attempt < BACKEND_RETURN_MAX_ATTEMPTS; attempt += 1) {
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, BACKEND_RETURN_POLL_MS))
|
||||
try {
|
||||
await checkHermesUpdate()
|
||||
|
||||
return
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
dismissNotification(UPDATE_TOAST_ID)
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: 'Updating backend…' })
|
||||
@@ -323,19 +339,22 @@ export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
|
||||
let last: Awaited<ReturnType<typeof getActionStatus>> | null = null
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500))
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, 1500))
|
||||
try {
|
||||
last = await getActionStatus(started.name, 200)
|
||||
} catch {
|
||||
// The dashboard restarts mid-update, dropping this connection — expected, not a failure.
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
applying: true,
|
||||
stage: 'restart',
|
||||
message: 'Backend restarting to load the update…'
|
||||
})
|
||||
await waitForBackendReturn()
|
||||
$backendUpdateApply.set(IDLE)
|
||||
void checkBackendUpdates()
|
||||
|
||||
return { ok: true, message: 'Backend update applied; backend is restarting.' }
|
||||
return { ok: true, message: 'Backend update applied; backend is back online.' }
|
||||
}
|
||||
|
||||
if (last && !last.running) {
|
||||
@@ -344,17 +363,24 @@ export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
}
|
||||
|
||||
const ok = !!last && (last.exit_code ?? 1) === 0
|
||||
if (ok) {
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: 'Backend restarting to load the update…' })
|
||||
await waitForBackendReturn()
|
||||
$backendUpdateApply.set(IDLE)
|
||||
void checkBackendUpdates()
|
||||
|
||||
return { ok: true, message: 'Backend update applied.' }
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: ok ? 'restart' : 'error',
|
||||
error: ok ? null : 'apply-failed',
|
||||
message: ok ? 'Backend updated. Restart it to load the new code.' : 'Backend update failed.'
|
||||
stage: 'error',
|
||||
error: 'apply-failed',
|
||||
message: 'Backend update failed.'
|
||||
})
|
||||
|
||||
return ok
|
||||
? { ok: true, message: 'Backend update applied.' }
|
||||
: { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
|
||||
return { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
|
||||
|
||||
Reference in New Issue
Block a user