mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:20:43 +00:00
fix(gateway): ignore broken pipe crashes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user