fix: show desktop server startup diagnostics

This commit is contained in:
Relakkes Yang 2026-04-27 10:40:50 +08:00
parent 139dcbc4c7
commit 2961a49fc3
6 changed files with 208 additions and 17 deletions

View File

@ -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<ServerStatus>);
@ -546,6 +548,32 @@ fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> {
))
}
fn push_server_startup_log(logs: &Arc<Mutex<VecDeque<String>>>, 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<Mutex<VecDeque<String>>>) -> String {
let log_text = logs
.lock()
.ok()
.map(|guard| guard.iter().cloned().collect::<Vec<_>>().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<PathBuf, String> {
// 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上
// 的 src/server/index.ts 和 preload.ts所以 Tauri 必须把整个 src/ +
@ -589,6 +617,9 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
&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<ServerRuntime, String> {
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 })
}

View File

@ -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 (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<div className="max-w-xl rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6">
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('app.serverFailed')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{startupError}
</p>
</div>
</div>
)
return <StartupErrorView error={startupError} />
}
if (!ready) {

View File

@ -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(
<StartupErrorView error={'startup failed\n\nRecent server logs:\n[stderr] boom'} />,
)
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()
})
})

View File

@ -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 (
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
<section className="w-full max-w-3xl rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
{t('app.serverFailed')}
</h1>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
{t('app.serverFailedHint')}
</p>
</div>
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('app.startupError')}
</div>
<pre className="mt-2 max-h-28 overflow-auto whitespace-pre-wrap break-words font-mono text-xs text-[var(--color-error)]">
{message}
</pre>
</div>
{logs ? (
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
<div className="text-xs font-medium uppercase text-[var(--color-text-tertiary)]">
{t('app.serverLogs')}
</div>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-[var(--color-text-secondary)]">
{logs}
</pre>
</div>
) : null}
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
icon={<Copy className="h-4 w-4" aria-hidden="true" />}
onClick={handleCopy}
>
{copied ? t('app.copiedDiagnostics') : t('app.copyDiagnostics')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
icon={<RefreshCw className="h-4 w-4" aria-hidden="true" />}
onClick={() => window.location.reload()}
>
{t('common.retry')}
</Button>
</div>
</div>
</section>
</div>
)
}

View File

@ -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 ──────────────────────────────────────

View File

@ -852,6 +852,11 @@ export const zh: Record<TranslationKey, string> = {
// ─── 应用外壳 ──────────────────────────────────────
'app.serverFailed': '本地服务启动失败',
'app.serverFailedHint': '反馈这个问题时,请把这块诊断信息截图一起发到 issue 里。',
'app.startupError': '启动错误',
'app.serverLogs': '服务日志',
'app.copyDiagnostics': '复制诊断信息',
'app.copiedDiagnostics': '已复制',
'app.launching': '正在启动本地工作区...',
// ─── Error Codes ──────────────────────────────────────