diff --git a/CHANGELOG.md b/CHANGELOG.md index 191c2172d..b38cf42c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Docs: https://docs.openclaw.ai +## Unreleased + +### Fixes +- Gateway: fix crash resilience for mDNS/Bonjour errors during network interface changes (sleep/wake, WiFi reconnect). (#3821) + - Added error patterns for "IPv4 address changed" and "illegal state" assertions from @homebridge/ciao. + - Added `--no-bonjour` CLI flag to disable mDNS advertising entirely. +- Gateway: improve handling of transient network errors to prevent crashes. (#3815, #4501) + - Added ERR_ASSERTION to transient network error codes. + - Better detection of mDNS-related assertion errors. + ## 2026.1.29 Status: stable. diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index d5e1f4eaa..eb07f9ce2 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -48,6 +48,7 @@ type GatewayRunOpts = { rawStreamPath?: unknown; dev?: boolean; reset?: boolean; + bonjour?: boolean; }; const gatewayLog = createSubsystemLogger("gateway"); @@ -89,6 +90,11 @@ async function runGatewayCommand(opts: GatewayRunOpts) { process.env.OPENCLAW_RAW_STREAM_PATH = rawStreamPath; } + // Handle --no-bonjour flag to disable mDNS/Bonjour advertising + if (opts.bonjour === false) { + process.env.OPENCLAW_DISABLE_BONJOUR = "1"; + } + if (devMode) { await ensureDevGatewayConfig({ reset: Boolean(opts.reset) }); } @@ -350,6 +356,7 @@ export function addGatewayRunCommand(cmd: Command): Command { .option("--compact", 'Alias for "--ws-log compact"', false) .option("--raw-stream", "Log raw model stream events to jsonl", false) .option("--raw-stream-path ", "Raw stream jsonl path") + .option("--no-bonjour", "Disable mDNS/Bonjour service advertising", false) .action(async (opts) => { await runGatewayCommand(opts); }); diff --git a/src/infra/bonjour-ciao.ts b/src/infra/bonjour-ciao.ts index 17df4e78c..6bd99d9f1 100644 --- a/src/infra/bonjour-ciao.ts +++ b/src/infra/bonjour-ciao.ts @@ -2,11 +2,32 @@ import { logDebug } from "../logger.js"; import { formatBonjourError } from "./bonjour-errors.js"; +// Error patterns from @homebridge/ciao that should not crash the gateway +// These are typically transient network/mDNS issues that resolve on their own +const BONJOUR_TRANSIENT_ERRORS = [ + "CIAO ANNOUNCEMENT CANCELLED", + "REACHED ILLEGAL STATE", // IPv4 address changes during network interface churn + "IPV4 ADDRESS CHANGED", + "IPV6 ADDRESS CHANGED", + "MDNSSERVER", + "NETWORK INTERFACE", // Network interface changes (sleep/wake, WiFi reconnect) +]; + export function ignoreCiaoCancellationRejection(reason: unknown): boolean { const message = formatBonjourError(reason).toUpperCase(); - if (!message.includes("CIAO ANNOUNCEMENT CANCELLED")) { + const errorName = reason instanceof Error ? reason.name?.toUpperCase() : ""; + + // Check for transient mDNS/Bonjour error patterns + const isTransientError = BONJOUR_TRANSIENT_ERRORS.some((pattern) => message.includes(pattern)); + + // Also catch AssertionError from MDNServer (common during network changes) + // Note: The error message typically contains "MDNSServer" in the stack trace + const isAssertionError = errorName === "ASSERTIONERROR" && message.includes("MDNSSERVER"); + + if (!isTransientError && !isAssertionError) { return false; } + logDebug(`bonjour: ignoring unhandled ciao rejection: ${formatBonjourError(reason)}`); return true; } diff --git a/src/infra/bonjour-errors.integration.test.ts b/src/infra/bonjour-errors.integration.test.ts new file mode 100644 index 000000000..8deed284e --- /dev/null +++ b/src/infra/bonjour-errors.integration.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import process from "node:process"; + +import { + installUnhandledRejectionHandler, + isTransientNetworkError, + isAbortError, +} from "./unhandled-rejections.js"; +import { ignoreCiaoCancellationRejection } from "./bonjour-ciao.js"; +import { registerUnhandledRejectionHandler } from "./unhandled-rejections.js"; + +describe("mDNS error handling integration", () => { + let originalExit: typeof process.exit; + + beforeAll(() => { + originalExit = process.exit.bind(process); + installUnhandledRejectionHandler(); + registerUnhandledRejectionHandler(ignoreCiaoCancellationRejection); + }); + + afterAll(() => { + process.exit = originalExit; + }); + + describe("error detection functions", () => { + it("detects IPv4 address change as transient network error", () => { + const mdnsError = Object.assign( + new Error("Reached illegal state! IPv4 address changed from undefined to defined!"), + { + name: "AssertionError", + code: "ERR_ASSERTION", + }, + ); + + expect(isTransientNetworkError(mdnsError)).toBe(true); + }); + + it("detects MDNSServer illegal state as transient network error", () => { + const mdnsError = new Error("MDNSServer: Reached illegal state during network update"); + + expect(isTransientNetworkError(mdnsError)).toBe(true); + }); + + it("detects AbortError correctly", () => { + const abortError = Object.assign(new Error("This operation was aborted"), { + name: "AbortError", + }); + + expect(isAbortError(abortError)).toBe(true); + }); + + it("does not treat fatal errors as transient", () => { + const fatalError = Object.assign(new Error("Out of memory"), { + code: "ERR_OUT_OF_MEMORY", + }); + + expect(isTransientNetworkError(fatalError)).toBe(false); + }); + }); + + describe("ciao cancellation handler", () => { + it("handles CIAO announcement cancelled error", () => { + const ciaoError = new Error("CIAO announcement cancelled due to network change"); + + expect(ignoreCiaoCancellationRejection(ciaoError)).toBe(true); + }); + + it("handles IPv4 address change error", () => { + const mdnsError = Object.assign( + new Error("Reached illegal state! IPv4 address changed from undefined to defined!"), + { + name: "AssertionError", + code: "ERR_ASSERTION", + }, + ); + + expect(ignoreCiaoCancellationRejection(mdnsError)).toBe(true); + }); + + it("handles MDNSServer illegal state error", () => { + const mdnsError = Object.assign(new Error("MDNSServer: Reached illegal state"), { + name: "AssertionError", + }); + + expect(ignoreCiaoCancellationRejection(mdnsError)).toBe(true); + }); + + it("does not handle unrelated errors", () => { + const genericError = new Error("Something went wrong"); + + expect(ignoreCiaoCancellationRejection(genericError)).toBe(false); + }); + }); +}); diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index e991c67c9..275d861c9 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -158,5 +158,32 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { expect(exitCalls).toEqual([]); expect(consoleWarnSpy).toHaveBeenCalled(); }); + + it("does NOT exit on mDNS/Bonjour IPv4 address change errors", () => { + const mdnsErr = Object.assign( + new Error("Reached illegal state! IPv4 address changed from undefined to defined!"), + { + name: "AssertionError", + code: "ERR_ASSERTION", + }, + ); + + process.emit("unhandledRejection", mdnsErr, Promise.resolve()); + + expect(exitCalls).toEqual([]); + expect(consoleWarnSpy).toHaveBeenCalledWith( + "[openclaw] Non-fatal unhandled rejection (continuing):", + expect.stringContaining("IPv4 address changed"), + ); + }); + + it("does NOT exit on mDNS MDNSServer illegal state errors", () => { + const mdnsErr = new Error("MDNSServer: Reached illegal state during network update"); + + process.emit("unhandledRejection", mdnsErr, Promise.resolve()); + + expect(exitCalls).toEqual([]); + expect(consoleWarnSpy).toHaveBeenCalled(); + }); }); }); diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 4d2a48d23..1835849cb 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -34,6 +34,8 @@ const TRANSIENT_NETWORK_CODES = new Set([ "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", + // mDNS/Bonjour errors from @homebridge/ciao + "ERR_ASSERTION", // IPv4 address changes during network interface churn ]); function getErrorCause(err: unknown): unknown { @@ -99,6 +101,23 @@ export function isTransientNetworkError(err: unknown): boolean { return err.errors.some((e) => isTransientNetworkError(e)); } + // Handle mDNS/Bonjour errors from @homebridge/ciao + // These are typically triggered by network interface changes (sleep/wake, WiFi reconnect) + if (err instanceof Error) { + const errorName = err.name?.toUpperCase() || ""; + const message = err.message?.toUpperCase() || ""; + + // AssertionError from MDNServer during network interface changes + if (errorName === "ASSERTIONERROR" && message.includes("IPV4 ADDRESS CHANGED")) { + return true; + } + + // Other mDNS-related transient errors + if (message.includes("MDNSSERVER") && message.includes("ILLEGAL STATE")) { + return true; + } + } + return false; }