From 54f738e7feddbb362b883c9f77193f67a3bdec43 Mon Sep 17 00:00:00 2001 From: trangiasang77 Date: Sun, 31 May 2026 13:06:09 +0700 Subject: [PATCH 1/2] fix(updater): ignore stale check results --- .../src/services/AppUpdateService/index.ts | 15 +++ .../services/AppUpdateService/service.test.ts | 100 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index 9a583aad..c4b95c90 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -56,6 +56,7 @@ export class AppUpdateController { private readonly now: () => string; private initialized = false; private unlistenProgress: UnlistenFn | null = null; + private checkRequestVersion = 0; constructor(deps: AppUpdateControllerDeps) { this.native = deps.native; @@ -111,6 +112,7 @@ export class AppUpdateController { async setChannel(channel: AppUpdateChannel): Promise { await this.initialize(); + this.checkRequestVersion += 1; await this.settings.updateAppUpdateChannel(channel); await this.settings.updateAppUpdateLastCheckedAt(null); this.commit({ type: 'channel-updated', channel }); @@ -125,15 +127,24 @@ export class AppUpdateController { const previousState = this.state; const channel = this.state.channel; + const requestVersion = ++this.checkRequestVersion; this.commit({ type: 'check-started', channel }); try { const result = await this.native.checkForUpdates(channel); const checkedAt = this.now(); + if (!this.isCurrentCheckRequest(requestVersion, channel)) { + return false; + } + this.commit({ type: 'check-completed', channel, result, checkedAt }); await this.settings.updateAppUpdateLastCheckedAt(checkedAt); return true; } catch (error) { + if (!this.isCurrentCheckRequest(requestVersion, channel)) { + return false; + } + if (source === 'automatic') { this.replaceState(previousState); return false; @@ -171,6 +182,10 @@ export class AppUpdateController { } } + private isCurrentCheckRequest(requestVersion: number, channel: AppUpdateChannel): boolean { + return requestVersion === this.checkRequestVersion && this.state.channel === channel; + } + private shouldRunAutomaticCheck(): boolean { if (!this.state.autoCheckEnabled) { return false; diff --git a/apps/desktop/tests/services/AppUpdateService/service.test.ts b/apps/desktop/tests/services/AppUpdateService/service.test.ts index bee64628..2f5bedb6 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -170,6 +170,33 @@ describe('AppUpdateController', () => { }); }); + it('does not restore an old channel after a stale automatic check fails', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('automatic'); + await Promise.resolve(); + + expect(controller.getState()).toMatchObject({ + status: 'checking', + channel: 'stable', + }); + + await controller.setChannel('nightly'); + deferredCheck.reject(new Error('network unavailable')); + + await expect(checkPromise).resolves.toBe(false); + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: 'nightly', + error: null, + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); + }); + it('surfaces manual check failures', async () => { const { controller } = createController({ checkError: new Error('network unavailable'), @@ -248,6 +275,79 @@ describe('AppUpdateController', () => { }); }); + it('does not persist stale check timestamps after the user switches channels', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + await controller.setChannel('nightly'); + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + + await expect(checkPromise).resolves.toBe(false); + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: 'nightly', + availableUpdate: null, + lastCheckedAt: null, + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); + }); + + it('ignores an older check result when a newer same-channel check finishes first', async () => { + const firstCheck = createDeferred(); + const secondCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController(); + checkForUpdates + .mockReturnValueOnce(firstCheck.promise) + .mockReturnValueOnce(secondCheck.promise); + + await controller.initialize(); + const firstPromise = controller.checkNow('manual'); + await Promise.resolve(); + const secondPromise = controller.checkNow('manual'); + await Promise.resolve(); + + secondCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + await expect(secondPromise).resolves.toBe(true); + + firstCheck.resolve({ + status: 'not_available', + channel: 'stable', + currentVersion: '0.2.0', + latest: latestUpdate, + requirement: neutralRequirement, + }); + await expect(firstPromise).resolves.toBe(false); + + expect(controller.getState()).toMatchObject({ + status: 'available', + channel: 'stable', + availableUpdate, + currentVersion: '0.1.0', + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith('2026-05-22T10:00:00.000Z'); + }); + it('persists auto-check changes', async () => { const { controller, updateAppUpdateAutoCheck } = createController(); From 9ba57af6a9bfcb8a8f021658603be748d3386340 Mon Sep 17 00:00:00 2001 From: trangiasang77 Date: Sun, 7 Jun 2026 09:30:29 +0700 Subject: [PATCH 2/2] fix(updater): preserve checks when channel write fails --- .../src/services/AppUpdateService/index.ts | 2 +- .../services/AppUpdateService/service.test.ts | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index c4b95c90..62206d7a 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -112,8 +112,8 @@ export class AppUpdateController { async setChannel(channel: AppUpdateChannel): Promise { await this.initialize(); - this.checkRequestVersion += 1; await this.settings.updateAppUpdateChannel(channel); + this.checkRequestVersion += 1; await this.settings.updateAppUpdateLastCheckedAt(null); this.commit({ type: 'channel-updated', channel }); } diff --git a/apps/desktop/tests/services/AppUpdateService/service.test.ts b/apps/desktop/tests/services/AppUpdateService/service.test.ts index 2f5bedb6..97f6e514 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -48,6 +48,7 @@ function createController( lastCheckedAt?: string | null; checkResult?: AppUpdateCheckResult; checkError?: Error; + updateChannelError?: Error; } = {} ) { const checkForUpdates = options.checkError @@ -66,7 +67,9 @@ function createController( ); const downloadUpdate = vi.fn().mockResolvedValue(availableUpdate); const installUpdate = vi.fn().mockResolvedValue(true); - const updateAppUpdateChannel = vi.fn().mockResolvedValue(undefined); + const updateAppUpdateChannel = options.updateChannelError + ? vi.fn().mockRejectedValue(options.updateChannelError) + : vi.fn().mockResolvedValue(undefined); const updateAppUpdateAutoCheck = vi.fn().mockResolvedValue(undefined); const updateAppUpdateLastCheckedAt = vi.fn().mockResolvedValue(undefined); @@ -305,6 +308,38 @@ describe('AppUpdateController', () => { expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); }); + it('keeps in-flight checks current when channel persistence fails', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController({ + updateChannelError: new Error('database unavailable'), + }); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + await expect(controller.setChannel('nightly')).rejects.toThrow('database unavailable'); + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + + await expect(checkPromise).resolves.toBe(true); + expect(controller.getState()).toMatchObject({ + status: 'available', + channel: 'stable', + availableUpdate, + lastCheckedAt: '2026-05-22T10:00:00.000Z', + }); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledTimes(1); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith('2026-05-22T10:00:00.000Z'); + }); + it('ignores an older check result when a newer same-channel check finishes first', async () => { const firstCheck = createDeferred(); const secondCheck = createDeferred();