Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,10 @@ if (args[0] === "refresh-token") {
const exec = promisify(execCallback)
const execFile = promisify(execFileCallback)

// Prevent SDK subprocess crashes from killing the proxy
process.on("uncaughtException", (err) => {
console.error(`[PROXY] Uncaught exception (recovered): ${err.message}`)
})
process.on("unhandledRejection", (reason) => {
console.error(`[PROXY] Unhandled rejection (recovered): ${reason instanceof Error ? reason.message : reason}`)
})
// Process error handlers (SDK subprocess crash recovery, socket EPIPE, etc.)
// are installed by startProxyServer when `installProcessErrorHandlers: true`
// is passed below. Library consumers can either pass the same flag or call
// `installProxyProcessErrorHandlers()` directly.

const port = parseInt(process.env.MERIDIAN_PORT ?? process.env.CLAUDE_PROXY_PORT ?? "3456", 10)
const host = process.env.MERIDIAN_HOST ?? process.env.CLAUDE_PROXY_HOST ?? "127.0.0.1"
Expand Down Expand Up @@ -190,7 +187,7 @@ export async function runCli(
enableDiskProfileDiscovery()
}

const proxy = await start({ port, host, idleTimeoutSeconds, profiles, defaultProfile, version })
const proxy = await start({ port, host, idleTimeoutSeconds, profiles, defaultProfile, version, installProcessErrorHandlers: true })

// Handle EADDRINUSE — preserve CLI behavior of exiting on port conflict
proxy.server.on("error", (error: NodeJS.ErrnoException) => {
Expand Down
62 changes: 62 additions & 0 deletions src/__tests__/install-process-error-handlers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* installProxyProcessErrorHandlers — library safety net
*
* The CLI (bin/cli.ts) has long installed uncaughtException + unhandledRejection
* handlers so that socket-level errors (EPIPE, ECONNRESET) and SDK subprocess
* crashes don't kill the proxy. Until this commit those handlers lived only in
* the CLI, so library consumers (e.g. era-code's in-process startProxyServer
* call) had no safety net — a single mid-stream EPIPE crashed the host process
* and orphaned downstream agents.
*
* This test verifies the exported helper is idempotent and actually attaches
* listeners. It is intentionally narrow: no socket simulation; that integration
* is exercised by the proxy stream tests + manual e2e.
*/

import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { installProxyProcessErrorHandlers } from "../proxy/server"

describe("installProxyProcessErrorHandlers", () => {
let originalUncaught: NodeJS.UncaughtExceptionListener[]
let originalRejection: NodeJS.UnhandledRejectionListener[]

beforeEach(() => {
originalUncaught = process.listeners("uncaughtException")
originalRejection = process.listeners("unhandledRejection")
})

afterEach(() => {
// Detach anything we may have added; restore the snapshot.
for (const listener of process.listeners("uncaughtException")) {
if (!originalUncaught.includes(listener)) {
process.off("uncaughtException", listener)
}
}
for (const listener of process.listeners("unhandledRejection")) {
if (!originalRejection.includes(listener)) {
process.off("unhandledRejection", listener)
}
}
})

it("attaches uncaughtException + unhandledRejection listeners", () => {
const beforeUncaught = process.listenerCount("uncaughtException")
const beforeRejection = process.listenerCount("unhandledRejection")

installProxyProcessErrorHandlers()

expect(process.listenerCount("uncaughtException")).toBe(beforeUncaught + 1)
expect(process.listenerCount("unhandledRejection")).toBe(beforeRejection + 1)
})

it("is idempotent — second call is a no-op", () => {
installProxyProcessErrorHandlers()
const after1Uncaught = process.listenerCount("uncaughtException")
const after1Rejection = process.listenerCount("unhandledRejection")

installProxyProcessErrorHandlers()

expect(process.listenerCount("uncaughtException")).toBe(after1Uncaught)
expect(process.listenerCount("unhandledRejection")).toBe(after1Rejection)
})
})
28 changes: 28 additions & 0 deletions src/proxy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2925,11 +2925,39 @@ export function createProxyServer(config: Partial<ProxyConfig> = {}): ProxyServe
return { app, config: finalConfig, initPlugins: initPluginsAsync }
}

/**
* Install process-level handlers that log and swallow uncaught exceptions
* and unhandled promise rejections instead of crashing the host process.
*
* Idempotent: safe to call multiple times; only the first invocation attaches
* listeners. Exported so library consumers can opt in explicitly without
* having to set `installProcessErrorHandlers: true` in `startProxyServer`.
*/
let processErrorHandlersInstalled = false
export function installProxyProcessErrorHandlers(): void {
if (processErrorHandlersInstalled) return
processErrorHandlersInstalled = true
// Prevent SDK subprocess crashes (and downstream socket EPIPE / ECONNRESET
// from aborted streaming responses) from killing the proxy. Mirrors the
// long-standing behavior of `bin/cli.ts`; lifted here so library consumers
// (e.g. era-code's in-process startProxyServer) get the same safety net.
process.on("uncaughtException", (err) => {
console.error(`[PROXY] Uncaught exception (recovered): ${err.message}`)
})
process.on("unhandledRejection", (reason) => {
console.error(`[PROXY] Unhandled rejection (recovered): ${reason instanceof Error ? reason.message : reason}`)
})
}

export async function startProxyServer(config: Partial<ProxyConfig> = {}): Promise<ProxyInstance> {
claudeExecutable = await resolveClaudeExecutableAsync()
const { app, config: finalConfig, initPlugins } = createProxyServer(config)
if (initPlugins) await initPlugins()

if (finalConfig.installProcessErrorHandlers) {
installProxyProcessErrorHandlers()
}

const server = serve({
fetch: app.fetch,
port: finalConfig.port,
Expand Down
7 changes: 7 additions & 0 deletions src/proxy/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export interface ProxyConfig {
pluginDir?: string
/** Plugin config file path. Defaults to ~/.config/meridian/plugins.json. */
pluginConfigPath?: string
/**
* Install process-level uncaughtException/unhandledRejection handlers that
* log and swallow socket-level errors (EPIPE, ECONNRESET, etc.) instead of
* crashing the host process. Defaults to false to preserve any handlers a
* library consumer has already installed; the bundled CLI passes `true`.
*/
installProcessErrorHandlers?: boolean
}

export interface ProxyInstance {
Expand Down