fix: stop gateway handshake retry spam and improve origin guidance (#131)

This commit is contained in:
nyk 2026-03-04 08:41:41 +07:00 committed by GitHub
parent 2111f03542
commit 3662ab0fe7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 98 additions and 4 deletions

View File

@ -118,3 +118,25 @@ pnpm build
### Database locked errors
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.

View File

@ -82,7 +82,8 @@ export function MultiGatewayPanel() {
}
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
}

View File

@ -48,6 +48,7 @@ export function useWebSocket() {
const handshakeCompleteRef = useRef<boolean>(false)
const reconnectAttemptsRef = useRef<number>(0)
const manualDisconnectRef = useRef<boolean>(false)
const nonRetryableErrorRef = useRef<string | null>(null)
const connectRef = useRef<(url: string, token?: string) => void>(() => {})
// Heartbeat tracking
@ -70,6 +71,35 @@ export function useWebSocket() {
agents,
} = 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
const nextRequestId = () => {
requestIdRef.current += 1
@ -331,13 +361,35 @@ export function useWebSocket() {
// Handle connect error
if (frame.type === 'res' && !frame.ok) {
console.error('Gateway error:', frame.error)
const rawMessage = frame.error?.message || JSON.stringify(frame.error)
const help = getGatewayErrorHelp(rawMessage)
const nonRetryable = isNonRetryableGatewayError(rawMessage)
addLog({
id: `error-${Date.now()}`,
id: nonRetryable ? `gateway-handshake-${rawMessage}` : `error-${Date.now()}`,
timestamp: Date.now(),
level: 'error',
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
}
@ -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 state = wsRef.current?.readyState
@ -437,6 +502,7 @@ export function useWebSocket() {
reconnectUrl.current = url
handshakeCompleteRef.current = false
manualDisconnectRef.current = false
nonRetryableErrorRef.current = null
try {
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
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)
const attempts = reconnectAttemptsRef.current