mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
2dd8ee4a33
commit
8b07c16f45
@ -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', () => {
|
||||
|
||||
@ -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}` : ''}`,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user