fix(desktop): never replay a failed RPC on a different gateway
useGatewayRequest recovered a transport failure through the current active route and retried the original payload there. A gateway switch between dispatch and the delayed rejection replayed the request on the other source. Retry only while the dispatch route is still active; otherwise surface the original error.
This commit is contained in:
committed by
Teknium
parent
ad4fed6b8d
commit
6cd5b5ca18
@@ -318,6 +318,76 @@ describe('useGatewayRequest', () => {
|
||||
expect(gateway.connect).toHaveBeenLastCalledWith(expect.stringContaining('ticket=fresh-2'))
|
||||
})
|
||||
|
||||
it.each(['default', 'research'])(
|
||||
'does not replay a delayed local failure onto another source (%s)',
|
||||
async profile => {
|
||||
installRemoteDesktop()
|
||||
const primary = makePrimaryGateway()
|
||||
const failure = new Error('connection closed')
|
||||
let rejectRequest!: (error: Error) => void
|
||||
primary.request.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectRequest = reject
|
||||
})
|
||||
)
|
||||
setPrimaryGateway(primary as unknown as HermesGateway, 'default')
|
||||
$gateway.set(primary as unknown as HermesGateway)
|
||||
$gatewayState.set('open')
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
const pending = result.current
|
||||
.requestGateway('prompt.submit', { session_id: 'local-only', text: 'private' })
|
||||
.catch(error => error)
|
||||
|
||||
await act(async () => {
|
||||
await ensureGatewayForAgent('home', profile)
|
||||
})
|
||||
const destination = $gateway.get() as unknown as TestGateway
|
||||
destination.request.mockResolvedValue({ sent: true })
|
||||
await act(async () => {
|
||||
rejectRequest(failure)
|
||||
})
|
||||
|
||||
expect(await pending).toBe(failure)
|
||||
expect(primary.request).toHaveBeenCalledTimes(1)
|
||||
expect(destination.request).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not replay when the source changes while recovery is pending', async () => {
|
||||
const desktop = installRemoteDesktop()
|
||||
const primary = makePrimaryGateway()
|
||||
const failure = new Error('connection closed')
|
||||
primary.connectionState = 'closed'
|
||||
primary.request.mockRejectedValueOnce(failure).mockResolvedValue({ sent: true })
|
||||
let finishRecovery!: () => void
|
||||
primary.connect.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>(resolve => {
|
||||
finishRecovery = resolve
|
||||
})
|
||||
)
|
||||
setPrimaryGateway(primary as unknown as HermesGateway, 'default')
|
||||
$gateway.set(primary as unknown as HermesGateway)
|
||||
$gatewayState.set('closed')
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
const pending = result.current.requestGateway('prompt.submit', { text: 'local-only' }).catch(error => error)
|
||||
await vi.waitFor(() => expect(primary.connect).toHaveBeenCalledTimes(1))
|
||||
await act(async () => {
|
||||
await ensureGatewayForAgent('home', 'default')
|
||||
})
|
||||
const destination = $gateway.get() as unknown as TestGateway
|
||||
await act(async () => {
|
||||
finishRecovery()
|
||||
})
|
||||
|
||||
expect(await pending).toBe(failure)
|
||||
expect(primary.request).toHaveBeenCalledTimes(1)
|
||||
expect(destination.request).not.toHaveBeenCalled()
|
||||
expect(desktop.getConnection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not reconnect for a non-transport request failure', async () => {
|
||||
const { desktop, gateway } = await activateRemoteGateway()
|
||||
const failure = Object.assign(new Error('request rejected'), { code: 'EVALIDATION' })
|
||||
|
||||
@@ -5,7 +5,14 @@ import { useCallback, useEffect, useRef } from 'react'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { resolveDesktopGatewayWsUrl } from '@/lib/gateway-ws-url'
|
||||
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
||||
import { $gateway, activeGateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import {
|
||||
$gateway,
|
||||
activeGateway,
|
||||
activeGatewayConnectionId,
|
||||
activeGatewayProfileKey,
|
||||
ensureActiveGatewayOpen,
|
||||
isActivePrimary
|
||||
} from '@/store/gateway'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
@@ -138,10 +145,20 @@ export function useGatewayRequest() {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
// Bind retries to the dispatch owner, not whichever source is focused
|
||||
// when a delayed transport failure (or its reconnect) finishes.
|
||||
const connectionId = activeGatewayConnectionId()
|
||||
const profile = activeGatewayProfileKey()
|
||||
|
||||
const isDispatchRouteActive = () =>
|
||||
(gatewayRef.current ?? activeGateway()) === gateway &&
|
||||
activeGatewayConnectionId() === connectionId &&
|
||||
activeGatewayProfileKey() === profile
|
||||
|
||||
try {
|
||||
return await gateway.request<T>(method, params, timeoutMs, signal)
|
||||
} catch (error) {
|
||||
if (!isGatewayTransportError(error)) {
|
||||
if (!isGatewayTransportError(error) || !isDispatchRouteActive()) {
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -151,6 +168,10 @@ export function useGatewayRequest() {
|
||||
// sources.
|
||||
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
|
||||
|
||||
if (!isDispatchRouteActive() || (recovered && recovered !== gateway)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!recovered) {
|
||||
// Prefer the reauth error from the failed reconnect (OAuth session
|
||||
// expired) over the generic transport error that triggered the retry.
|
||||
|
||||
Reference in New Issue
Block a user