From d2b0ff808a5976c67294bf034c3182300d9f709f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 20:16:33 +0100 Subject: [PATCH] fix(gateway): ignore broken pipe crashes --- CHANGELOG.md | 1 + src/cli/run-main.exit.test.ts | 36 ++++++++++++++++++++++++++ src/cli/run-main.ts | 13 +++++++++- src/index.ts | 8 ++++++ src/infra/unhandled-rejections.test.ts | 12 +++++++++ src/infra/unhandled-rejections.ts | 12 +++++++++ 6 files changed, 81 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a077cfe69a4..90127a318c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - CLI/update: install npm global updates into a verified temporary prefix before swapping the package tree into place, preventing mixed old/new installs and stale packaged files from breaking `openclaw update` verification. Thanks @shakkernerd. - Gateway: skip CLI startup self-respawn for foreground gateway runs so low-memory Linux/Node 24 hosts start through the same path as direct `dist/index.js` without hanging before logs. Fixes #72720. Thanks @sign-2025. - Google Meet: grant Meet media permissions through browser control and pin local Chrome audio defaults to `BlackHole 2ch`, so joined agents no longer show `Permission needed` or use macOS default audio devices. Thanks @DougButdorf. +- Gateway: treat uncaught broken-pipe stream errors like `EPIPE` as non-fatal so Discord delivery or closed pipes no longer crash the Gateway after a reply is ready. - Google Meet: route local Chrome joins through OpenClaw browser control instead of raw default Chrome, so agents use the configured OpenClaw browser profile when opening Meet. Thanks @oromeis. - Plugins/discovery: follow symlinked plugin directories in global and workspace plugin roots while keeping broken links ignored and existing package safety checks in place. Fixes #36754; carries forward #72695 and #63206. Thanks @Quackstro, @ming1523, and @xsfX20. - Plugins/install: skip test files and directories during install security scans while still force-scanning declared runtime entrypoints, so packaged test mocks no longer block plugin installs. Fixes #66840; carries forward #67050. Thanks @saurabhjain1592 and @Magicray1217. diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 2a8d546cb69..de39f6d1a2f 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -356,4 +356,40 @@ describe("runCli exit behavior", () => { processOnSpy.mockRestore(); } }); + + it("does not exit for transient uncaught CLI exceptions", async () => { + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => "status" }], + parseAsync: vi.fn().mockResolvedValueOnce(undefined), + }); + + const processOnSpy = vi.spyOn(process, "on"); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + await runCli(["node", "openclaw", "status"]); + + const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1]; + expect(typeof handler).toBe("function"); + + try { + const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + expect(() => (handler as (error: unknown) => void)(epipe)).not.toThrow(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[openclaw] Non-fatal uncaught exception (continuing):", + expect.stringContaining("write EPIPE"), + ); + expect(restoreTerminalStateMock).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + if (typeof handler === "function") { + process.off("uncaughtException", handler); + } + consoleWarnSpy.mockRestore(); + exitSpy.mockRestore(); + processOnSpy.mockRestore(); + } + }); }); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index a2adac4af26..5dee18402c3 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -247,7 +247,11 @@ export async function runCli(argv: string[] = process.argv) { { buildProgram }, { formatUncaughtError }, { runFatalErrorHooks }, - { installUnhandledRejectionHandler, isUncaughtExceptionHandled }, + { + installUnhandledRejectionHandler, + isBenignUncaughtExceptionError, + isUncaughtExceptionHandled, + }, { restoreTerminalState }, ] = await Promise.all([ import("./program.js"), @@ -266,6 +270,13 @@ export async function runCli(argv: string[] = process.argv) { if (isUncaughtExceptionHandled(error)) { return; } + if (isBenignUncaughtExceptionError(error)) { + console.warn( + "[openclaw] Non-fatal uncaught exception (continuing):", + formatUncaughtError(error), + ); + return; + } console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); diff --git a/src/index.ts b/src/index.ts index 36c34a70883..a5fb0dd36cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { runFatalErrorHooks } from "./infra/fatal-error-hooks.js"; import { isMainModule } from "./infra/is-main.js"; import { installUnhandledRejectionHandler, + isBenignUncaughtExceptionError, isUncaughtExceptionHandled, } from "./infra/unhandled-rejections.js"; @@ -92,6 +93,13 @@ if (isMain) { if (isUncaughtExceptionHandled(error)) { return; } + if (isBenignUncaughtExceptionError(error)) { + console.warn( + "[openclaw] Non-fatal uncaught exception (continuing):", + formatUncaughtError(error), + ); + return; + } console.error("[openclaw] Uncaught exception:", formatUncaughtError(error)); for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index d58f07acb9a..eb8e9f3cb1b 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { isAbortError, + isBenignUncaughtExceptionError, isTransientNetworkError, isTransientSqliteError, isTransientUnhandledRejectionError, @@ -258,6 +259,17 @@ describe("isTransientSqliteError", () => { }); describe("isTransientUnhandledRejectionError", () => { + it("keeps uncaught exception suppression scoped to broken pipes", () => { + const epipe = Object.assign(new Error("write EPIPE"), { code: "EPIPE" }); + const sqlite = Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }); + const network = Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); + const generic = new Error("boom"); + + expect(isBenignUncaughtExceptionError(epipe)).toBe(true); + expect(isBenignUncaughtExceptionError(sqlite)).toBe(false); + expect(isBenignUncaughtExceptionError(network)).toBe(false); + expect(isBenignUncaughtExceptionError(generic)).toBe(false); + }); it("returns true for transient SQLite errors", () => { const error = Object.assign(new Error("unable to open database file"), { code: "ERR_SQLITE_ERROR", diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 219fda7a10f..593a9fdd563 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -88,6 +88,8 @@ const TRANSIENT_SQLITE_CODES = new Set([ const TRANSIENT_SQLITE_ERRCODES = new Set([5, 6, 10, 14]); +const BENIGN_UNCAUGHT_EXCEPTION_CODES = new Set(["EPIPE", "EIO"]); + const TRANSIENT_NETWORK_MESSAGE_CODE_RE = /\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|EPROTO|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|UND_ERR_SOCKET|UND_ERR_HEADERS_TIMEOUT|UND_ERR_BODY_TIMEOUT)\b/i; @@ -339,6 +341,16 @@ export function isTransientUnhandledRejectionError(err: unknown): boolean { return isTransientNetworkError(err) || isTransientSqliteError(err); } +export function isBenignUncaughtExceptionError(err: unknown): boolean { + for (const candidate of collectNestedUnhandledErrorCandidates(err)) { + const code = extractErrorCodeOrErrno(candidate); + if (code && BENIGN_UNCAUGHT_EXCEPTION_CODES.has(code)) { + return true; + } + } + return false; +} + export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void { handlers.add(handler); return () => {