fix(updater): tell the renderer when a stale verdict abandons the install

The cycle guard that stops a stale digest verdict from clobbering a newer
card also withheld the only signal the renderer has that the restart was
called off. The preload abort relay keys on an 'error' status, so with the
status suppressed the window stays restart-prepared for the rest of the
session: Terminal/Settings skip their unsaved-work prompts and the shutdown
checkpoint stays deduped, so a later real quit stages no fresh snapshot.

Push the abandon from performQuitAndInstall's single return-false site, so
it cannot depend on what the reporter decides about the status text, and
relay it to the existing relay.abort() (a no-op unless the renderer armed
a restart). The status stays cycle-guarded exactly as before.

Tests: the stale-verdict and swapped-primary-install cases now assert the
push, a committed install asserts its absence, and the preload relay test
covers the new channel. Each fails with only its source change reverted.
This commit is contained in:
OrcaWin
2026-08-04 01:12:53 -04:00
parent f657a51466
commit ef447a5835
4 changed files with 24 additions and 5 deletions
+8 -1
View File
@@ -4234,6 +4234,7 @@ describe('updater', () => {
message:
'The downloaded package no longer matches the verified release, so Orca will not hand it to a package manager. Download the update again, or get it from the official release page.'
})
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ action: 'restart-to-install', reason: 'hash-mismatch' }),
@@ -4242,7 +4243,7 @@ describe('updater', () => {
})
it('installs normally when the retained package still matches its digest', async () => {
const { updater } = await startUpdater('deb')
const { send, updater } = await startUpdater('deb')
await reachDownloaded(updater, downloadedEvent())
updater.quitAndInstall()
@@ -4250,6 +4251,9 @@ describe('updater', () => {
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
expect(killAllPtyMock).toHaveBeenCalledTimes(1)
// Why: an abort push here would clear the restart flag mid-quit and re-arm the dirty-buffer
// prompt against the install that is already committed.
expect(send).not.toHaveBeenCalledWith('updater:quitAndInstallAborted')
expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.anything(),
@@ -4277,6 +4281,9 @@ describe('updater', () => {
// The install is still abandoned — only the stale status is withheld.
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
expect(lastStatus(send)).toMatchObject({ state: 'available', version: '1.0.61' })
// Withholding the status must not also withhold the abort: the renderer armed its restart and
// would otherwise skip its unsaved-work prompt for the rest of the session.
expect(send).toHaveBeenCalledWith('updater:quitAndInstallAborted')
expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith(
'linux_package_revalidation_failed',
expect.objectContaining({ reason: 'hash-mismatch' }),
+4
View File
@@ -717,6 +717,10 @@ async function performQuitAndInstall(): Promise<void> {
// swapped or vanished package aborts instead of being installed as root. The synchronous guard
// keeps every non-Linux install on its existing timing.
if (getTrackedLinuxPackageArtifact() && !(await proveRetainedLinuxPackage(pendingVersion))) {
// Why: the renderer armed its restart before invoking, and it infers the abort from the error
// status — which a stale-cycle verdict deliberately withholds. Signal the abandon here, where
// it cannot depend on that decision, or the window keeps skipping its unsaved-work prompt.
mainWindowRef?.webContents.send('updater:quitAndInstallAborted')
return
}
quitAndInstallInProgress = true
+7 -3
View File
@@ -7,10 +7,11 @@ import {
} from './renderer-restart-wiring'
describe('renderer restart wiring', () => {
it('relays updater status and prevented unload events', () => {
it('relays updater status, aborted installs, and prevented unload events', () => {
const eventTarget = new EventTarget()
const unloadPrevented = vi.fn()
const handleStatus = vi.fn()
const abort = vi.fn()
const listeners = new Map<string, (...args: unknown[]) => void>()
const ipcRenderer = {
on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => {
@@ -20,12 +21,15 @@ describe('renderer restart wiring', () => {
} as unknown as Parameters<typeof registerRendererRestartIpcRelays>[0]
eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, unloadPrevented)
registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus })
registerRendererRestartIpcRelays(ipcRenderer, eventTarget, { handleStatus, abort })
listeners.get('updater:status')?.({}, { state: 'error', message: 'install failed' })
// Why: main abandons an install without any status when its verdict outlived the cycle.
listeners.get('updater:quitAndInstallAborted')?.({})
listeners.get('window:unload-prevented')?.({})
expect(ipcRenderer.on).toHaveBeenCalledTimes(2)
expect(ipcRenderer.on).toHaveBeenCalledTimes(3)
expect(handleStatus).toHaveBeenCalledWith({ state: 'error', message: 'install failed' })
expect(abort).toHaveBeenCalledTimes(1)
expect(unloadPrevented).toHaveBeenCalledTimes(1)
})
+5 -1
View File
@@ -13,11 +13,15 @@ import {
export function registerRendererRestartIpcRelays(
ipcRenderer: Pick<IpcRenderer, 'on'>,
eventTarget: EventTarget,
relay: Pick<UpdaterQuitAbortRelay, 'handleStatus'>
relay: Pick<UpdaterQuitAbortRelay, 'handleStatus' | 'abort'>
): void {
ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => {
relay.handleStatus(status)
})
// Why: main abandons some installs without an error status, and only this tells the renderer.
ipcRenderer.on('updater:quitAndInstallAborted', () => {
relay.abort()
})
ipcRenderer.on('window:unload-prevented', () => {
eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
})