Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,71 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
type GatewaySurvivor,
|
||||
stashGatewaySurvivor,
|
||||
survivorIsStale,
|
||||
takeGatewaySurvivor
|
||||
} from './gateway-hmr-survivor'
|
||||
|
||||
// A minimal stand-in for HermesGateway: the survivor cache only reads
|
||||
// `connectionState`. Cast through unknown so we don't drag the whole class in.
|
||||
function fakeGateway(state: 'idle' | 'connecting' | 'open' | 'closed' | 'error') {
|
||||
return { connectionState: state } as unknown as GatewaySurvivor['gateway']
|
||||
}
|
||||
|
||||
function makeSurvivor(state: Parameters<typeof fakeGateway>[0]): GatewaySurvivor {
|
||||
return { gateway: fakeGateway(state), profile: 'default', connection: null }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
// Drain any survivor a test parked but didn't take, so cases don't leak across
|
||||
// the shared globalThis slot.
|
||||
takeGatewaySurvivor()
|
||||
})
|
||||
|
||||
describe('gateway HMR survivor cache', () => {
|
||||
it('returns null when nothing is parked', () => {
|
||||
expect(takeGatewaySurvivor()).toBeNull()
|
||||
})
|
||||
|
||||
it('round-trips a parked survivor', () => {
|
||||
const survivor = makeSurvivor('open')
|
||||
stashGatewaySurvivor(survivor)
|
||||
expect(takeGatewaySurvivor()).toBe(survivor)
|
||||
})
|
||||
|
||||
it('is single-shot — a second take returns null', () => {
|
||||
stashGatewaySurvivor(makeSurvivor('open'))
|
||||
expect(takeGatewaySurvivor()).not.toBeNull()
|
||||
expect(takeGatewaySurvivor()).toBeNull()
|
||||
})
|
||||
|
||||
it('persists across re-imports via the globalThis slot', async () => {
|
||||
const survivor = makeSurvivor('open')
|
||||
stashGatewaySurvivor(survivor)
|
||||
|
||||
// A fresh import of the module (simulating an HMR module swap) must resolve
|
||||
// the same underlying store, not a reset one.
|
||||
const reimported = await import('./gateway-hmr-survivor')
|
||||
expect(reimported.takeGatewaySurvivor()).toBe(survivor)
|
||||
})
|
||||
|
||||
it('treats open / connecting sockets as adoptable', () => {
|
||||
expect(survivorIsStale(makeSurvivor('open'))).toBe(false)
|
||||
expect(survivorIsStale(makeSurvivor('connecting'))).toBe(false)
|
||||
})
|
||||
|
||||
it('treats closed / error / idle sockets as stale', () => {
|
||||
expect(survivorIsStale(makeSurvivor('closed'))).toBe(true)
|
||||
expect(survivorIsStale(makeSurvivor('error'))).toBe(true)
|
||||
expect(survivorIsStale(makeSurvivor('idle'))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns a stale survivor from take() so the caller can close it', () => {
|
||||
const dead = makeSurvivor('closed')
|
||||
stashGatewaySurvivor(dead)
|
||||
const taken = takeGatewaySurvivor()
|
||||
expect(taken).toBe(dead)
|
||||
expect(taken && survivorIsStale(taken)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
// Keep the live primary gateway socket alive across Vite HMR so editing UI never
|
||||
// drops the agent session. Stash on dispose, re-adopt on remount. globalThis +
|
||||
// self-accept so this module's own reload doesn't reset the cache. Prod strips
|
||||
// import.meta.hot → byte-for-byte unchanged live unmount.
|
||||
|
||||
import type { HermesConnection } from '@/global'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
|
||||
export interface GatewaySurvivor {
|
||||
gateway: HermesGateway
|
||||
profile: string
|
||||
connection: HermesConnection | null
|
||||
}
|
||||
|
||||
// One slot on globalThis, keyed by a process-stable Symbol so repeated imports
|
||||
// (across hot reloads) resolve the exact same store.
|
||||
const SURVIVOR_KEY = Symbol.for('hermes.desktop.gatewaySurvivor')
|
||||
|
||||
interface SurvivorGlobal {
|
||||
[SURVIVOR_KEY]?: GatewaySurvivor | null
|
||||
}
|
||||
|
||||
function slot(): SurvivorGlobal {
|
||||
return globalThis as unknown as SurvivorGlobal
|
||||
}
|
||||
|
||||
/** Park the live gateway so the next module instance can re-adopt it. */
|
||||
export function stashGatewaySurvivor(survivor: GatewaySurvivor): void {
|
||||
slot()[SURVIVOR_KEY] = survivor
|
||||
}
|
||||
|
||||
/**
|
||||
* Take the parked gateway, if any. Single-shot: the slot is cleared on read.
|
||||
* The caller decides whether to adopt or discard it via `survivorIsStale` — a
|
||||
* socket that died while parked (e.g. backend restart between edits) is still
|
||||
* returned so the caller can close it and boot fresh.
|
||||
*/
|
||||
export function takeGatewaySurvivor(): GatewaySurvivor | null {
|
||||
const store = slot()
|
||||
const survivor = store[SURVIVOR_KEY] ?? null
|
||||
store[SURVIVOR_KEY] = null
|
||||
|
||||
return survivor
|
||||
}
|
||||
|
||||
/** A parked survivor whose socket is no longer open — caller should discard it. */
|
||||
export function survivorIsStale(survivor: GatewaySurvivor): boolean {
|
||||
const state = survivor.gateway.connectionState
|
||||
|
||||
return state !== 'open' && state !== 'connecting'
|
||||
}
|
||||
|
||||
// Self-accept so editing THIS module doesn't blow away the cache it manages.
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,392 @@
|
||||
import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
instances: [] as Array<{
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
connectionState: string
|
||||
request: ReturnType<typeof vi.fn>
|
||||
wsUrl: string
|
||||
}>
|
||||
}))
|
||||
|
||||
vi.mock('@/hermes', async importOriginal => {
|
||||
const actual = await importOriginal<typeof HermesModule>()
|
||||
|
||||
class FakeHermesGateway {
|
||||
connectionState = 'closed'
|
||||
wsUrl = ''
|
||||
request = vi.fn()
|
||||
connect = vi.fn(async (wsUrl: string) => {
|
||||
this.wsUrl = wsUrl
|
||||
this.connectionState = 'open'
|
||||
|
||||
for (const handler of this.stateHandlers) {
|
||||
handler('open')
|
||||
}
|
||||
})
|
||||
close = vi.fn(() => {
|
||||
this.connectionState = 'closed'
|
||||
|
||||
for (const handler of this.stateHandlers) {
|
||||
handler('closed')
|
||||
}
|
||||
})
|
||||
onEvent = vi.fn(() => () => undefined)
|
||||
onState = vi.fn((handler: (state: string) => void) => {
|
||||
this.stateHandlers.add(handler)
|
||||
handler(this.connectionState)
|
||||
|
||||
return () => this.stateHandlers.delete(handler)
|
||||
})
|
||||
private stateHandlers = new Set<(state: string) => void>()
|
||||
|
||||
constructor() {
|
||||
gatewayMocks.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
return { ...actual, HermesGateway: FakeHermesGateway }
|
||||
})
|
||||
|
||||
import type * as HermesModule from '@/hermes'
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import {
|
||||
$gateway,
|
||||
closeSecondaryGateways,
|
||||
configureGatewayRegistry,
|
||||
ensureGatewayForAgent,
|
||||
setPrimaryGateway
|
||||
} from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $connection, $gatewayState } from '@/store/session'
|
||||
|
||||
import { useGatewayRequest } from './use-gateway-request'
|
||||
|
||||
interface TestGateway {
|
||||
connect: ReturnType<typeof vi.fn>
|
||||
connectionState: string
|
||||
request: ReturnType<typeof vi.fn>
|
||||
wsUrl?: string
|
||||
}
|
||||
|
||||
const fakeGateway = { connectionState: 'open' } as unknown as HermesGateway
|
||||
|
||||
const remoteConnection = {
|
||||
authMode: 'oauth' as const,
|
||||
baseUrl: 'https://ssh.example.test',
|
||||
connectionId: 'ssh-source',
|
||||
mode: 'remote' as const,
|
||||
profile: 'research',
|
||||
remoteIdentity: 'ssh.example.test',
|
||||
remoteKind: 'ssh' as const,
|
||||
token: 'remote-token',
|
||||
wsUrl: 'wss://ssh.example.test/api/ws?ticket=stale'
|
||||
}
|
||||
|
||||
function installRemoteDesktop() {
|
||||
let mintCount = 0
|
||||
|
||||
const getConnection = vi.fn(async (profile?: null | string) => ({
|
||||
authMode: 'token' as const,
|
||||
baseUrl: 'http://127.0.0.1:5151',
|
||||
mode: 'local' as const,
|
||||
profile: profile ?? 'default',
|
||||
token: 'local-token',
|
||||
wsUrl: 'ws://127.0.0.1:5151/api/ws?token=local'
|
||||
}))
|
||||
|
||||
const getConnectionFor = vi.fn(async ({ connectionId, profile }: { connectionId: string; profile: string }) => ({
|
||||
...remoteConnection,
|
||||
connectionId,
|
||||
profile
|
||||
}))
|
||||
|
||||
const getGatewayWsUrl = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
wsUrl: 'ws://127.0.0.1:5151/api/ws?token=fresh-local'
|
||||
}))
|
||||
|
||||
const getGatewayWsUrlFor = vi.fn(
|
||||
async ({ connectionId, profile }: { connectionId: string; profile: string }): Promise<GatewayWsUrlResult> => {
|
||||
mintCount += 1
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
wsUrl: `wss://${connectionId}.example.test/api/ws?profile=${profile}&ticket=fresh-${mintCount}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor }
|
||||
})
|
||||
|
||||
return { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor }
|
||||
}
|
||||
|
||||
function installPrimaryDesktop(authMode: 'oauth' | 'token') {
|
||||
const getConnection = vi.fn(async (profile?: null | string) => ({
|
||||
authMode,
|
||||
baseUrl: authMode === 'oauth' ? 'https://gateway.example.test' : 'http://127.0.0.1:5151',
|
||||
mode: authMode === 'oauth' ? ('remote' as const) : ('local' as const),
|
||||
profile: profile ?? 'default',
|
||||
token: 'primary-token',
|
||||
wsUrl: authMode === 'oauth' ? 'wss://gateway.example.test/api/ws?ticket=stale' : 'ws://127.0.0.1:5151/api/ws'
|
||||
}))
|
||||
|
||||
const getGatewayWsUrl = vi.fn(async (profile?: null | string) => ({
|
||||
ok: true as const,
|
||||
wsUrl:
|
||||
authMode === 'oauth'
|
||||
? `wss://gateway.example.test/api/ws?profile=${profile ?? 'default'}&ticket=fresh`
|
||||
: 'ws://127.0.0.1:5151/api/ws?token=fresh'
|
||||
}))
|
||||
|
||||
const getConnectionFor = vi.fn()
|
||||
const getGatewayWsUrlFor = vi.fn()
|
||||
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor }
|
||||
})
|
||||
|
||||
return { getConnection, getConnectionFor, getGatewayWsUrl, getGatewayWsUrlFor }
|
||||
}
|
||||
|
||||
function makePrimaryGateway(): TestGateway {
|
||||
return {
|
||||
connect: vi.fn(async () => undefined),
|
||||
connectionState: 'open',
|
||||
request: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
async function activateRemoteGateway() {
|
||||
const desktop = installRemoteDesktop()
|
||||
const primary = makePrimaryGateway()
|
||||
|
||||
setPrimaryGateway(primary as unknown as HermesGateway, 'default')
|
||||
$gateway.set(primary as unknown as HermesGateway)
|
||||
await ensureGatewayForAgent('ssh-source', 'research')
|
||||
|
||||
const gateway = $gateway.get() as unknown as TestGateway
|
||||
|
||||
expect(gateway).not.toBe(primary)
|
||||
expect($activeGatewayProfile.get()).toBe('research')
|
||||
|
||||
return { desktop, gateway }
|
||||
}
|
||||
|
||||
async function expectSecondaryRecoveryFailure(
|
||||
gateway: TestGateway,
|
||||
request: ReturnType<typeof useGatewayRequest>['requestGateway']
|
||||
) {
|
||||
const transportError = new Error('connection closed')
|
||||
gateway.request.mockRejectedValueOnce(transportError)
|
||||
gateway.connectionState = 'closed'
|
||||
|
||||
vi.useFakeTimers()
|
||||
|
||||
const retry = request('session.resume').then(
|
||||
() => undefined,
|
||||
error => error
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
})
|
||||
|
||||
await expect(retry).resolves.toBe(transportError)
|
||||
expect(gateway.request).toHaveBeenCalledTimes(1)
|
||||
expect(gateway.connect).toHaveBeenCalledTimes(1)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gatewayMocks.instances.length = 0
|
||||
closeSecondaryGateways()
|
||||
setPrimaryGateway(null)
|
||||
$gateway.set(null)
|
||||
$connection.set(null)
|
||||
$gatewayState.set('idle')
|
||||
$activeGatewayProfile.set('default')
|
||||
configureGatewayRegistry({
|
||||
onActiveRouteChanged: profile => $activeGatewayProfile.set(profile),
|
||||
onEvent: vi.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
closeSecondaryGateways()
|
||||
setPrimaryGateway(null)
|
||||
$gateway.set(null)
|
||||
$connection.set(null)
|
||||
$gatewayState.set('idle')
|
||||
$activeGatewayProfile.set('default')
|
||||
Reflect.deleteProperty(window, 'hermesDesktop')
|
||||
})
|
||||
|
||||
describe('useGatewayRequest', () => {
|
||||
it('exposes the live gateway on the first render, before effects run', () => {
|
||||
$gateway.set(fakeGateway)
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
expect(result.current.gateway).toBe(fakeGateway)
|
||||
})
|
||||
|
||||
it('tracks the gateway when the active socket changes', () => {
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
expect(result.current.gateway).toBeNull()
|
||||
|
||||
act(() => $gateway.set(fakeGateway))
|
||||
|
||||
expect(result.current.gateway).toBe(fakeGateway)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ error: new Error('connection closed'), label: 'closed message' },
|
||||
{ error: new Error('ECONNRESET'), label: 'reset message' },
|
||||
{ error: Object.assign(new Error('socket failed'), { code: 'ECONNRESET' }), label: 'error code' },
|
||||
{ error: Object.assign(new Error('socket failed'), { cause: { code: 'ECONNRESET' } }), label: 'cause code' }
|
||||
])('recovers the registered remote source after a $label failure', async ({ error }) => {
|
||||
const { desktop, gateway } = await activateRemoteGateway()
|
||||
gateway.request.mockResolvedValueOnce({ turn: 1 }).mockRejectedValueOnce(error).mockResolvedValueOnce({ turn: 2 })
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.requestGateway('prompt.submit', { text: 'first' })).resolves.toEqual({ turn: 1 })
|
||||
})
|
||||
gateway.connectionState = 'closed'
|
||||
await act(async () => {
|
||||
await expect(result.current.requestGateway('prompt.submit', { text: 'second' })).resolves.toEqual({ turn: 2 })
|
||||
})
|
||||
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledTimes(2)
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'ssh-source', profile: 'research' })
|
||||
expect(desktop.getGatewayWsUrlFor).toHaveBeenCalledTimes(2)
|
||||
expect(desktop.getGatewayWsUrlFor).toHaveBeenCalledWith({ connectionId: 'ssh-source', profile: 'research' })
|
||||
expect(desktop.getConnection).not.toHaveBeenCalled()
|
||||
expect(desktop.getGatewayWsUrl).not.toHaveBeenCalled()
|
||||
expect(gateway.connect).toHaveBeenLastCalledWith(expect.stringContaining('ticket=fresh-2'))
|
||||
})
|
||||
|
||||
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' })
|
||||
gateway.request.mockRejectedValueOnce(failure)
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
await expect(result.current.requestGateway('session.resume')).rejects.toBe(failure)
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledTimes(1)
|
||||
expect(desktop.getGatewayWsUrlFor).toHaveBeenCalledTimes(1)
|
||||
expect(gateway.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('surfaces a real secondary OAuth reauth rejection as the original transport failure', async () => {
|
||||
const { desktop, gateway } = await activateRemoteGateway()
|
||||
desktop.getGatewayWsUrlFor.mockImplementation(async () => ({
|
||||
error: '401 cookie expired',
|
||||
needsOauthLogin: true,
|
||||
ok: false as const
|
||||
}))
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
await expectSecondaryRecoveryFailure(gateway, result.current.requestGateway)
|
||||
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'ssh-source', profile: 'research' })
|
||||
expect(desktop.getGatewayWsUrlFor).toHaveBeenCalled()
|
||||
expect(desktop.getConnection).not.toHaveBeenCalled()
|
||||
expect(desktop.getGatewayWsUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a failed secondary OAuth ticket mint without using the stale ticket or local bridges', async () => {
|
||||
const { desktop, gateway } = await activateRemoteGateway()
|
||||
desktop.getGatewayWsUrlFor.mockRejectedValue(new Error('ticket mint failed'))
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
await expectSecondaryRecoveryFailure(gateway, result.current.requestGateway)
|
||||
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'ssh-source', profile: 'research' })
|
||||
expect(desktop.getConnection).not.toHaveBeenCalled()
|
||||
expect(desktop.getGatewayWsUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('surfaces a missing optional scoped mint bridge without falling back to a stale ticket or local lookup', async () => {
|
||||
const { desktop, gateway } = await activateRemoteGateway()
|
||||
Reflect.deleteProperty(window.hermesDesktop, 'getGatewayWsUrlFor')
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
await expectSecondaryRecoveryFailure(gateway, result.current.requestGateway)
|
||||
|
||||
expect(desktop.getConnectionFor).toHaveBeenCalledWith({ connectionId: 'ssh-source', profile: 'research' })
|
||||
expect(desktop.getConnection).not.toHaveBeenCalled()
|
||||
expect(desktop.getGatewayWsUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ authMode: 'oauth' as const, label: 'primary OAuth' },
|
||||
{ authMode: 'token' as const, label: 'local primary' }
|
||||
])('preserves $label recovery', async ({ authMode }) => {
|
||||
const desktop = installPrimaryDesktop(authMode)
|
||||
const primary = makePrimaryGateway()
|
||||
primary.request.mockRejectedValueOnce(new Error('connection closed')).mockResolvedValueOnce({ recovered: true })
|
||||
|
||||
setPrimaryGateway(primary as unknown as HermesGateway, 'default')
|
||||
$gateway.set(primary as unknown as HermesGateway)
|
||||
$gatewayState.set('closed')
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.requestGateway('session.resume')).resolves.toEqual({ recovered: true })
|
||||
})
|
||||
|
||||
expect(desktop.getConnection).toHaveBeenCalledWith('default')
|
||||
expect(desktop.getGatewayWsUrl).toHaveBeenCalledWith('default')
|
||||
expect(desktop.getConnectionFor).not.toHaveBeenCalled()
|
||||
expect(desktop.getGatewayWsUrlFor).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects instead of hanging forever when the reconnect getConnection() wedges (#93454)', async () => {
|
||||
// Repro: a request lands on a dropped socket, the "not connected" catch
|
||||
// kicks off a reconnect, and the IPC round-trip into main
|
||||
// (desktop.getConnection) never settles — e.g. a wedged revalidation after
|
||||
// a liveness-probe trip. Without an internal timeout on that await,
|
||||
// reconnectingRef never clears and requestGateway hangs forever instead of
|
||||
// surfacing the original transport error.
|
||||
vi.useFakeTimers()
|
||||
|
||||
const dropped = {
|
||||
connectionState: 'closed',
|
||||
request: vi.fn().mockRejectedValue(new Error('connection closed'))
|
||||
} as unknown as HermesGateway
|
||||
|
||||
const getConnection = vi.fn(() => new Promise(() => undefined))
|
||||
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { getConnection }
|
||||
$gateway.set(dropped)
|
||||
|
||||
const { result } = renderHook(() => useGatewayRequest())
|
||||
|
||||
const pending = expect(result.current.requestGateway('some.method')).rejects.toThrow('connection closed')
|
||||
|
||||
// Advance past the internal reconnect-attempt timeout (20s) — the stalled
|
||||
// getConnection() await must reject so the reconnect gives up and the
|
||||
// original transport error surfaces, instead of requestGateway() never
|
||||
// settling.
|
||||
await vi.advanceTimersByTimeAsync(20_000)
|
||||
await pending
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import type { HermesGateway } from '@/hermes'
|
||||
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
||||
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $gatewayState, setConnection } from '@/store/session'
|
||||
|
||||
export function useGatewayRequest() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
// Reactive companion to `gatewayRef`. The ref exists so `requestGateway`
|
||||
// keeps a stable identity and always reaches the live socket, but it is only
|
||||
// populated by the subscription effect below — i.e. AFTER the first render.
|
||||
// A component that reads `gatewayRef.current` while rendering therefore sees
|
||||
// null on mount, and if the connection state doesn't happen to flip
|
||||
// afterwards it never re-renders to pick the instance up. Anything that needs
|
||||
// the gateway as a render-time VALUE (props, memo deps) must use this.
|
||||
const gateway = useStore($gateway) as HermesGateway | null
|
||||
const gatewayRef = useRef<HermesGateway | null>(null)
|
||||
|
||||
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const gatewayStateRef = useRef(gatewayState)
|
||||
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
|
||||
// Holds the reauth error from the most recent failed reconnect so
|
||||
// requestGateway can surface the gateway's "session expired, sign in again"
|
||||
// message instead of the opaque "connection closed" that triggered the retry.
|
||||
const reauthErrorRef = useRef<unknown>(null)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
gatewayStateRef.current = gatewayState
|
||||
}, [gatewayState])
|
||||
|
||||
// Track the active gateway (primary or a background profile's socket) so
|
||||
// outbound requests and overlay props always target the focused profile.
|
||||
useEffect(
|
||||
() =>
|
||||
$gateway.subscribe(gateway => {
|
||||
gatewayRef.current = gateway as HermesGateway | null
|
||||
}),
|
||||
[]
|
||||
)
|
||||
|
||||
const ensureGatewayOpen = useCallback(async () => {
|
||||
const existing = gatewayRef.current
|
||||
|
||||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (gatewayStateRef.current === 'open') {
|
||||
return existing
|
||||
}
|
||||
|
||||
if (reconnectingRef.current) {
|
||||
return reconnectingRef.current
|
||||
}
|
||||
|
||||
reconnectingRef.current = (async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop) {
|
||||
return null
|
||||
}
|
||||
|
||||
reauthErrorRef.current = null
|
||||
|
||||
try {
|
||||
// Reconnect to whichever profile the gateway is currently routed to (not
|
||||
// always the primary), so a sleep/wake reconnect keeps the user on the
|
||||
// profile they were chatting in. Both awaits below are IPC round-trips
|
||||
// into the main process with no timeout of their own (#93454) — a
|
||||
// wedged main-process round-trip otherwise hangs this await forever,
|
||||
// latching reconnectingRef.current so every later requestGateway() call
|
||||
// returns the same never-settling promise. Bound the same way
|
||||
// use-gateway-boot.ts bounds the primary boot/soft-switch equivalents.
|
||||
const conn = await withTimeout(
|
||||
desktop.getConnection($activeGatewayProfile.get()),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
'Timed out reconnecting to Hermes backend'
|
||||
)
|
||||
|
||||
connectionRef.current = conn
|
||||
setConnection(conn)
|
||||
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
// and short-lived, so the cached conn.wsUrl ticket is dead here;
|
||||
// resolveGatewayWsUrl() never connects with a stale ticket. An explicit
|
||||
// auth rejection becomes a reauth error; transport failures remain
|
||||
// retryable. Stash only the former so requestGateway can show the
|
||||
// actionable "sign in again" message.
|
||||
const wsUrl = await withTimeout(
|
||||
resolveGatewayWsUrl(desktop, conn),
|
||||
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
||||
'Timed out re-minting the gateway WebSocket URL'
|
||||
)
|
||||
|
||||
await existing.connect(wsUrl)
|
||||
|
||||
return existing
|
||||
} catch (error) {
|
||||
if (isGatewayReauthRequired(error)) {
|
||||
reauthErrorRef.current = error
|
||||
}
|
||||
|
||||
connectionRef.current = null
|
||||
setConnection(null)
|
||||
|
||||
return null
|
||||
} finally {
|
||||
reconnectingRef.current = null
|
||||
}
|
||||
})()
|
||||
|
||||
return reconnectingRef.current
|
||||
}, [])
|
||||
|
||||
const requestGateway = useCallback(
|
||||
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
|
||||
const gateway = gatewayRef.current
|
||||
|
||||
if (!gateway) {
|
||||
throw new Error('Hermes gateway unavailable')
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.request<T>(method, params, timeoutMs, signal)
|
||||
} catch (error) {
|
||||
if (!isGatewayTransportError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
|
||||
// single-use ticket). Background profiles stay on the registry's
|
||||
// connection-owned reconnect path, including composite remote/SSH
|
||||
// sources.
|
||||
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
|
||||
|
||||
if (!recovered) {
|
||||
// Prefer the reauth error from the failed reconnect (OAuth session
|
||||
// expired) over the generic transport error that triggered the retry.
|
||||
const reauthError = reauthErrorRef.current
|
||||
reauthErrorRef.current = null
|
||||
|
||||
if (reauthError) {
|
||||
throw reauthError
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
return recovered.request<T>(method, params, timeoutMs, signal)
|
||||
}
|
||||
},
|
||||
[ensureGatewayOpen]
|
||||
)
|
||||
|
||||
return { connectionRef, gateway, gatewayRef, requestGateway }
|
||||
}
|
||||
|
||||
const GATEWAY_TRANSPORT_ERROR_CODES = new Set([
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH',
|
||||
'ENOTFOUND',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'ERR_NETWORK',
|
||||
'ERR_SOCKET_CLOSED'
|
||||
])
|
||||
|
||||
function errorCode(value: unknown): string | null {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const code = (value as { code?: unknown }).code
|
||||
|
||||
return typeof code === 'string' ? code.toUpperCase() : null
|
||||
}
|
||||
|
||||
function isGatewayTransportError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
||||
if (/not connected|connection closed|connection reset|ECONNRESET/i.test(message)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const cause = typeof error === 'object' && error !== null ? (error as { cause?: unknown }).cause : undefined
|
||||
|
||||
return [error, cause].some(value => {
|
||||
const code = errorCode(value)
|
||||
|
||||
return code !== null && GATEWAY_TRANSPORT_ERROR_CODES.has(code)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user