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:
JackJin
2026-07-12 04:12:58 +08:00
committed by brooklyn!
parent 406ec797dd
commit 83bbf8a5a2
2 changed files with 105 additions and 4 deletions

View File

@@ -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', () => {

View File

@@ -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()
}
}
}