fix(openclaw): improve gateway startup diagnostics and fix UI polling loop (#13495)

### What this PR does

Before this PR:
- Gateway startup failures showed limited diagnostics (only stderr, no
stdout)
- OpenClawPage had infinite re-render loops caused by `setInterval` +
`dispatch` in `useEffect` deps
- Process exit with code 0 was not properly diagnosed when the
daemonized child failed

After this PR:
- Capture both stdout and stderr from the gateway process for better
failure diagnostics
- Replace `setInterval` polling with SWR for stable, dedup'd
status/health polling in the UI
- Improve error messages for gateway startup timeout (combine health,
stderr, stdout diagnostics)
- Handle edge case where gateway exits with code 0 but never becomes
healthy

### Why we need it and why it was done in this way

The infinite re-render loop in the renderer was the root cause of false
timeout errors during gateway health checks. Switching to SWR provides
stable polling without the pitfall of `setInterval` + `dispatch` in
`useEffect` deps.

The diagnostics improvements (stdout capture, combined error output) are
independent quality-of-life improvements that help debug gateway startup
failures.

### Breaking changes

None.

### Checklist

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: Write code that humans can understand and Keep it simple
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
NONE
```

---------

Signed-off-by: suyao <sy20010504@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
SuYao
2026-03-16 14:28:30 +08:00
committed by GitHub
parent 878861db84
commit 590815bbd5
2 changed files with 45 additions and 37 deletions

View File

@@ -399,14 +399,18 @@ class OpenClawService {
const proc = spawn(openclawPath, args, {
env: shellEnv,
detached: !isWin, // Only detach on non-Windows to avoid console flash
stdio: ['ignore', 'ignore', 'pipe'],
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
})
proc.unref()
// Collect early exit errors (e.g. binary crash on startup)
let earlyExitError = ''
let stdoutOutput = ''
let stderrOutput = ''
proc.stdout?.on('data', (data) => {
stdoutOutput += data.toString()
})
proc.stderr?.on('data', (data) => {
stderrOutput += data.toString()
})
@@ -414,10 +418,16 @@ class OpenClawService {
earlyExitError = err.message
})
proc.on('exit', (code) => {
// Capture output from both streams for diagnostics
const combinedOutput = [stderrOutput.trim(), stdoutOutput.trim()].filter(Boolean).join('\n')
const detail = combinedOutput.split('\n').filter(Boolean).slice(0, 5).join('\n')
if (code !== 0) {
// Extract the most useful line from stderr for the error message
const detail = stderrOutput.trim().split('\n').filter(Boolean).slice(0, 3).join('\n')
earlyExitError = detail || `gateway exited with code ${code}`
} else {
// Process exited with code 0 but gateway may not be healthy (e.g. daemonized child failed)
earlyExitError = detail
? `gateway exited with code 0 but output: ${detail}`
: 'gateway process exited with code 0 before becoming healthy'
}
})
@@ -446,7 +456,15 @@ class OpenClawService {
if (healthError) lastError = healthError
}
const detail = lastError ? `\n${lastError}` : ''
// Combine all available diagnostics: health check errors, stderr, and stdout
const diagnostics = [
lastError ? `health: ${lastError}` : '',
stderrOutput.trim() ? `stderr: ${stderrOutput.trim().split('\n').slice(0, 5).join('\n')}` : '',
stdoutOutput.trim() ? `stdout: ${stdoutOutput.trim().split('\n').slice(0, 5).join('\n')}` : ''
]
.filter(Boolean)
.join('\n')
const detail = diagnostics ? `\n${diagnostics}` : ''
throw new Error(`Gateway failed to start within ${maxWaitMs}ms (${pollCount} polls)${detail}`)
}
@@ -904,10 +922,9 @@ class OpenClawService {
*/
public async getChannelStatus(): Promise<ChannelInfo[]> {
try {
const response = await fetch(`http://localhost:${this.gatewayPort}/api/channels`, {
const response = await fetch(`http://127.0.0.1:${this.gatewayPort}/api/channels`, {
signal: AbortSignal.timeout(5000)
})
if (response.ok) {
const data = await response.json()
return data.channels || []

View File

@@ -19,6 +19,7 @@ import { Download, ExternalLink, Play, Square } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import UpdateButton from './components/UpdateButton'
@@ -188,23 +189,30 @@ const OpenClawPage: FC = () => {
}
}, [uninstallSuccess])
const fetchStatus = useCallback(async () => {
try {
const status = await window.api.openclaw.getStatus()
dispatch(setGatewayStatus(status.status as GatewayStatus))
} catch (err) {
logger.debug('Failed to fetch status', err as Error)
}
}, [dispatch])
// Poll gateway status every 5s (only when installed).
// useSWR handles deduplication and stable polling without the infinite-loop
// pitfall of setInterval + dispatch in useEffect deps.
const isInstallPage = pageState === 'installed'
const fetchHealth = useCallback(async () => {
try {
useSWR(
isInstallPage ? 'openclaw/status' : null,
async () => {
const [status] = await Promise.all([window.api.openclaw.getStatus(), checkInstallation()])
dispatch(setGatewayStatus(status.status as GatewayStatus))
return status
},
{ refreshInterval: 5000, revalidateOnFocus: false }
)
useSWR(
isInstallPage && gatewayStatus === 'running' ? 'openclaw/health' : null,
async () => {
const health = await window.api.openclaw.checkHealth()
dispatch(setLastHealthCheck(health as HealthInfo))
} catch (err) {
logger.debug('Failed to check health', err as Error)
}
}, [dispatch])
return health
},
{ refreshInterval: 5000, revalidateOnFocus: false }
)
useEffect(() => {
checkInstallation()
@@ -221,23 +229,6 @@ const OpenClawPage: FC = () => {
return cleanup
}, [])
useEffect(() => {
if (pageState !== 'installed') return
fetchStatus()
if (gatewayStatus === 'running') {
fetchHealth()
}
const interval = setInterval(() => {
checkInstallation()
fetchStatus()
if (gatewayStatus === 'running') {
fetchHealth()
}
}, 5000)
return () => clearInterval(interval)
}, [fetchStatus, fetchHealth, checkInstallation, gatewayStatus, pageState])
const handleModelSelect = (modelUniqId: string) => {
dispatch(setSelectedModelUniqId(modelUniqId))
}