diff --git a/apps/desktop/src/store/updates.test.ts b/apps/desktop/src/store/updates.test.ts index df641bae3d..5d6bb02b83 100644 --- a/apps/desktop/src/store/updates.test.ts +++ b/apps/desktop/src/store/updates.test.ts @@ -1546,6 +1546,75 @@ describe('startUpdatePoller', () => { expect(checkHermesUpdateSpy).toHaveBeenCalled() }) + + it('discards a stale in-flight response and re-checks after switching to B before A resolves', async () => { + // A's check is still in flight when the switch to B happens — B's + // trigger is locked out by $backendUpdateChecking. Once A's (now stale) + // response lands it must not overwrite the display with A's result, and + // B's own check must still run once the lock clears. + checkHermesUpdateSpy.mockReset() + + let resolveA: (value: unknown) => void = () => {} + + const aPending = new Promise(resolve => { + resolveA = resolve + }) + + checkHermesUpdateSpy.mockImplementationOnce(() => aPending) + checkHermesUpdateSpy.mockResolvedValueOnce({ + install_method: 'git', + current_version: '0.17.0', + behind: 4, + update_available: true, + can_apply: true, + update_command: 'hermes update', + message: null + }) + + setConnection({ + baseUrl: 'http://profile-a:9119', + isFullscreen: false, + mode: 'remote', + nativeOverlayWidth: 0, + token: 't', + wsUrl: 'ws://profile-a:9119', + logs: [], + windowButtonPosition: null + }) + + startUpdatePoller() + // A's checkHermesUpdate() call is now in flight (aPending unresolved). + // Switch profiles before it settles. + + setConnection({ + baseUrl: 'http://profile-b:9119', + isFullscreen: false, + mode: 'remote', + nativeOverlayWidth: 0, + token: 't', + wsUrl: 'ws://profile-b:9119', + logs: [], + windowButtonPosition: null + }) + + // Now A's slow response lands. + resolveA({ + install_method: 'git', + current_version: '0.16.0', + behind: 1, + update_available: true, + can_apply: true, + update_command: 'hermes update', + message: null + }) + await vi.advanceTimersByTimeAsync(0) + // Flush the automatic follow-up check queued for B once A's lock cleared. + await vi.advanceTimersByTimeAsync(0) + + expect(checkHermesUpdateSpy).toHaveBeenCalledTimes(2) + // B's result (behind: 4), not A's stale one (behind: 1). + expect($backendUpdateStatus.get()?.behind).toBe(4) + }) }) describe('discontinued retirement notice', () => { diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index c9dcb43b82..f137e517ac 100644 --- a/apps/desktop/src/store/updates.ts +++ b/apps/desktop/src/store/updates.ts @@ -455,10 +455,26 @@ export interface UpdateCheckOptions { force?: boolean } +// Key of the connection that wants the next check once the in-flight one +// (if any) clears — set when a check is requested while another is already +// running for a different target, so that target isn't silently dropped. +let backendCheckPendingKey: string | undefined + export async function checkBackendUpdates({ force = false }: UpdateCheckOptions = {}): Promise { - if (!isRemoteMode() || $backendUpdateChecking.get()) { + if (!isRemoteMode()) { + return $backendUpdateStatus.get() + } + + // Bind this request to the connection active when it started. Switching + // remote targets mid-request must not let a slower, now-stale response + // (for the connection we've since left) overwrite the newer one. + const requestKey = connectionKey($connection.get()) + + if ($backendUpdateChecking.get()) { + backendCheckPendingKey = requestKey + return $backendUpdateStatus.get() } @@ -466,8 +482,11 @@ export async function checkBackendUpdates({ try { const status = mapBackendCheck(await checkHermesUpdate(force)) - $backendUpdateStatus.set(status) - maybeNotifyUpdateAvailable(status, 'backend') + + if (connectionKey($connection.get()) === requestKey) { + $backendUpdateStatus.set(status) + maybeNotifyUpdateAvailable(status, 'backend') + } return status } catch (error) { @@ -478,11 +497,24 @@ export async function checkBackendUpdates({ fetchedAt: Date.now() } - $backendUpdateStatus.set(fallback) + if (connectionKey($connection.get()) === requestKey) { + $backendUpdateStatus.set(fallback) + } return fallback } finally { $backendUpdateChecking.set(false) + + const pendingKey = backendCheckPendingKey + + backendCheckPendingKey = undefined + + // Someone asked for a check for a different (still-active) target while + // this one was in flight — run it now instead of leaving that target + // showing whatever this request happened to return. + if (pendingKey && pendingKey !== requestKey && pendingKey === connectionKey($connection.get())) { + void checkBackendUpdates() + } } }