diff --git a/desktop/src/main/python-manager.ts b/desktop/src/main/python-manager.ts index 0cfe1884..33857f20 100644 --- a/desktop/src/main/python-manager.ts +++ b/desktop/src/main/python-manager.ts @@ -187,19 +187,17 @@ export class PythonBackend extends EventEmitter { private waitForReady(): Promise { return new Promise((resolve) => { - const maxAttempts = 120 - let attempts = 0 + // Wall-clock deadline rather than an attempt counter: if the machine + // sleeps/suspends, the 1s timers stretch out and a counter would give up + // far too early. Time-based bounding tracks real elapsed time instead. + const timeoutMs = 120_000 + const startedAt = Date.now() const check = () => { - attempts++ - if (attempts % 10 === 0) { - this.emit('log', `Waiting for backend... (${attempts}s)`) - } - const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => { if (res.statusCode === 200) { this.status = 'ready' - this.emit('log', `Backend ready on port ${this.port} after ${attempts}s`) + this.emit('log', `Backend ready on port ${this.port}`) this.emit('ready', this.port) resolve() } else { @@ -215,13 +213,13 @@ export class PythonBackend extends EventEmitter { } const retry = () => { - if (this.status === 'stopped') { + if (this.status === 'stopped' || this.status === 'ready') { resolve() return } - if (attempts >= maxAttempts) { + if (Date.now() - startedAt >= timeoutMs) { this.status = 'error' - this.emit('error', `Backend failed to start within ${maxAttempts} seconds`) + this.emit('error', `Backend failed to start within ${Math.round(timeoutMs / 1000)} seconds`) resolve() return } diff --git a/desktop/src/renderer/src/hooks/useBackend.ts b/desktop/src/renderer/src/hooks/useBackend.ts index 5d002567..146ee071 100644 --- a/desktop/src/renderer/src/hooks/useBackend.ts +++ b/desktop/src/renderer/src/hooks/useBackend.ts @@ -24,29 +24,46 @@ export function useBackend() { } }, []) + // True once the backend has answered at least once. After this we never flip + // back to "error" from polling — a hidden/backgrounded window throttles JS + // timers, so attempt counters are unreliable and would otherwise produce a + // false "failed to start" even though the backend is alive. + const readyRef = useRef(false) + // Holds the latest resolved port so the visibility handler (registered once) + // always probes the correct port without re-running the effect. + const portRef = useRef(9899) + useEffect(() => { let cancelled = false let offStatus: (() => void) | undefined const api = window.electronAPI + // Use a wall-clock deadline instead of an attempt counter so timer + // throttling (when the window is in the background) can't fast-forward us + // into a false failure. Only give up if we genuinely can't reach the + // backend for this long. const startPolling = async (port: number) => { - let attempts = 0 - const maxAttempts = 90 + portRef.current = port + const deadline = Date.now() + 90_000 const poll = async () => { if (cancelled) return - attempts++ const ready = await probeBackend(port) if (cancelled) return if (ready) { + readyRef.current = true setState({ status: 'ready', port }) return } - if (attempts >= maxAttempts) { - setState({ status: 'error', port, error: 'Backend failed to start. Check if Python and dependencies are installed.' }) + // Backend already answered before but is briefly unreachable (e.g. + // window was asleep): keep retrying, never surface an error. + if (!readyRef.current && Date.now() >= deadline) { + // Leave error undefined so StatusScreen shows the localized, + // user-friendly message instead of a raw technical string. + setState({ status: 'error', port }) return } @@ -59,31 +76,51 @@ export function useBackend() { if (api) { api.getBackendPort().then((port) => { const p = port || 9899 + portRef.current = p setState((prev) => ({ ...prev, port: p })) startPolling(p) }) offStatus = api.onBackendStatus((data) => { if (data.status === 'ready' && data.port) { + readyRef.current = true + portRef.current = data.port setState({ status: 'ready', port: data.port }) if (pollingRef.current) { clearTimeout(pollingRef.current) pollingRef.current = null } - } else if (data.status === 'error') { - setState((prev) => ({ ...prev, status: 'error', error: data.error })) + } else if (data.status === 'error' && !readyRef.current) { + // Ignore late "error" from the main process once we've been ready — + // it usually means the window was backgrounded, not a real failure. + // Drop the raw technical message; StatusScreen shows a localized one. + setState((prev) => ({ ...prev, status: 'error' })) } }) } else { startPolling(9899) } + // When the window comes back to the foreground, re-probe immediately so a + // user returning after a while sees the real (ready) state right away + // instead of waiting for the throttled timer to catch up. + const onVisible = () => { + if (cancelled || document.visibilityState !== 'visible') return + probeBackend(portRef.current).then((ready) => { + if (cancelled || !ready) return + readyRef.current = true + setState((prev) => ({ ...prev, status: 'ready' })) + }) + } + document.addEventListener('visibilitychange', onVisible) + return () => { cancelled = true if (pollingRef.current) { clearTimeout(pollingRef.current) } offStatus?.() + document.removeEventListener('visibilitychange', onVisible) } }, [probeBackend]) diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts index 3aa79317..3beb4ba3 100644 --- a/desktop/src/renderer/src/i18n.ts +++ b/desktop/src/renderer/src/i18n.ts @@ -290,9 +290,9 @@ const translations: Record> = { logs_live: '实时', logs_connecting: '日志流将在连接后显示...', status_starting: '正在启动 CowAgent...', - status_starting_desc: '正在初始化后端服务,请稍候', - status_error: '启动失败', - status_error_desc: '无法连接到后端服务', + status_starting_desc: '正在初始化客户端,请稍候', + status_error: '初始化失败', + status_error_desc: '客户端初始化失败,请重试', status_retry: '重试', }, en: { @@ -586,9 +586,9 @@ const translations: Record> = { logs_live: 'Live', logs_connecting: 'Log streaming will connect shortly...', status_starting: 'Starting CowAgent...', - status_starting_desc: 'Initializing the backend service, please wait', - status_error: 'Failed to Start', - status_error_desc: 'Unable to connect to the backend service', + status_starting_desc: 'Initializing the client, please wait', + status_error: 'Initialization Failed', + status_error_desc: 'Failed to initialize the client, please retry', status_retry: 'Retry', }, }