fix(updater): avoid duplicate markers and stale quit relaunches

This commit is contained in:
Merge Sim
2026-09-03 12:52:34 -07:00
parent e7b18d291a
commit f1eb844055
4 changed files with 67 additions and 5 deletions
+12 -2
View File
@@ -1,6 +1,9 @@
import { app, autoUpdater as nativeUpdater } from 'electron'
import type { UpdateStatus } from '../shared/update-status-types'
import { markMacUpdateInstallInFlight } from './mac-update-install-marker'
import {
isMacUpdateInstallInFlight,
markMacUpdateInstallInFlight
} from './mac-update-install-marker'
import { recordUpdaterLifecycle } from './updater-lifecycle-diagnostics'
const MAC_INSTALL_READY_TIMEOUT_MS = 15000
@@ -42,7 +45,14 @@ export function registerMacUpdaterEvents({
// Why will-quit rather than before-quit: vetoes have already been resolved here, so the app is
// genuinely on its way out and the marker cannot strand a launch for a quit that got abandoned.
app.on('will-quit', () => {
if (process.platform !== 'darwin' || !isMacInstallerReady()) {
if (
process.platform !== 'darwin' ||
!isMacInstallerReady() ||
// Explicit quitAndInstall already wrote the marker immediately before invoking native
// handoff. Writing a second attempt here leaves an orphan marker that reports the same
// install again on a later startup and can keep the launch gate active after settlement.
isMacUpdateInstallInFlight()
) {
return
}
const pendingVersion = getPendingInstallVersion()
+50 -2
View File
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { loadUpdaterModule, warmUpdaterModule } from './updater-test-module-loader'
import type * as MacUpdateMarkerModule from './mac-update-install-marker'
const {
appMock,
@@ -8,7 +9,9 @@ const {
autoUpdaterMock,
shellMock,
isMock,
killAllPtyMock
killAllPtyMock,
isMacUpdateInstallInFlightMock,
markMacUpdateInstallInFlightMock
} = vi.hoisted(() => {
const appEventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
const eventHandlers = new Map<string, ((...args: unknown[]) => void)[]>()
@@ -80,7 +83,9 @@ const {
openExternal: vi.fn()
},
isMock: { dev: false },
killAllPtyMock: vi.fn()
killAllPtyMock: vi.fn(),
isMacUpdateInstallInFlightMock: vi.fn(),
markMacUpdateInstallInFlightMock: vi.fn()
}
})
@@ -109,6 +114,12 @@ vi.mock('./ipc/pty', () => ({
killAllPty: killAllPtyMock
}))
vi.mock('./mac-update-install-marker', async (importOriginal) => ({
...(await importOriginal<typeof MacUpdateMarkerModule>()),
isMacUpdateInstallInFlight: isMacUpdateInstallInFlightMock,
markMacUpdateInstallInFlight: markMacUpdateInstallInFlightMock
}))
vi.mock('./updater-changelog', () => ({
fetchChangelog: vi.fn().mockResolvedValue(null)
}))
@@ -134,11 +145,48 @@ describe('updater mac install handoff', () => {
appMock.isPackaged = true
isMock.dev = false
killAllPtyMock.mockReset()
isMacUpdateInstallInFlightMock.mockReset()
isMacUpdateInstallInFlightMock.mockReturnValue(false)
markMacUpdateInstallInFlightMock.mockReset()
autoUpdaterMock.downloadUpdate.mockResolvedValue([])
vi.unstubAllGlobals()
vi.useRealTimers()
})
it.runIf(process.platform === 'darwin')(
'does not write a duplicate marker during explicit quitAndInstall',
async () => {
const mainWindow = { webContents: { send: vi.fn() } }
autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined)
const { setupAutoUpdater, downloadUpdate, quitAndInstall } = await loadUpdaterModule()
setupAutoUpdater(mainWindow as never)
await vi.waitFor(() => {
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1)
})
autoUpdaterMock.emit('checking-for-update')
autoUpdaterMock.emit('update-available', { version: '1.0.61' })
await new Promise((r) => setTimeout(r, 0))
downloadUpdate()
autoUpdaterMock.emit('update-downloaded', { version: '1.0.61' })
const nativeDownloadedHandler = nativeUpdaterMock.on.mock.calls.find(
([eventName]) => eventName === 'update-downloaded'
)?.[1] as (() => void) | undefined
nativeDownloadedHandler?.()
quitAndInstall()
await vi.waitFor(() => {
expect(markMacUpdateInstallInFlightMock).toHaveBeenCalledTimes(1)
})
// The native install path writes the marker before quit; its will-quit hook must not write
// another attempt for the same handoff.
isMacUpdateInstallInFlightMock.mockReturnValue(true)
appMock.emit('will-quit')
expect(markMacUpdateInstallInFlightMock).toHaveBeenCalledTimes(1)
}
)
it.runIf(process.platform === 'darwin')(
'waits for Squirrel.Mac before honoring a manual quit that should install the update',
async () => {
@@ -98,9 +98,10 @@ describe('window-close-request-coordinator', () => {
{ once: true }
)
await dispatchWindowCloseRequest({ isQuitting: true })
await dispatchWindowCloseRequest({ isQuitting: true, requestId: 42 })
expect(confirmWindowClose).not.toHaveBeenCalled()
expect(cancelWindowClose).toHaveBeenCalledWith(42)
expect(toast.error).toHaveBeenCalledWith(
'Quit canceled: the session snapshot could not be saved (sendSync payload rejected).'
)
@@ -101,5 +101,8 @@ export async function dispatchWindowCloseRequest(data: {
window.api.ui.confirmWindowClose()
return
}
// A beforeunload veto is another abandoned close path. Without this signal, main keeps the
// request outstanding and any relaunch deferred to `quit` can fire on a later unrelated quit.
window.api.ui.cancelWindowClose(data.requestId)
showShutdownCheckpointFailureToast()
}