diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4830475b3..6f9b20c8a77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - CLI: lazy-load model, plugin, and device runtime helpers and keep channel option help on generated startup metadata or generic fallback text so parent/help output renders without importing those runtime paths. - CLI: route `plugins list --json` through the parsed command fast path and cover it in response budgets so plugin JSON inventory avoids full CLI registration work. - Gateway/session history: carry monotonic transcript message sequence through live updates and refresh SSE history when stale sequence input would otherwise append bad incremental state. (#81474) Thanks @samzong. +- Gateway/network: keep OpenClaw-installed undici dispatchers on HTTP/1.1 and treat destroyed HTTP/2 session errors as recoverable network teardown, preventing `ERR_HTTP2_INVALID_SESSION` from crashing active gateway turns. Fixes #81627. (#81838) Thanks @joshavant. - Memory/daily-files: widen the daily-memory file matcher used by Dreaming, rem-backfill, rem-harness, the doctor sweep, and short-term promotion so `memory/YYYY-MM-DD-.md` files written by the bundled session-memory hook (and any future slugged variants) are discovered alongside the date-only `memory/YYYY-MM-DD.md` shape. Date extraction still uses the leading `YYYY-MM-DD` capture group, so per-day ingestion/promotion semantics are unchanged for existing date-only files; slugged files now flow through the same paths instead of being silently skipped. Fixes #69536. Thanks @jack-stormentswe. - macOS/Gateway: fail managed LaunchAgent stop and restart when the configured gateway port remains busy after cleanup instead of reporting success while a listener survives. Fixes #73132. Thanks @BunsDev. - Telegram: reuse the sticky IPv4 Bot API transport for periodic getMe health checks, so IPv4-working hosts with broken IPv6 egress stop logging repeated probe timeouts. Fixes #76852. (#76856) Thanks @SymbolStar. diff --git a/src/infra/net/undici-global-dispatcher.test.ts b/src/infra/net/undici-global-dispatcher.test.ts index 639568392ca..4ed1dfd793b 100644 --- a/src/infra/net/undici-global-dispatcher.test.ts +++ b/src/infra/net/undici-global-dispatcher.test.ts @@ -164,6 +164,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { expect(next.options).toEqual({ bodyTimeout: 1_900_000, headersTimeout: 1_900_000, + allowH2: false, connect: { autoSelectFamily: false, autoSelectFamilyAttemptTimeout: 300, @@ -184,6 +185,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { expect(next).toBeInstanceOf(EnvHttpProxyAgent); expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); + expect(next.options?.allowH2).toBe(false); expect(next.options?.connect).toEqual({ autoSelectFamily: false, autoSelectFamilyAttemptTimeout: 300, @@ -207,6 +209,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { expect(next.options?.httpsProxy).toBe("socks5://proxy.test:1080"); expect(next.options?.bodyTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); expect(next.options?.headersTimeout).toBe(DEFAULT_UNDICI_STREAM_TIMEOUT_MS); + expect(next.options?.allowH2).toBe(false); }); it("records timeout bridge but does not override unsupported custom proxy dispatcher types", () => { @@ -281,6 +284,7 @@ describe("ensureGlobalUndiciStreamTimeouts", () => { autoSelectFamily: false, autoSelectFamilyAttemptTimeout: 300, }); + expect(next.options?.allowH2).toBe(false); }); }); @@ -299,7 +303,9 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => { ensureGlobalUndiciEnvProxyDispatcher(); expect(setGlobalDispatcher).toHaveBeenCalledTimes(1); - expect(getCurrentDispatcher()).toBeInstanceOf(EnvHttpProxyAgent); + const next = getCurrentDispatcher() as { options?: Record }; + expect(next).toBeInstanceOf(EnvHttpProxyAgent); + expect(next.options?.allowH2).toBe(false); }); it("installs EnvHttpProxyAgent with explicit ALL_PROXY fallback options", () => { @@ -317,6 +323,7 @@ describe("ensureGlobalUndiciEnvProxyDispatcher", () => { expect(next.options).toEqual({ httpProxy: "socks5://proxy.test:1080", httpsProxy: "socks5://proxy.test:1080", + allowH2: false, }); }); @@ -413,6 +420,7 @@ describe("forceResetGlobalDispatcher", () => { expect((getCurrentDispatcher() as { options?: Record }).options).toEqual({ httpProxy: "http://proxy-b.example:8080", httpsProxy: "http://proxy-b.example:8080", + allowH2: false, }); }); @@ -431,6 +439,7 @@ describe("forceResetGlobalDispatcher", () => { expect((getCurrentDispatcher() as { options?: Record }).options).toEqual({ httpProxy: "http://proxy-all.example:3128", httpsProxy: "http://proxy-all.example:3128", + allowH2: false, }); }); }); diff --git a/src/infra/net/undici-global-dispatcher.ts b/src/infra/net/undici-global-dispatcher.ts index c75847633eb..c7f3f4712e9 100644 --- a/src/infra/net/undici-global-dispatcher.ts +++ b/src/infra/net/undici-global-dispatcher.ts @@ -9,6 +9,9 @@ import { } from "./undici-runtime.js"; export const DEFAULT_UNDICI_STREAM_TIMEOUT_MS = 30 * 60 * 1000; +const HTTP1_ONLY_DISPATCHER_OPTIONS = Object.freeze({ + allowH2: false as const, +}); /** * Module-level bridge so `resolveDispatcherTimeoutMs` in fetch-guard.ts @@ -93,7 +96,12 @@ export function ensureGlobalUndiciEnvProxyDispatcher(): void { return; } try { - setGlobalDispatcher(new EnvHttpProxyAgent(resolveEnvHttpProxyAgentOptions())); + setGlobalDispatcher( + new EnvHttpProxyAgent({ + ...resolveEnvHttpProxyAgentOptions(), + ...HTTP1_ONLY_DISPATCHER_OPTIONS, + }), + ); lastAppliedProxyBootstrap = true; } catch { // Best-effort bootstrap only. @@ -120,6 +128,7 @@ function applyGlobalDispatcherStreamTimeouts(params: { bodyTimeout: timeoutMs, headersTimeout: timeoutMs, ...(connect ? { connect } : {}), + ...HTTP1_ONLY_DISPATCHER_OPTIONS, } as ConstructorParameters[0]; runtime.setGlobalDispatcher(new runtime.EnvHttpProxyAgent(proxyOptions)); } else { @@ -128,6 +137,7 @@ function applyGlobalDispatcherStreamTimeouts(params: { bodyTimeout: timeoutMs, headersTimeout: timeoutMs, ...(connect ? { connect } : {}), + ...HTTP1_ONLY_DISPATCHER_OPTIONS, }), ); } @@ -192,7 +202,7 @@ export function forceResetGlobalDispatcher(): void { lastAppliedProxyBootstrap = false; try { const { Agent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps(); - setGlobalDispatcher(new Agent()); + setGlobalDispatcher(new Agent(HTTP1_ONLY_DISPATCHER_OPTIONS)); } catch { // Best-effort reset only. } @@ -203,9 +213,10 @@ export function forceResetGlobalDispatcher(): void { const { EnvHttpProxyAgent, setGlobalDispatcher } = loadUndiciGlobalDispatcherDeps(); const proxyOptions = resolveEnvHttpProxyAgentOptions(); setGlobalDispatcher( - new EnvHttpProxyAgent( - proxyOptions as ConstructorParameters[0], - ), + new EnvHttpProxyAgent({ + ...proxyOptions, + ...HTTP1_ONLY_DISPATCHER_OPTIONS, + } as ConstructorParameters[0]), ); lastAppliedProxyBootstrap = true; } catch { diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts index 32fa652d712..00a1ef256ce 100644 --- a/src/infra/unhandled-rejections.test.ts +++ b/src/infra/unhandled-rejections.test.ts @@ -69,6 +69,7 @@ describe("isTransientNetworkError", () => { "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", + "ERR_HTTP2_INVALID_SESSION", "ERR_SSL_WRONG_VERSION_NUMBER", "ERR_SSL_PROTOCOL_RETURNED_AN_ERROR", ]; @@ -103,6 +104,17 @@ describe("isTransientNetworkError", () => { expect(isTransientNetworkError(error)).toBe(true); }); + it("returns true for destroyed HTTP/2 sessions from undici", () => { + const innerCause = Object.assign(new Error("The session has been destroyed"), { + code: "ERR_HTTP2_INVALID_SESSION", + }); + const outerCause = Object.assign(new Error("model call failed"), { cause: innerCause }); + + expect(isTransientNetworkError(innerCause)).toBe(true); + expect(isTransientNetworkError(outerCause)).toBe(true); + expect(isTransientNetworkError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true); + }); + it("returns true for Slack request errors that wrap network codes in .original", () => { const error = Object.assign(new Error("A request error occurred: getaddrinfo EAI_AGAIN"), { code: "slack_webapi_request_error", @@ -375,6 +387,12 @@ describe("isTransientUnhandledRejectionError", () => { const rawAddressUnavailable = new Error( "connect EADDRNOTAVAIL 2607:6bc0::10:443 - Local (:::0)", ); + const destroyedHttp2Session = Object.assign(new Error("The session has been destroyed"), { + code: "ERR_HTTP2_INVALID_SESSION", + }); + const wrappedDestroyedHttp2Session = Object.assign(new Error("model call failed"), { + cause: destroyedHttp2Session, + }); const generic = new Error("boom"); expect(isBenignUncaughtExceptionError(epipe)).toBe(true); @@ -384,6 +402,9 @@ describe("isTransientUnhandledRejectionError", () => { expect(isBenignUncaughtExceptionError(rawHostUnreachable)).toBe(true); expect(isBenignUncaughtExceptionError(addressUnavailable)).toBe(true); expect(isBenignUncaughtExceptionError(rawAddressUnavailable)).toBe(true); + expect(isBenignUncaughtExceptionError(destroyedHttp2Session)).toBe(true); + expect(isBenignUncaughtExceptionError(wrappedDestroyedHttp2Session)).toBe(true); + expect(isBenignUncaughtExceptionError(new Error("ERR_HTTP2_INVALID_SESSION"))).toBe(true); expect(isBenignUncaughtExceptionError(generic)).toBe(false); }); it("returns true for transient SQLite errors", () => { diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 46ffa0fe242..04f799451da 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -67,6 +67,7 @@ const TRANSIENT_NETWORK_CODES = new Set([ "UND_ERR_SOCKET", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", + "ERR_HTTP2_INVALID_SESSION", "EPROTO", "ERR_SSL_WRONG_VERSION_NUMBER", "ERR_SSL_PROTOCOL_RETURNED_AN_ERROR", @@ -101,12 +102,13 @@ const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_CODES = new Set([ "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_DNS_RESOLVE_FAILED", "UND_ERR_CONNECT", + "ERR_HTTP2_INVALID_SESSION", ]); const TRANSIENT_NETWORK_MESSAGE_CODE_RE = - /\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|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; + /\b(ECONNRESET|ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ESOCKETTIMEDOUT|ECONNABORTED|EPIPE|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|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|ERR_HTTP2_INVALID_SESSION)\b/i; const BENIGN_UNCAUGHT_EXCEPTION_NETWORK_MESSAGE_CODE_RE = - /\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT)\b/i; + /\b(ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|EADDRNOTAVAIL|EAI_AGAIN|ENOTFOUND|ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT|UND_ERR_DNS_RESOLVE_FAILED|UND_ERR_CONNECT|ERR_HTTP2_INVALID_SESSION)\b/i; const TRANSIENT_SQLITE_MESSAGE_CODE_RE = /\b(SQLITE_BUSY|SQLITE_CANTOPEN|SQLITE_IOERR|SQLITE_LOCKED)\b/i;