fix: stop gateway handshake retry spam and improve origin guidance (#131)
This commit is contained in:
parent
2111f03542
commit
3662ab0fe7
|
|
@ -118,3 +118,25 @@ pnpm build
|
||||||
### Database locked errors
|
### Database locked errors
|
||||||
|
|
||||||
Ensure only one instance is running against the same `.data/` directory. SQLite uses WAL mode but does not support multiple writers.
|
Ensure only one instance is running against the same `.data/` directory. SQLite uses WAL mode but does not support multiple writers.
|
||||||
|
|
||||||
|
### "Gateway error: origin not allowed"
|
||||||
|
|
||||||
|
Your gateway is rejecting the Mission Control browser origin. Add the Control UI origin
|
||||||
|
to your gateway config allowlist, for example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gateway": {
|
||||||
|
"controlUi": {
|
||||||
|
"allowedOrigins": ["http://YOUR_HOST:3000"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the gateway and reconnect from Mission Control.
|
||||||
|
|
||||||
|
### "Gateway error: device identity required"
|
||||||
|
|
||||||
|
Device identity signing uses WebCrypto and requires a secure browser context.
|
||||||
|
Open Mission Control over HTTPS (or localhost), then reconnect.
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ export function MultiGatewayPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectTo = (gw: Gateway) => {
|
const connectTo = (gw: Gateway) => {
|
||||||
const wsUrl = `ws://${window.location.hostname}:${gw.port}`
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const wsUrl = `${proto}://${gw.host}:${gw.port}`
|
||||||
connect(wsUrl, '') // token is handled by the gateway entry, not passed to frontend
|
connect(wsUrl, '') // token is handled by the gateway entry, not passed to frontend
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ export function useWebSocket() {
|
||||||
const handshakeCompleteRef = useRef<boolean>(false)
|
const handshakeCompleteRef = useRef<boolean>(false)
|
||||||
const reconnectAttemptsRef = useRef<number>(0)
|
const reconnectAttemptsRef = useRef<number>(0)
|
||||||
const manualDisconnectRef = useRef<boolean>(false)
|
const manualDisconnectRef = useRef<boolean>(false)
|
||||||
|
const nonRetryableErrorRef = useRef<string | null>(null)
|
||||||
const connectRef = useRef<(url: string, token?: string) => void>(() => {})
|
const connectRef = useRef<(url: string, token?: string) => void>(() => {})
|
||||||
|
|
||||||
// Heartbeat tracking
|
// Heartbeat tracking
|
||||||
|
|
@ -70,6 +71,35 @@ export function useWebSocket() {
|
||||||
agents,
|
agents,
|
||||||
} = useMissionControl()
|
} = useMissionControl()
|
||||||
|
|
||||||
|
const isNonRetryableGatewayError = useCallback((message: string): boolean => {
|
||||||
|
const normalized = message.toLowerCase()
|
||||||
|
return (
|
||||||
|
normalized.includes('origin not allowed') ||
|
||||||
|
normalized.includes('device identity required') ||
|
||||||
|
normalized.includes('device_auth_signature_invalid') ||
|
||||||
|
normalized.includes('auth rate limit') ||
|
||||||
|
normalized.includes('rate limited')
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getGatewayErrorHelp = useCallback((message: string): string => {
|
||||||
|
const normalized = message.toLowerCase()
|
||||||
|
if (normalized.includes('origin not allowed')) {
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '<control-ui-origin>'
|
||||||
|
return `Gateway rejected browser origin. Add ${origin} to gateway.controlUi.allowedOrigins on the gateway, then reconnect.`
|
||||||
|
}
|
||||||
|
if (normalized.includes('device identity required')) {
|
||||||
|
return 'Gateway requires device identity. Open Mission Control via HTTPS (or localhost), then reconnect so WebCrypto signing can run.'
|
||||||
|
}
|
||||||
|
if (normalized.includes('device_auth_signature_invalid')) {
|
||||||
|
return 'Gateway rejected device signature. Clear local device identity in the browser and reconnect.'
|
||||||
|
}
|
||||||
|
if (normalized.includes('auth rate limit') || normalized.includes('rate limited')) {
|
||||||
|
return 'Gateway authentication is rate limited. Wait briefly, then reconnect.'
|
||||||
|
}
|
||||||
|
return 'Gateway handshake failed. Check gateway control UI origin and device identity settings, then reconnect.'
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Generate unique request ID
|
// Generate unique request ID
|
||||||
const nextRequestId = () => {
|
const nextRequestId = () => {
|
||||||
requestIdRef.current += 1
|
requestIdRef.current += 1
|
||||||
|
|
@ -331,13 +361,35 @@ export function useWebSocket() {
|
||||||
// Handle connect error
|
// Handle connect error
|
||||||
if (frame.type === 'res' && !frame.ok) {
|
if (frame.type === 'res' && !frame.ok) {
|
||||||
console.error('Gateway error:', frame.error)
|
console.error('Gateway error:', frame.error)
|
||||||
|
const rawMessage = frame.error?.message || JSON.stringify(frame.error)
|
||||||
|
const help = getGatewayErrorHelp(rawMessage)
|
||||||
|
const nonRetryable = isNonRetryableGatewayError(rawMessage)
|
||||||
|
|
||||||
addLog({
|
addLog({
|
||||||
id: `error-${Date.now()}`,
|
id: nonRetryable ? `gateway-handshake-${rawMessage}` : `error-${Date.now()}`,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
level: 'error',
|
level: 'error',
|
||||||
source: 'gateway',
|
source: 'gateway',
|
||||||
message: `Gateway error: ${frame.error?.message || JSON.stringify(frame.error)}`
|
message: `Gateway error: ${rawMessage}${nonRetryable ? ` — ${help}` : ''}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (nonRetryable) {
|
||||||
|
nonRetryableErrorRef.current = rawMessage
|
||||||
|
addNotification({
|
||||||
|
id: Date.now(),
|
||||||
|
recipient: 'operator',
|
||||||
|
type: 'error',
|
||||||
|
title: 'Gateway Handshake Blocked',
|
||||||
|
message: help,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stop futile reconnect loops for config/auth errors.
|
||||||
|
stopHeartbeat()
|
||||||
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
ws.close(4001, 'Non-retryable gateway handshake error')
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,7 +470,20 @@ export function useWebSocket() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [sendConnectHandshake, setConnection, setSessions, addLog, startHeartbeat, handlePong, addChatMessage, addNotification, updateAgent])
|
}, [
|
||||||
|
sendConnectHandshake,
|
||||||
|
setConnection,
|
||||||
|
setSessions,
|
||||||
|
addLog,
|
||||||
|
startHeartbeat,
|
||||||
|
handlePong,
|
||||||
|
addChatMessage,
|
||||||
|
addNotification,
|
||||||
|
updateAgent,
|
||||||
|
stopHeartbeat,
|
||||||
|
isNonRetryableGatewayError,
|
||||||
|
getGatewayErrorHelp,
|
||||||
|
])
|
||||||
|
|
||||||
const connect = useCallback((url: string, token?: string) => {
|
const connect = useCallback((url: string, token?: string) => {
|
||||||
const state = wsRef.current?.readyState
|
const state = wsRef.current?.readyState
|
||||||
|
|
@ -437,6 +502,7 @@ export function useWebSocket() {
|
||||||
reconnectUrl.current = url
|
reconnectUrl.current = url
|
||||||
handshakeCompleteRef.current = false
|
handshakeCompleteRef.current = false
|
||||||
manualDisconnectRef.current = false
|
manualDisconnectRef.current = false
|
||||||
|
nonRetryableErrorRef.current = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ws = new WebSocket(url.split('?')[0]) // Connect without query params
|
const ws = new WebSocket(url.split('?')[0]) // Connect without query params
|
||||||
|
|
@ -477,6 +543,11 @@ export function useWebSocket() {
|
||||||
|
|
||||||
// Skip auto-reconnect if this was a manual disconnect
|
// Skip auto-reconnect if this was a manual disconnect
|
||||||
if (manualDisconnectRef.current) return
|
if (manualDisconnectRef.current) return
|
||||||
|
// Skip auto-reconnect for non-retryable handshake failures
|
||||||
|
if (nonRetryableErrorRef.current) {
|
||||||
|
setConnection({ reconnectAttempts: 0 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-reconnect with exponential backoff (uses connectRef to avoid stale closure)
|
// Auto-reconnect with exponential backoff (uses connectRef to avoid stale closure)
|
||||||
const attempts = reconnectAttemptsRef.current
|
const attempts = reconnectAttemptsRef.current
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue