From 54f738e7feddbb362b883c9f77193f67a3bdec43 Mon Sep 17 00:00:00 2001 From: trangiasang77 Date: Sun, 31 May 2026 13:06:09 +0700 Subject: [PATCH 1/5] 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/5] 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(); From 8cb8b3e0352e80302baa6f6093bdd80dd00f351e Mon Sep 17 00:00:00 2001 From: ThunderTr77 Date: Thu, 11 Jun 2026 20:14:43 +0700 Subject: [PATCH 3/5] fix(updater): keep checks current until channel switch commits --- .../src/services/AppUpdateService/index.ts | 2 +- .../services/AppUpdateService/service.test.ts | 49 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index 62206d7a..522b4c16 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -113,8 +113,8 @@ export class AppUpdateController { async setChannel(channel: AppUpdateChannel): Promise { await this.initialize(); await this.settings.updateAppUpdateChannel(channel); - this.checkRequestVersion += 1; await this.settings.updateAppUpdateLastCheckedAt(null); + this.checkRequestVersion += 1; 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 97f6e514..feca2eb4 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -49,6 +49,7 @@ function createController( checkResult?: AppUpdateCheckResult; checkError?: Error; updateChannelError?: Error; + updateLastCheckedAtError?: Error; } = {} ) { const checkForUpdates = options.checkError @@ -71,7 +72,15 @@ function createController( ? vi.fn().mockRejectedValue(options.updateChannelError) : vi.fn().mockResolvedValue(undefined); const updateAppUpdateAutoCheck = vi.fn().mockResolvedValue(undefined); - const updateAppUpdateLastCheckedAt = vi.fn().mockResolvedValue(undefined); + const updateAppUpdateLastCheckedAt = options.updateLastCheckedAtError + ? vi + .fn() + .mockImplementation((checkedAt: string | null) => + checkedAt === null + ? Promise.reject(options.updateLastCheckedAtError) + : Promise.resolve(undefined) + ) + : vi.fn().mockResolvedValue(undefined); const controller = new AppUpdateController({ native: { @@ -340,6 +349,44 @@ describe('AppUpdateController', () => { expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith('2026-05-22T10:00:00.000Z'); }); + it('keeps in-flight checks current when clearing the channel timestamp fails', async () => { + const deferredCheck = createDeferred(); + const { + controller, + checkForUpdates, + updateAppUpdateChannel, + updateAppUpdateLastCheckedAt, + } = createController({ + updateLastCheckedAtError: new Error('timestamp unavailable'), + }); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + await expect(controller.setChannel('nightly')).rejects.toThrow('timestamp 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(updateAppUpdateChannel).toHaveBeenCalledWith('nightly'); + expect(updateAppUpdateLastCheckedAt).toHaveBeenNthCalledWith(1, null); + expect(updateAppUpdateLastCheckedAt).toHaveBeenNthCalledWith(2, '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(); From 2a2ee367423f33b71ac586233bb2fd125e5c79e0 Mon Sep 17 00:00:00 2001 From: ThunderTr77 Date: Tue, 28 Jul 2026 14:46:53 +0700 Subject: [PATCH 4/5] fix(updater): invalidate checks before channel persistence --- .../src/services/AppUpdateService/index.ts | 21 ++++++- .../services/AppUpdateService/service.test.ts | 63 ++++++++++++------- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index 522b4c16..90195439 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -112,10 +112,25 @@ export class AppUpdateController { async setChannel(channel: AppUpdateChannel): Promise { await this.initialize(); - await this.settings.updateAppUpdateChannel(channel); - await this.settings.updateAppUpdateLastCheckedAt(null); + const previousChannel = this.state.channel; + const restoreState: AppUpdateState = + this.state.status === 'checking' ? { ...this.state, status: 'idle' } : this.state; this.checkRequestVersion += 1; - this.commit({ type: 'channel-updated', channel }); + this.replaceState(restoreState); + + let channelPersisted = false; + try { + await this.settings.updateAppUpdateChannel(channel); + channelPersisted = true; + await this.settings.updateAppUpdateLastCheckedAt(null); + this.commit({ type: 'channel-updated', channel }); + } catch (error) { + if (channelPersisted) { + await this.settings.updateAppUpdateChannel(previousChannel).catch(() => undefined); + } + this.replaceState(restoreState); + throw error; + } } async checkNow(source: AppUpdateCheckSource = 'manual'): Promise { diff --git a/apps/desktop/tests/services/AppUpdateService/service.test.ts b/apps/desktop/tests/services/AppUpdateService/service.test.ts index feca2eb4..16381d68 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -317,7 +317,36 @@ describe('AppUpdateController', () => { expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); }); - it('keeps in-flight checks current when channel persistence fails', async () => { + it('invalidates in-flight checks before channel persistence completes', async () => { + const deferredCheck = createDeferred(); + const deferredChannelUpdate = createDeferred(); + const { controller, checkForUpdates, updateAppUpdateChannel } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + updateAppUpdateChannel.mockReturnValueOnce(deferredChannelUpdate.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + const setChannelPromise = controller.setChannel('nightly'); + await Promise.resolve(); + + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + const checked = await checkPromise; + + deferredChannelUpdate.resolve(); + await setChannelPromise; + + expect(checked).toBe(false); + }); + + it('restores the previous state when channel persistence fails', async () => { const deferredCheck = createDeferred(); const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController({ updateChannelError: new Error('database unavailable'), @@ -338,18 +367,13 @@ describe('AppUpdateController', () => { 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'); + expect(controller.getState().status).not.toBe('checking'); + expect(controller.getState().channel).toBe('stable'); + await expect(checkPromise).resolves.toBe(false); + expect(updateAppUpdateLastCheckedAt).not.toHaveBeenCalled(); }); - it('keeps in-flight checks current when clearing the channel timestamp fails', async () => { + it('rolls back the persisted channel when clearing the channel timestamp fails', async () => { const deferredCheck = createDeferred(); const { controller, @@ -375,16 +399,13 @@ describe('AppUpdateController', () => { 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(updateAppUpdateChannel).toHaveBeenCalledWith('nightly'); - expect(updateAppUpdateLastCheckedAt).toHaveBeenNthCalledWith(1, null); - expect(updateAppUpdateLastCheckedAt).toHaveBeenNthCalledWith(2, '2026-05-22T10:00:00.000Z'); + expect(controller.getState().status).not.toBe('checking'); + expect(controller.getState().channel).toBe('stable'); + await expect(checkPromise).resolves.toBe(false); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(1, 'nightly'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(2, 'stable'); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledOnce(); + expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); }); it('ignores an older check result when a newer same-channel check finishes first', async () => { From d414e13f92f851c59ecf1bf59d35244fa74cb872 Mon Sep 17 00:00:00 2001 From: ThunderTr77 Date: Tue, 28 Jul 2026 14:58:25 +0700 Subject: [PATCH 5/5] fix(updater): harden channel transitions --- .../src/services/AppUpdateService/index.ts | 52 ++++++-- .../services/AppUpdateService/service.test.ts | 125 ++++++++++++++++++ 2 files changed, 164 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/services/AppUpdateService/index.ts b/apps/desktop/src/services/AppUpdateService/index.ts index 90195439..9d2248df 100644 --- a/apps/desktop/src/services/AppUpdateService/index.ts +++ b/apps/desktop/src/services/AppUpdateService/index.ts @@ -57,6 +57,7 @@ export class AppUpdateController { private initialized = false; private unlistenProgress: UnlistenFn | null = null; private checkRequestVersion = 0; + private channelTransitionQueue: Promise = Promise.resolve(); constructor(deps: AppUpdateControllerDeps) { this.native = deps.native; @@ -110,29 +111,54 @@ export class AppUpdateController { } } - async setChannel(channel: AppUpdateChannel): Promise { - await this.initialize(); - const previousChannel = this.state.channel; - const restoreState: AppUpdateState = - this.state.status === 'checking' ? { ...this.state, status: 'idle' } : this.state; - this.checkRequestVersion += 1; - this.replaceState(restoreState); + setChannel(channel: AppUpdateChannel): Promise { + if (this.initialized) { + return this.queueChannelTransition(channel); + } + + return this.initialize().then(() => this.queueChannelTransition(channel)); + } + + private queueChannelTransition(channel: AppUpdateChannel): Promise { + this.invalidateChecksForChannelTransition(); + const transition = this.channelTransitionQueue.then(() => + this.runChannelTransition(channel) + ); + this.channelTransitionQueue = transition.catch(() => undefined); + return transition; + } + + private async runChannelTransition(channel: AppUpdateChannel): Promise { + const restoreState = this.invalidateChecksForChannelTransition(); + const previousChannel = restoreState.channel; - let channelPersisted = false; try { await this.settings.updateAppUpdateChannel(channel); - channelPersisted = true; await this.settings.updateAppUpdateLastCheckedAt(null); this.commit({ type: 'channel-updated', channel }); } catch (error) { - if (channelPersisted) { - await this.settings.updateAppUpdateChannel(previousChannel).catch(() => undefined); - } - this.replaceState(restoreState); + await this.settings.updateAppUpdateChannel(previousChannel).catch(() => undefined); + const persistedChannel = this.settings.getChannel(); + this.replaceState( + persistedChannel === previousChannel + ? restoreState + : reduceAppUpdateState(restoreState, { + type: 'channel-updated', + channel: persistedChannel, + }) + ); throw error; } } + private invalidateChecksForChannelTransition(): AppUpdateState { + const restoreState: AppUpdateState = + this.state.status === 'checking' ? { ...this.state, status: 'idle' } : this.state; + this.checkRequestVersion += 1; + this.replaceState(restoreState); + return restoreState; + } + async checkNow(source: AppUpdateCheckSource = 'manual'): Promise { await this.initialize(); diff --git a/apps/desktop/tests/services/AppUpdateService/service.test.ts b/apps/desktop/tests/services/AppUpdateService/service.test.ts index 16381d68..e0a7aee3 100644 --- a/apps/desktop/tests/services/AppUpdateService/service.test.ts +++ b/apps/desktop/tests/services/AppUpdateService/service.test.ts @@ -346,6 +346,29 @@ describe('AppUpdateController', () => { expect(checked).toBe(false); }); + it('invalidates a resolved check synchronously when changing channels', async () => { + const deferredCheck = createDeferred(); + const { controller, checkForUpdates } = createController(); + checkForUpdates.mockReturnValueOnce(deferredCheck.promise); + + await controller.initialize(); + const checkPromise = controller.checkNow('manual'); + await Promise.resolve(); + + deferredCheck.resolve({ + status: 'available', + channel: 'stable', + currentVersion: '0.1.0', + latest: latestUpdate, + update: availableUpdate, + requirement: neutralRequirement, + }); + const setChannelPromise = controller.setChannel('nightly'); + + await expect(checkPromise).resolves.toBe(false); + await setChannelPromise; + }); + it('restores the previous state when channel persistence fails', async () => { const deferredCheck = createDeferred(); const { controller, checkForUpdates, updateAppUpdateLastCheckedAt } = createController({ @@ -373,6 +396,21 @@ describe('AppUpdateController', () => { expect(updateAppUpdateLastCheckedAt).not.toHaveBeenCalled(); }); + it('initializes before snapshotting a channel transition', async () => { + const operationError = new Error('database unavailable'); + const { controller } = createController({ + channel: 'beta', + updateChannelError: operationError, + }); + + await expect(controller.setChannel('nightly')).rejects.toBe(operationError); + + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: 'beta', + }); + }); + it('rolls back the persisted channel when clearing the channel timestamp fails', async () => { const deferredCheck = createDeferred(); const { @@ -408,6 +446,93 @@ describe('AppUpdateController', () => { expect(updateAppUpdateLastCheckedAt).toHaveBeenCalledWith(null); }); + it('reconciles state when a channel write rejects after mutation and compensation fails', async () => { + let persistedChannel: AppUpdateChannel = 'stable'; + const operationError = new Error('settings broadcast failed'); + const compensationError = new Error('compensation failed'); + const updateAppUpdateChannel = vi + .fn<(channel: AppUpdateChannel) => Promise>() + .mockImplementation(async (channel) => { + if (channel === 'nightly') { + persistedChannel = channel; + throw operationError; + } + throw compensationError; + }); + const controller = new AppUpdateController({ + native: { + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + installUpdate: vi.fn(), + }, + settings: { + initialize: vi.fn().mockResolvedValue(undefined), + getChannel: () => persistedChannel, + getAutoCheckEnabled: () => true, + getLastCheckedAt: () => null, + updateAppUpdateChannel, + updateAppUpdateAutoCheck: vi.fn().mockResolvedValue(undefined), + updateAppUpdateLastCheckedAt: vi.fn().mockResolvedValue(undefined), + }, + }); + + await controller.initialize(); + await expect(controller.setChannel('nightly')).rejects.toBe(operationError); + + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(1, 'nightly'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(2, 'stable'); + expect(persistedChannel).toBe('nightly'); + expect(controller.getState()).toMatchObject({ + status: 'idle', + channel: persistedChannel, + }); + }); + + it('does not let an older failed channel transition undo a newer selection', async () => { + let persistedChannel: AppUpdateChannel = 'stable'; + const olderUpdate = createDeferred(); + const olderError = new Error('older transition failed'); + const updateAppUpdateChannel = vi + .fn<(channel: AppUpdateChannel) => Promise>() + .mockImplementation(async (channel) => { + if (channel === 'beta') { + await olderUpdate.promise; + } + persistedChannel = channel; + }); + const controller = new AppUpdateController({ + native: { + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + installUpdate: vi.fn(), + }, + settings: { + initialize: vi.fn().mockResolvedValue(undefined), + getChannel: () => persistedChannel, + getAutoCheckEnabled: () => true, + getLastCheckedAt: () => null, + updateAppUpdateChannel, + updateAppUpdateAutoCheck: vi.fn().mockResolvedValue(undefined), + updateAppUpdateLastCheckedAt: vi.fn().mockResolvedValue(undefined), + }, + }); + + await controller.initialize(); + const olderTransition = controller.setChannel('beta'); + await Promise.resolve(); + const newerTransition = controller.setChannel('nightly'); + + olderUpdate.reject(olderError); + + await expect(olderTransition).rejects.toBe(olderError); + await expect(newerTransition).resolves.toBeUndefined(); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(1, 'beta'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(2, 'stable'); + expect(updateAppUpdateChannel).toHaveBeenNthCalledWith(3, 'nightly'); + expect(persistedChannel).toBe('nightly'); + expect(controller.getState().channel).toBe('nightly'); + }); + it('ignores an older check result when a newer same-channel check finishes first', async () => { const firstCheck = createDeferred(); const secondCheck = createDeferred();