From 2961a49fc3b81a4acb2dcbceaeb5ad64faa2c597 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Mon, 27 Apr 2026 10:40:50 +0800 Subject: [PATCH] fix: show desktop server startup diagnostics --- desktop/src-tauri/src/lib.rs | 56 ++++++++++- desktop/src/components/layout/AppShell.tsx | 14 +-- .../layout/StartupErrorView.test.tsx | 46 +++++++++ .../components/layout/StartupErrorView.tsx | 99 +++++++++++++++++++ desktop/src/i18n/locales/en.ts | 5 + desktop/src/i18n/locales/zh.ts | 5 + 6 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 desktop/src/components/layout/StartupErrorView.test.tsx create mode 100644 desktop/src/components/layout/StartupErrorView.tsx diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ee47e4b6..eecc9a39 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, io::{Error as IoError, ErrorKind, Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, path::PathBuf, @@ -7,7 +7,7 @@ use std::{ str, sync::{ atomic::{AtomicU32, Ordering}, - Mutex, + Arc, Mutex, }, thread, time::{Duration, Instant}, @@ -24,6 +24,8 @@ use tauri_plugin_shell::{ ShellExt, }; +const SERVER_STARTUP_LOG_LIMIT: usize = 80; + #[derive(Default)] struct ServerState(Mutex); @@ -546,6 +548,32 @@ fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> { )) } +fn push_server_startup_log(logs: &Arc>>, line: String) { + let line = line.trim_end().to_string(); + if line.is_empty() { + return; + } + + let Ok(mut guard) = logs.lock() else { + return; + }; + if guard.len() >= SERVER_STARTUP_LOG_LIMIT { + guard.pop_front(); + } + guard.push_back(line); +} + +fn format_server_startup_error(message: &str, logs: &Arc>>) -> String { + let log_text = logs + .lock() + .ok() + .map(|guard| guard.iter().cloned().collect::>().join("\n")) + .filter(|text| !text.trim().is_empty()) + .unwrap_or_else(|| "No server stdout/stderr was captured before the timeout.".to_string()); + + format!("{message}\n\nRecent server logs:\n{log_text}") +} + fn resolve_app_root(_app: &AppHandle) -> Result { // 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上 // 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ + @@ -589,6 +617,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result { &port.to_string(), ]); + let startup_logs = Arc::new(Mutex::new(VecDeque::new())); + let logs_for_task = Arc::clone(&startup_logs); + let (mut rx, child) = sidecar .spawn() .map_err(|err| format!("spawn server sidecar: {err}"))?; @@ -598,18 +629,33 @@ fn start_server_sidecar(app: &AppHandle) -> Result { match event { CommandEvent::Stdout(line) => { let line = String::from_utf8_lossy(&line); - println!("[claude-server] {}", line.trim_end()); + let line = line.trim_end(); + println!("[claude-server] {line}"); + push_server_startup_log(&logs_for_task, format!("[stdout] {line}")); } CommandEvent::Stderr(line) => { let line = String::from_utf8_lossy(&line); - eprintln!("[claude-server] {}", line.trim_end()); + let line = line.trim_end(); + eprintln!("[claude-server] {line}"); + push_server_startup_log(&logs_for_task, format!("[stderr] {line}")); + } + CommandEvent::Terminated(payload) => { + let line = format!( + "sidecar exited (code={:?}, signal={:?})", + payload.code, payload.signal + ); + eprintln!("[claude-server] {line}"); + push_server_startup_log(&logs_for_task, format!("[exit] {line}")); } _ => {} } } }); - wait_for_server(host, port)?; + if let Err(err) = wait_for_server(host, port) { + let _ = child.kill(); + return Err(format_server_startup_error(&err, &startup_logs)); + } Ok(ServerRuntime { url, child }) } diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 1d661d8a..a1af3279 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -8,6 +8,7 @@ import { useUIStore, type SettingsTab } from '../../stores/uiStore' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' import { initializeDesktopServerUrl } from '../../lib/desktopRuntime' import { TabBar } from './TabBar' +import { StartupErrorView } from './StartupErrorView' import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useTranslation } from '../../i18n' @@ -73,18 +74,7 @@ export function AppShell() { useKeyboardShortcuts() if (startupError) { - return ( -
-
-

- {t('app.serverFailed')} -

-

- {startupError} -

-
-
- ) + return } if (!ready) { diff --git a/desktop/src/components/layout/StartupErrorView.test.tsx b/desktop/src/components/layout/StartupErrorView.test.tsx new file mode 100644 index 00000000..757a63c2 --- /dev/null +++ b/desktop/src/components/layout/StartupErrorView.test.tsx @@ -0,0 +1,46 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import '@testing-library/jest-dom' +import { describe, expect, it, vi } from 'vitest' + +import { splitStartupError, StartupErrorView } from './StartupErrorView' + +describe('splitStartupError', () => { + it('separates the timeout message from captured sidecar logs', () => { + const result = splitStartupError( + 'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds\n\nRecent server logs:\n[stderr] failed to bind\n[exit] sidecar exited (code=1, signal=None)', + ) + + expect(result.message).toBe( + 'desktop server did not start listening on 127.0.0.1:57608 within 10 seconds', + ) + expect(result.logs).toContain('[stderr] failed to bind') + expect(result.diagnostics).toContain('Recent server logs:') + }) +}) + +describe('StartupErrorView', () => { + it('shows diagnostics and copies the full payload', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }) + + render( + , + ) + + expect(screen.getByText('本地服务启动失败')).toBeInTheDocument() + expect(screen.getByText('startup failed')).toBeInTheDocument() + expect(screen.getByText('[stderr] boom')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: '复制诊断信息' })) + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith( + 'startup failed\n\nRecent server logs:\n[stderr] boom', + ) + }) + expect(screen.getByText('已复制')).toBeInTheDocument() + }) +}) diff --git a/desktop/src/components/layout/StartupErrorView.tsx b/desktop/src/components/layout/StartupErrorView.tsx new file mode 100644 index 00000000..7238fcfa --- /dev/null +++ b/desktop/src/components/layout/StartupErrorView.tsx @@ -0,0 +1,99 @@ +import { Copy, RefreshCw } from 'lucide-react' +import { useMemo, useState } from 'react' +import { useTranslation } from '../../i18n' +import { Button } from '../shared/Button' + +const LOG_MARKER = '\n\nRecent server logs:\n' + +export function splitStartupError(error: string) { + const markerIndex = error.indexOf(LOG_MARKER) + if (markerIndex === -1) { + return { + message: error, + logs: '', + diagnostics: error, + } + } + + const message = error.slice(0, markerIndex).trim() + const logs = error.slice(markerIndex + LOG_MARKER.length).trim() + return { + message, + logs, + diagnostics: `${message}\n\nRecent server logs:\n${logs}`, + } +} + +type StartupErrorViewProps = { + error: string +} + +export function StartupErrorView({ error }: StartupErrorViewProps) { + const t = useTranslation() + const { message, logs, diagnostics } = useMemo(() => splitStartupError(error), [error]) + const [copied, setCopied] = useState(false) + + const handleCopy = async () => { + await navigator.clipboard?.writeText(diagnostics) + setCopied(true) + window.setTimeout(() => setCopied(false), 1600) + } + + return ( +
+
+
+
+

+ {t('app.serverFailed')} +

+

+ {t('app.serverFailedHint')} +

+
+ +
+
+ {t('app.startupError')} +
+
+              {message}
+            
+
+ + {logs ? ( +
+
+ {t('app.serverLogs')} +
+
+                {logs}
+              
+
+ ) : null} + +
+ + +
+
+
+
+ ) +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1ae3b485..1df1c39e 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -850,6 +850,11 @@ export const en = { // ─── App Shell ────────────────────────────────────── 'app.serverFailed': 'Local server failed to start', + 'app.serverFailedHint': 'Please include a screenshot of this diagnostic panel when reporting the issue.', + 'app.startupError': 'Startup error', + 'app.serverLogs': 'Server logs', + 'app.copyDiagnostics': 'Copy diagnostics', + 'app.copiedDiagnostics': 'Copied', 'app.launching': 'Launching local workspace...', // ─── Error Codes ────────────────────────────────────── diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 5bf553dc..54f55deb 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -852,6 +852,11 @@ export const zh: Record = { // ─── 应用外壳 ────────────────────────────────────── 'app.serverFailed': '本地服务启动失败', + 'app.serverFailedHint': '反馈这个问题时,请把这块诊断信息截图一起发到 issue 里。', + 'app.startupError': '启动错误', + 'app.serverLogs': '服务日志', + 'app.copyDiagnostics': '复制诊断信息', + 'app.copiedDiagnostics': '已复制', 'app.launching': '正在启动本地工作区...', // ─── Error Codes ──────────────────────────────────────