fix(gateway): ignore broken pipe crashes

This commit is contained in:
Peter Steinberger
2026-04-27 20:16:33 +01:00
parent 3cb460873d
commit d2b0ff808a
6 changed files with 81 additions and 1 deletions

View File

@@ -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.

View File

@@ -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();
}
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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",

View File

@@ -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 () => {