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}` : ''}`,