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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-07 16:50:20 +08:00
parent 2dd8ee4a33
commit 8b07c16f45
2 changed files with 91 additions and 1 deletions

View File

@ -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<number> {
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<void> {
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', () => {

View File

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