diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a9b8c0778..c0ddc492567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Bonjour: suppress known @homebridge/ciao cancellation and network assertion failures through scoped process handlers so malformed mDNS packets or restricted VPS networking disable/restart Bonjour instead of crashing the gateway. Fixes #67578. Thanks @zenassist26-create. - Discord: keep late clicks on already-resolved exec approval buttons quiet when elevated mode auto-resolved the request, while still surfacing real approval submission failures. Fixes #66906. Thanks @rlerikse. ## 2026.4.25 diff --git a/extensions/bonjour/index.ts b/extensions/bonjour/index.ts index ba39e0f874e..0547a832f55 100644 --- a/extensions/bonjour/index.ts +++ b/extensions/bonjour/index.ts @@ -1,5 +1,8 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime"; +import { + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, +} from "openclaw/plugin-sdk/runtime"; import { startGatewayBonjourAdvertiser } from "./src/advertiser.js"; function formatBonjourInstanceName(displayName: string) { @@ -33,7 +36,11 @@ export default definePluginEntry({ cliPath: ctx.cliPath, minimal: ctx.minimal, }, - { logger: api.logger, registerUnhandledRejectionHandler }, + { + logger: api.logger, + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, + }, ); return { stop: advertiser.stop }; }, diff --git a/extensions/bonjour/src/advertiser.test.ts b/extensions/bonjour/src/advertiser.test.ts index bd59c3ac69c..9f13037c7ad 100644 --- a/extensions/bonjour/src/advertiser.test.ts +++ b/extensions/bonjour/src/advertiser.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ createService: vi.fn(), getResponder: vi.fn(), shutdown: vi.fn(), + registerUncaughtExceptionHandler: vi.fn(), registerUnhandledRejectionHandler: vi.fn(), logger: { info: vi.fn(), @@ -12,7 +13,14 @@ const mocks = vi.hoisted(() => ({ debug: vi.fn(), }, })); -const { createService, getResponder, shutdown, registerUnhandledRejectionHandler, logger } = mocks; +const { + createService, + getResponder, + shutdown, + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, + logger, +} = mocks; const asString = (value: unknown, fallback: string) => typeof value === "string" && value.trim() ? value : fallback; @@ -77,6 +85,7 @@ const startAdvertiser = ( ): ReturnType => startGatewayBonjourAdvertiser(opts, { logger, + registerUncaughtExceptionHandler: (handler) => registerUncaughtExceptionHandler(handler), registerUnhandledRejectionHandler: (handler) => registerUnhandledRejectionHandler(handler), }); @@ -103,6 +112,7 @@ describe("gateway bonjour advertiser", () => { createService.mockClear(); getResponder.mockReset(); shutdown.mockClear(); + registerUncaughtExceptionHandler.mockClear(); registerUnhandledRejectionHandler.mockClear(); logger.info.mockClear(); logger.warn.mockClear(); @@ -220,7 +230,7 @@ describe("gateway bonjour advertiser", () => { await started.stop(); }); - it("does not install a process-level unhandled rejection handler by default", async () => { + it("does not install process-level ciao handlers by default", async () => { enableAdvertiserUnitMode(); const destroy = vi.fn().mockResolvedValue(undefined); @@ -237,11 +247,12 @@ describe("gateway bonjour advertiser", () => { ); expect(processOn).not.toHaveBeenCalledWith("unhandledRejection", expect.any(Function)); + expect(processOn).not.toHaveBeenCalledWith("uncaughtException", expect.any(Function)); await started.stop(); }); - it("cleans up unhandled rejection handler after shutdown", async () => { + it("cleans up ciao process handlers after shutdown", async () => { enableAdvertiserUnitMode(); const destroy = vi.fn().mockResolvedValue(undefined); @@ -252,10 +263,14 @@ describe("gateway bonjour advertiser", () => { }); mockCiaoService({ advertise, destroy }); - const cleanup = vi.fn(() => { - order.push("cleanup"); + const cleanupException = vi.fn(() => { + order.push("cleanup-exception"); }); - registerUnhandledRejectionHandler.mockImplementation(() => cleanup); + const cleanupRejection = vi.fn(() => { + order.push("cleanup-rejection"); + }); + registerUncaughtExceptionHandler.mockImplementation(() => cleanupException); + registerUnhandledRejectionHandler.mockImplementation(() => cleanupRejection); const started = await startAdvertiser({ gatewayPort: 18789, @@ -264,9 +279,11 @@ describe("gateway bonjour advertiser", () => { await started.stop(); + expect(registerUncaughtExceptionHandler).toHaveBeenCalledTimes(1); expect(registerUnhandledRejectionHandler).toHaveBeenCalledTimes(1); - expect(cleanup).toHaveBeenCalledTimes(1); - expect(order).toEqual(["shutdown", "cleanup"]); + expect(cleanupException).toHaveBeenCalledTimes(1); + expect(cleanupRejection).toHaveBeenCalledTimes(1); + expect(order).toEqual(["shutdown", "cleanup-exception", "cleanup-rejection"]); }); it("logs ciao handler classifications at the bonjour caller", async () => { @@ -284,7 +301,11 @@ describe("gateway bonjour advertiser", () => { const handler = registerUnhandledRejectionHandler.mock.calls[0]?.[0] as | ((reason: unknown) => boolean) | undefined; + const exceptionHandler = registerUncaughtExceptionHandler.mock.calls[0]?.[0] as + | ((reason: unknown) => boolean) + | undefined; expect(handler).toBeTypeOf("function"); + expect(exceptionHandler).toBeTypeOf("function"); expect(handler?.(new Error("CIAO PROBING CANCELLED"))).toBe(true); expect(logger.debug).toHaveBeenCalledWith( @@ -299,6 +320,21 @@ describe("gateway bonjour advertiser", () => { expect.stringContaining("suppressing ciao interface assertion"), ); + logger.warn.mockClear(); + expect( + exceptionHandler?.( + Object.assign( + new Error( + "IP address version must match. Netmask cannot have a version different from the address!", + ), + { name: "AssertionError" }, + ), + ), + ).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining("suppressing ciao netmask assertion"), + ); + await started.stop(); }); diff --git a/extensions/bonjour/src/advertiser.ts b/extensions/bonjour/src/advertiser.ts index efa5a41a498..752e3a21028 100644 --- a/extensions/bonjour/src/advertiser.ts +++ b/extensions/bonjour/src/advertiser.ts @@ -1,6 +1,6 @@ import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry"; import { isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; -import { classifyCiaoUnhandledRejection } from "./ciao.js"; +import { classifyCiaoProcessError, type CiaoProcessErrorClassification } from "./ciao.js"; import { formatBonjourError } from "./errors.js"; export type GatewayBonjourAdvertiser = { @@ -50,6 +50,7 @@ type CiaoModule = { type BonjourCycle = { responder: BonjourResponder; services: Array<{ label: string; svc: BonjourService }>; + cleanupUncaughtException?: () => void; cleanupUnhandledRejection?: () => void; }; @@ -59,10 +60,12 @@ type ServiceStateTracker = { }; type ConsoleLogFn = (...args: unknown[]) => void; +type UncaughtExceptionHandler = (error: unknown) => boolean; type UnhandledRejectionHandler = (reason: unknown) => boolean; type BonjourAdvertiserDeps = { logger?: Pick; + registerUncaughtExceptionHandler?: (handler: UncaughtExceptionHandler) => () => void; registerUnhandledRejectionHandler?: (handler: UnhandledRejectionHandler) => () => void; }; @@ -175,19 +178,22 @@ export async function startGatewayBonjourAdvertiser( }; const { getResponder, Protocol } = await loadCiaoModule(); const restoreConsoleLog = installCiaoConsoleNoiseFilter(); + let requestCiaoRecovery: ((classification: CiaoProcessErrorClassification) => void) | undefined; - const handleCiaoUnhandledRejection = (reason: unknown): boolean => { - const classification = classifyCiaoUnhandledRejection(reason); + const handleCiaoProcessError = (reason: unknown): boolean => { + const classification = classifyCiaoProcessError(reason); if (!classification) { return false; } - if (classification.kind === "interface-assertion") { - logger.warn(`bonjour: suppressing ciao interface assertion: ${classification.formatted}`); - return true; + if (classification.kind === "cancellation") { + logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); + } else { + const label = + classification.kind === "netmask-assertion" ? "netmask assertion" : "interface assertion"; + logger.warn(`bonjour: suppressing ciao ${label}: ${classification.formatted}`); + requestCiaoRecovery?.(classification); } - - logger.debug(`bonjour: ignoring unhandled ciao rejection: ${classification.formatted}`); return true; }; @@ -255,10 +261,14 @@ export async function startGatewayBonjourAdvertiser( const cleanupUnhandledRejection = services.length > 0 && deps.registerUnhandledRejectionHandler - ? deps.registerUnhandledRejectionHandler(handleCiaoUnhandledRejection) + ? deps.registerUnhandledRejectionHandler(handleCiaoProcessError) + : undefined; + const cleanupUncaughtException = + services.length > 0 && deps.registerUncaughtExceptionHandler + ? deps.registerUncaughtExceptionHandler(handleCiaoProcessError) : undefined; - return { responder, services, cleanupUnhandledRejection }; + return { responder, services, cleanupUncaughtException, cleanupUnhandledRejection }; } async function stopCycle(cycle: BonjourCycle | null, opts?: { shutdownResponder?: boolean }) { @@ -279,6 +289,7 @@ export async function startGatewayBonjourAdvertiser( } catch { /* ignore */ } finally { + cycle.cleanupUncaughtException?.(); cycle.cleanupUnhandledRejection?.(); } } @@ -388,6 +399,9 @@ export async function startGatewayBonjourAdvertiser( }); return recreatePromise; }; + requestCiaoRecovery = (classification) => { + void recreateAdvertiser(`ciao ${classification.kind}: ${classification.formatted}`); + }; const lastRepairAttempt = new Map(); const watchdog = setInterval(() => { diff --git a/extensions/bonjour/src/ciao.test.ts b/extensions/bonjour/src/ciao.test.ts index ce2299ae26d..6d40787331c 100644 --- a/extensions/bonjour/src/ciao.test.ts +++ b/extensions/bonjour/src/ciao.test.ts @@ -21,6 +21,23 @@ describe("bonjour-ciao", () => { }); }); + it("classifies ciao netmask assertions separately from side effects", () => { + expect( + classifyCiaoUnhandledRejection( + Object.assign( + new Error( + "IP address version must match. Netmask cannot have a version different from the address!", + ), + { name: "AssertionError" }, + ), + ), + ).toEqual({ + kind: "netmask-assertion", + formatted: + "AssertionError: IP address version must match. Netmask cannot have a version different from the address!", + }); + }); + it("suppresses ciao announcement cancellation rejections", () => { expect(ignoreCiaoUnhandledRejection(new Error("Ciao announcement cancelled by shutdown"))).toBe( true, @@ -44,6 +61,17 @@ describe("bonjour-ciao", () => { expect(ignoreCiaoUnhandledRejection(error)).toBe(true); }); + it("suppresses ciao netmask assertion errors as non-fatal", () => { + const error = Object.assign( + new Error( + "IP address version must match. Netmask cannot have a version different from the address!", + ), + { name: "AssertionError" }, + ); + + expect(ignoreCiaoUnhandledRejection(error)).toBe(true); + }); + it("keeps unrelated rejections visible", () => { expect(ignoreCiaoUnhandledRejection(new Error("boom"))).toBe(false); }); diff --git a/extensions/bonjour/src/ciao.ts b/extensions/bonjour/src/ciao.ts index 013aace0722..d8a9a4a5c0c 100644 --- a/extensions/bonjour/src/ciao.ts +++ b/extensions/bonjour/src/ciao.ts @@ -2,15 +2,16 @@ import { formatBonjourError } from "./errors.js"; const CIAO_CANCELLATION_MESSAGE_RE = /^CIAO (?:ANNOUNCEMENT|PROBING) CANCELLED\b/u; const CIAO_INTERFACE_ASSERTION_MESSAGE_RE = - /REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGE FROM DEFINED TO UNDEFINED!?/u; + /REACHED ILLEGAL STATE!?\s+IPV4 ADDRESS CHANGE FROM (?:DEFINED TO UNDEFINED|UNDEFINED TO DEFINED)!?/u; +const CIAO_NETMASK_ASSERTION_MESSAGE_RE = + /IP ADDRESS VERSION MUST MATCH\.\s+NETMASK CANNOT HAVE A VERSION DIFFERENT FROM THE ADDRESS!?/u; -export type CiaoUnhandledRejectionClassification = +export type CiaoProcessErrorClassification = | { kind: "cancellation"; formatted: string } - | { kind: "interface-assertion"; formatted: string }; + | { kind: "interface-assertion"; formatted: string } + | { kind: "netmask-assertion"; formatted: string }; -export function classifyCiaoUnhandledRejection( - reason: unknown, -): CiaoUnhandledRejectionClassification | null { +export function classifyCiaoProcessError(reason: unknown): CiaoProcessErrorClassification | null { const formatted = formatBonjourError(reason); const message = formatted.toUpperCase(); if (CIAO_CANCELLATION_MESSAGE_RE.test(message)) { @@ -19,9 +20,14 @@ export function classifyCiaoUnhandledRejection( if (CIAO_INTERFACE_ASSERTION_MESSAGE_RE.test(message)) { return { kind: "interface-assertion", formatted }; } + if (CIAO_NETMASK_ASSERTION_MESSAGE_RE.test(message)) { + return { kind: "netmask-assertion", formatted }; + } return null; } +export const classifyCiaoUnhandledRejection = classifyCiaoProcessError; + export function ignoreCiaoUnhandledRejection(reason: unknown): boolean { - return classifyCiaoUnhandledRejection(reason) !== null; + return classifyCiaoProcessError(reason) !== null; } diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 5fd302ad999..f639b8a4e71 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -309,7 +309,7 @@ export async function runCli(argv: string[] = process.argv) { const [ { buildProgram }, { runFatalErrorHooks }, - { installUnhandledRejectionHandler }, + { installUnhandledRejectionHandler, isUncaughtExceptionHandled }, { restoreTerminalState }, ] = await Promise.all([ import("./program.js"), @@ -324,6 +324,9 @@ export async function runCli(argv: string[] = process.argv) { installUnhandledRejectionHandler(); process.on("uncaughtException", (error) => { + if (isUncaughtExceptionHandled(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 43e9a414e1a..36c34a70883 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,10 @@ import { fileURLToPath } from "node:url"; import { formatUncaughtError } from "./infra/errors.js"; import { runFatalErrorHooks } from "./infra/fatal-error-hooks.js"; import { isMainModule } from "./infra/is-main.js"; -import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js"; +import { + installUnhandledRejectionHandler, + isUncaughtExceptionHandled, +} from "./infra/unhandled-rejections.js"; type LegacyCliDeps = { runCli: (argv: string[]) => Promise; @@ -86,6 +89,9 @@ if (isMain) { installUnhandledRejectionHandler(); process.on("uncaughtException", (error) => { + if (isUncaughtExceptionHandled(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.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index 3a0d15ef4d0..f010dfbbd52 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -8,7 +8,11 @@ vi.mock("../terminal/restore.js", () => ({ })); import { resetFatalErrorHooksForTest } from "./fatal-error-hooks.js"; -import { installUnhandledRejectionHandler } from "./unhandled-rejections.js"; +import { + installUnhandledRejectionHandler, + isUncaughtExceptionHandled, + registerUncaughtExceptionHandler, +} from "./unhandled-rejections.js"; describe("installUnhandledRejectionHandler - fatal detection", () => { let exitCalls: Array = []; @@ -91,6 +95,20 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { }); }); + describe("scoped uncaught exception handlers", () => { + it("lets registered handlers suppress known dependency exceptions", () => { + const cleanup = registerUncaughtExceptionHandler((error) => { + return error instanceof Error && error.message === "known dependency assertion"; + }); + + expect(isUncaughtExceptionHandled(new Error("known dependency assertion"))).toBe(true); + expect(isUncaughtExceptionHandled(new Error("unknown"))).toBe(false); + + cleanup(); + expect(isUncaughtExceptionHandled(new Error("known dependency assertion"))).toBe(false); + }); + }); + describe("configuration errors", () => { it("exits on configuration error codes", () => { const configurationCases = [ diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index ac26f490e7e..219fda7a10f 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -10,11 +10,13 @@ import { import { runFatalErrorHooks } from "./fatal-error-hooks.js"; type UnhandledRejectionHandler = (reason: unknown) => boolean; +type UncaughtExceptionHandler = (error: unknown) => boolean; // Plugins resolve `openclaw/plugin-sdk/runtime` through their own staged // `node_modules`, which loads a separate copy of this module. To keep registry // state shared across instances, anchor the handlers Set on globalThis. const HANDLERS_GLOBAL_KEY = Symbol.for("openclaw.unhandledRejection.handlers"); +const EXCEPTION_HANDLERS_GLOBAL_KEY = Symbol.for("openclaw.uncaughtException.handlers"); const handlers: Set = (() => { const g = globalThis as unknown as Record>; const existing = g[HANDLERS_GLOBAL_KEY]; @@ -25,6 +27,16 @@ const handlers: Set = (() => { g[HANDLERS_GLOBAL_KEY] = created; return created; })(); +const exceptionHandlers: Set = (() => { + const g = globalThis as unknown as Record>; + const existing = g[EXCEPTION_HANDLERS_GLOBAL_KEY]; + if (existing instanceof Set) { + return existing; + } + const created = new Set(); + g[EXCEPTION_HANDLERS_GLOBAL_KEY] = created; + return created; +})(); const FATAL_ERROR_CODES = new Set([ "ERR_OUT_OF_MEMORY", @@ -350,6 +362,29 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean { return false; } +export function registerUncaughtExceptionHandler(handler: UncaughtExceptionHandler): () => void { + exceptionHandlers.add(handler); + return () => { + exceptionHandlers.delete(handler); + }; +} + +export function isUncaughtExceptionHandled(error: unknown): boolean { + for (const handler of exceptionHandlers) { + try { + if (handler(error)) { + return true; + } + } catch (err) { + console.error( + "[openclaw] Uncaught exception handler failed:", + err instanceof Error ? (err.stack ?? err.message) : err, + ); + } + } + return false; +} + export function installUnhandledRejectionHandler(): void { const exitWithTerminalRestore = (reason: string, error?: unknown, hookReason = reason) => { for (const message of runFatalErrorHooks({ reason: hookReason, error })) { diff --git a/src/plugin-sdk/runtime-env.ts b/src/plugin-sdk/runtime-env.ts index 85bb087f8f9..a754f7209f2 100644 --- a/src/plugin-sdk/runtime-env.ts +++ b/src/plugin-sdk/runtime-env.ts @@ -28,5 +28,8 @@ export { } from "../infra/format-time/format-duration.ts"; export { retryAsync } from "../infra/retry.js"; export { ensureGlobalUndiciEnvProxyDispatcher } from "../infra/net/undici-global-dispatcher.js"; -export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; +export { + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, +} from "../infra/unhandled-rejections.js"; export { isWSL2Sync } from "../infra/wsl.js"; diff --git a/src/plugin-sdk/runtime.ts b/src/plugin-sdk/runtime.ts index b1b5b823c05..f49f5708bde 100644 --- a/src/plugin-sdk/runtime.ts +++ b/src/plugin-sdk/runtime.ts @@ -29,5 +29,8 @@ export { formatPluginInstallPathIssue, } from "../infra/plugin-install-path-warnings.js"; export { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js"; -export { registerUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; +export { + registerUncaughtExceptionHandler, + registerUnhandledRejectionHandler, +} from "../infra/unhandled-rejections.js"; export { removePluginFromConfig } from "../plugins/uninstall.js";