From 8b07c16f4592e016e068f0d4f17d8f3a7dd8af77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 7 May 2026 16:50:20 +0800 Subject: [PATCH] fix: keep startup failures visible in terminals Diagnostics should capture fatal server failures without replacing the terminal stderr signal that developers rely on during local startup. Fatal uncaught exceptions now print their stack to stderr, persist the diagnostic event, and exit non-zero so repeated server starts expose port conflicts immediately. Constraint: Startup diagnostics must still write sanitized runtime events for export bundles. Rejected: Only document checking runtime-errors.log | developers should not lose the standard stderr failure path. Confidence: high Scope-risk: narrow Directive: Do not install process-level exception handlers that swallow fatal startup errors without preserving stderr and non-zero exit behavior. Tested: bun test src/server/__tests__/diagnostics-service.test.ts Tested: bun run check:server Tested: SERVER_PORT=3456 bun run src/server/index.ts returned exit=1 and printed the port-conflict stack while diagnostics recorded the event --- .../__tests__/diagnostics-service.test.ts | 75 +++++++++++++++++++ src/server/services/diagnosticsService.ts | 17 ++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/server/__tests__/diagnostics-service.test.ts b/src/server/__tests__/diagnostics-service.test.ts index db86cd6b..cf675aab 100644 --- a/src/server/__tests__/diagnostics-service.test.ts +++ b/src/server/__tests__/diagnostics-service.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import * as fs from 'node:fs/promises' +import { createServer } from 'node:net' import * as os from 'node:os' import * as path from 'node:path' import { gunzipSync } from 'node:zlib' @@ -29,6 +30,37 @@ function makeRequest(method: string, urlStr: string): { req: Request; url: URL; return { req, url, segments } } +async function getPort(): Promise { + return await new Promise((resolve, reject) => { + const server = createServer() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Failed to allocate a local port'))) + return + } + server.close(() => resolve(address.port)) + }) + }) +} + +async function waitForHttp(url: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + let lastError = '' + while (Date.now() < deadline) { + try { + const response = await fetch(url) + if (response.ok) return + lastError = `HTTP ${response.status}` + } catch (error) { + lastError = error instanceof Error ? error.message : String(error) + } + await Bun.sleep(100) + } + throw new Error(`Timed out waiting for ${url}${lastError ? ` (${lastError})` : ''}`) +} + describe('DiagnosticsService', () => { test('writes sanitized structured events and runtime error summaries', async () => { const service = new DiagnosticsService() @@ -103,6 +135,49 @@ describe('DiagnosticsService', () => { expect(archiveText).not.toContain('sk-provider-secret') expect(archiveText).not.toContain('provider-secret') }) + + test('keeps fatal startup errors visible on stderr while recording diagnostics', async () => { + const port = await getPort() + const serverArgs = ['bun', 'run', 'src/server/index.ts', '--host', '127.0.0.1', '--port', String(port)] + const env = { + ...process.env, + CLAUDE_CONFIG_DIR: tmpDir, + } + const server = Bun.spawn(serverArgs, { + cwd: process.cwd(), + env, + stdout: 'ignore', + stderr: 'ignore', + }) + + try { + await waitForHttp(`http://127.0.0.1:${port}/health`, 10_000) + + const duplicate = Bun.spawn(serverArgs, { + cwd: process.cwd(), + env, + stdout: 'pipe', + stderr: 'pipe', + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(duplicate.stdout).text(), + new Response(duplicate.stderr).text(), + duplicate.exited, + ]) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toContain('[Server] Uncaught exception:') + expect(stderr).toContain(`Failed to start server. Is port ${port} in use?`) + + const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'diagnostics', 'diagnostics.jsonl'), 'utf-8') + expect(raw).toContain('server_uncaught_exception') + expect(raw).toContain(`Failed to start server. Is port ${port} in use?`) + } finally { + server.kill() + await server.exited.catch(() => undefined) + } + }) }) describe('diagnostics API', () => { diff --git a/src/server/services/diagnosticsService.ts b/src/server/services/diagnosticsService.ts index c6b4b229..f3c3c356 100644 --- a/src/server/services/diagnosticsService.ts +++ b/src/server/services/diagnosticsService.ts @@ -140,15 +140,19 @@ export class DiagnosticsService { this.processCaptureInstalled = true process.on('uncaughtException', (error) => { + this.writeProcessFailureToStderr('Uncaught exception', error) + const fallbackExit = setTimeout(() => process.exit(1), 1000) + fallbackExit.unref?.() void this.recordEvent({ type: 'server_uncaught_exception', severity: 'error', summary: error.message || 'Uncaught exception', details: { error }, - }) + }).finally(() => process.exit(1)) }) process.on('unhandledRejection', (reason) => { + this.writeProcessFailureToStderr('Unhandled rejection', reason) void this.recordEvent({ type: 'server_unhandled_rejection', severity: 'error', @@ -354,6 +358,17 @@ export class DiagnosticsService { } } + private writeProcessFailureToStderr(label: string, reason: unknown): void { + if (reason instanceof Error && reason.stack) { + process.stderr.write(`[Server] ${label}:\n${reason.stack}\n`) + return + } + const summary = reason instanceof Error + ? `${reason.name}: ${reason.message}` + : this.formatUnknownReason(reason) + process.stderr.write(`[Server] ${label}: ${summary}\n`) + } + private formatRuntimeLogEntry(event: DiagnosticEvent): string { const lines = [ `[${event.timestamp}] ${event.severity.toUpperCase()} ${event.type}${event.sessionId ? ` session=${event.sessionId}` : ''}`,