fix(desktop): discard stale in-flight backend-update responses on profile switch
checkBackendUpdates() only guarded against concurrent calls via $backendUpdateChecking, so switching from remote profile A to B while A's request was still in flight caused B's trigger to be silently skipped (locked out), and A's later response to be published unconditionally even though B was now active. Bind each request to the connection key active when it started, discard responses whose key is no longer current, and queue a follow-up check for whichever target asked for one while the lock was held. Addresses review feedback on #62815.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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<DesktopUpdateStatus | null> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user