Skip to content

Commit 4f89858

Browse files
committed
fix(desktop): distinguish refresh failures from native update errors
1 parent 3a2ba81 commit 4f89858

3 files changed

Lines changed: 55 additions & 32 deletions

File tree

‎apps/desktop/e2e/updater.spec.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ test('the real MacUpdater waits for native staging, replaces old builds, and ret
116116

117117
await check('replacement cannot restart into stale native update', async () => {
118118
offeredVersion = '2.1.0'
119-
await expect.poll(async () => (await read()).nativeArchive, { timeout: 20_000 }).toBe('2.1.0')
119+
await shell.evaluate(() => globalThis.desktopUpdaterFixture.check())
120+
await expect.poll(async () => (await read()).nativeArchive).toBe('2.1.0')
120121
expect((await read()).state).toEqual({
121122
status: 'downloading',
122123
version: '2.1.0',

‎apps/desktop/src/main/updater.test.ts‎

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,11 @@ describe('initUpdater state machine', () => {
8888
}
8989

9090
/** Replays a native Squirrel.Mac event, e.g. `update-downloaded` once a bundle is staged. */
91-
function emitSquirrel(event: string) {
91+
function emitSquirrel(event: string, ...args: unknown[]) {
92+
if (event === 'error') emit(event, ...args)
9293
for (const [name, listener] of vi.mocked(squirrelUpdater.on).mock.calls) {
9394
if (name === event) {
94-
;(listener as () => void)()
95+
;(listener as (...values: unknown[]) => void)(...args)
9596
}
9697
}
9798
}
@@ -274,7 +275,7 @@ describe('initUpdater state machine', () => {
274275
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
275276
})
276277

277-
it('does not install when the updater fails during pre-install teardown', async () => {
278+
it('does not install when native staging fails during teardown with a background check pending', async () => {
278279
let finishTeardown: (() => void) | undefined
279280
const setRelaunchPending = vi.fn()
280281
const { handle } = await createUpdater({
@@ -290,14 +291,15 @@ describe('initUpdater state machine', () => {
290291
emit('update-available', { version: '2.0.0' })
291292
emit('update-downloaded', { version: '2.0.0' })
292293
emitSquirrel('update-downloaded')
294+
await vi.advanceTimersByTimeAsync(10_000)
293295
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
294296
response: 1,
295297
checkboxChecked: false,
296298
})
297299
handle.install()
298300
await vi.advanceTimersByTimeAsync(0)
299301

300-
emit('error', new Error('native installer failed'))
302+
emitSquirrel('error', new Error('native installer failed'))
301303
finishTeardown?.()
302304
await vi.advanceTimersByTimeAsync(0)
303305

@@ -306,6 +308,39 @@ describe('initUpdater state machine', () => {
306308
expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled()
307309
})
308310

311+
it('finishes a confirmed restart when an earlier background check fails during teardown', async () => {
312+
let finishTeardown: (() => void) | undefined
313+
let failRefresh: ((error: Error) => void) | undefined
314+
const { handle } = await createUpdater({
315+
beforeInstall: () =>
316+
new Promise<void>((resolve) => {
317+
finishTeardown = resolve
318+
}),
319+
})
320+
await stageUpdate(handle, '2.0.0')
321+
autoUpdaterMock.checkForUpdates.mockImplementationOnce(
322+
() =>
323+
new Promise((_, reject) => {
324+
failRefresh = (error) => {
325+
emit('error', error)
326+
reject(error)
327+
}
328+
})
329+
)
330+
await vi.advanceTimersByTimeAsync(10_000)
331+
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 1, checkboxChecked: false })
332+
handle.install()
333+
await vi.advanceTimersByTimeAsync(0)
334+
335+
failRefresh?.(new Error('Background feed unavailable'))
336+
await vi.advanceTimersByTimeAsync(0)
337+
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
338+
expect(autoUpdaterMock.autoInstallOnAppQuit).toBe(true)
339+
finishTeardown?.()
340+
await vi.advanceTimersByTimeAsync(0)
341+
expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1)
342+
})
343+
309344
it('bypasses renderer unload guards only after teardown succeeds', async () => {
310345
const setRelaunchPending = vi.fn()
311346
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({
@@ -382,16 +417,17 @@ describe('initUpdater state machine', () => {
382417
emit('update-available', { version: '2.0.0' })
383418
await vi.advanceTimersByTimeAsync(30 * 60 * 1000 - 10_000)
384419
emit('update-not-available')
420+
autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('net::ERR_NETWORK_CHANGED'))
385421
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
386-
emit('error', new Error('net::ERR_NETWORK_CHANGED'))
387422
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
423+
autoUpdaterMock.downloadUpdate.mockRejectedValueOnce(new Error('download interrupted'))
388424
emit('update-available', { version: '2.1.0' })
389-
emit('error', new Error('download interrupted'))
425+
await vi.advanceTimersByTimeAsync(0)
390426
expect(handle.getState()).toEqual({ status: 'ready', version: '2.0.0' })
391427
await vi.advanceTimersByTimeAsync(30 * 60 * 1000)
392428
emit('update-available', { version: '2.2.0' })
393429
emit('update-downloaded', { version: '2.2.0' })
394-
emit('error', new Error('Squirrel could not verify the replacement'))
430+
emitSquirrel('error', new Error('Squirrel could not verify the replacement'))
395431

396432
expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(6)
397433
expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(3)

‎apps/desktop/src/main/updater.ts‎

Lines changed: 10 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -683,29 +683,13 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
683683
setState({ status: 'ready', version })
684684
})
685685

686-
autoUpdater.on('error', (error) => {
687-
const checkId = activeUpdaterCheckId
688-
if (checkId !== null) {
689-
finishUpdaterCheck(checkId)
690-
if (updaterRequestId === checkId) updaterRequestId = null
691-
}
692-
const message = getErrorMessage(error, 'unknown')
693-
if (stagedVersion !== null && !installInFlight && !relaunchRequested) {
694-
acceptedUpdateVersion = null
695-
pendingStagingVersion = null
696-
logger.warn('Update refresh failed; keeping the staged update', { message })
697-
if (state.status !== 'ready') setState({ status: 'ready', version: stagedVersion })
698-
return
699-
}
700-
if (state.status === 'available') {
701-
logger.warn('Update re-check failed; keeping the offered update', { message })
702-
return
703-
}
686+
/** Network failures belong to their request promises; native errors invalidate staging. */
687+
squirrelUpdater.on('error', (error) => {
704688
if (
705-
checkId === null &&
706689
state.status !== 'downloading' &&
707690
state.status !== 'ready' &&
708-
!installInFlight
691+
!installInFlight &&
692+
!relaunchRequested
709693
) {
710694
return
711695
}
@@ -716,6 +700,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
716700
pendingStagingVersion = null
717701
deps.setRelaunchPending?.(false)
718702
autoUpdater.autoInstallOnAppQuit = false
703+
const message = getErrorMessage(error, 'unknown')
704+
logger.warn('Native update failed', { message })
719705
deps.events.record('update_error', { message })
720706
setState({ status: 'error', version: state.version })
721707
})
@@ -802,7 +788,9 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
802788
if (updaterRequestId === checkId) updaterRequestId = null
803789
if (activeUpdaterCheckId !== checkId) return
804790
finishUpdaterCheck(checkId)
805-
logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') })
791+
const message = getErrorMessage(error, 'unknown')
792+
logger.warn('Update check failed', { message })
793+
deps.events.record('update_error', { message })
806794
if (state.status === 'checking') setState({ status: 'error' })
807795
})
808796
}
@@ -817,10 +805,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle {
817805
return
818806
}
819807
if (isRefreshingOffer()) {
820-
if (interactive) return
821808
if (state.status === 'ready' && !canRefreshStagedUpdate()) return
822-
}
823-
if (interactive) {
809+
} else if (interactive) {
824810
setState({ status: 'checking' })
825811
}
826812
if (originFeedConfigured) {

0 commit comments

Comments
 (0)