From ef447a5835ea280d0118f2037e253348e98c5d07 Mon Sep 17 00:00:00 2001 From: OrcaWin <293788423+OrcaWin@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:12:53 -0400 Subject: [PATCH] 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. --- src/main/updater.test.ts | 9 ++++++++- src/main/updater.ts | 4 ++++ src/preload/renderer-restart-wiring.test.ts | 10 +++++++--- src/preload/renderer-restart-wiring.ts | 6 +++++- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a8d37301b9d..a0ab479343d 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -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' }), diff --git a/src/main/updater.ts b/src/main/updater.ts index a40c3a99b01..302591286f3 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -717,6 +717,10 @@ async function performQuitAndInstall(): Promise { // 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 diff --git a/src/preload/renderer-restart-wiring.test.ts b/src/preload/renderer-restart-wiring.test.ts index 0198c72a44f..6831b5f2181 100644 --- a/src/preload/renderer-restart-wiring.test.ts +++ b/src/preload/renderer-restart-wiring.test.ts @@ -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 void>() const ipcRenderer = { on: vi.fn((channel: string, listener: (...args: unknown[]) => void) => { @@ -20,12 +21,15 @@ describe('renderer restart wiring', () => { } as unknown as Parameters[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) }) diff --git a/src/preload/renderer-restart-wiring.ts b/src/preload/renderer-restart-wiring.ts index 254d386403a..8ee6d5f1486 100644 --- a/src/preload/renderer-restart-wiring.ts +++ b/src/preload/renderer-restart-wiring.ts @@ -13,11 +13,15 @@ import { export function registerRendererRestartIpcRelays( ipcRenderer: Pick, eventTarget: EventTarget, - relay: Pick + relay: Pick ): 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)) })