diff --git a/bin/cli.ts b/bin/cli.ts index ccd97034..8ebdfe3a 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -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" @@ -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) => { diff --git a/src/__tests__/install-process-error-handlers.test.ts b/src/__tests__/install-process-error-handlers.test.ts new file mode 100644 index 00000000..59b2bf6d --- /dev/null +++ b/src/__tests__/install-process-error-handlers.test.ts @@ -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) + }) +}) diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 049c8059..c97b7a2b 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -2925,11 +2925,39 @@ export function createProxyServer(config: Partial = {}): 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 = {}): Promise { 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, diff --git a/src/proxy/types.ts b/src/proxy/types.ts index 850ca568..7b1dba34 100644 --- a/src/proxy/types.ts +++ b/src/proxy/types.ts @@ -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 {