diff --git a/CHANGELOG.md b/CHANGELOG.md index f1a26d3f521..62e3798b029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/Gateway: wait for one-shot gateway RPC clients to finish WebSocket teardown before the CLI process exits, reducing hangs where commands like `openclaw status` or `openclaw version` could finish their work but stay alive until an external timeout killed them. - Thinking defaults/status: raise the implicit default thinking level for reasoning-capable models from legacy `off`/`low` fallback behavior to a safe provider-supported `medium` equivalent when no explicit config default is set, preserve configured-model reasoning metadata when runtime catalog loading is empty, and make `/status` report the same resolved default as runtime. - Gateway/model pricing: fetch OpenRouter and LiteLLM pricing asynchronously at startup and extend catalog fetch timeouts to 30 seconds, reducing noisy timeout warnings during slow upstream responses. - Agents/failover: classify bare undici transport failures (`terminated`, `UND_ERR_SOCKET`, `UND_ERR_CONNECT_TIMEOUT`, body/header timeouts, aborted streams) and pi-ai's openai-codex `Request failed` sentinel as `timeout`, so Cloudflare 502s with empty bodies and mid-response socket resets actually enter the configured fallback chain instead of surfacing as unclassified errors. Fixes #69368. (#69677) Thanks @sk7n4k3d. diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 927ccd3adac..cd284e00309 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -124,6 +124,7 @@ class StubGatewayClient { } } stop() {} + async stopAndWait() {} } function resetGatewayCallMocks() { @@ -812,6 +813,68 @@ describe("callGateway error details", () => { expect(lastRequestOptions?.opts?.timeoutMs).toBeUndefined(); }); + it("waits for gateway client teardown before resolving", async () => { + setLocalLoopbackGatewayConfig(); + + let releaseStop!: () => void; + let stopStarted = false; + let stopFinished = false; + let callResolved = false; + + __testing.setDepsForTests({ + createGatewayClient: (opts) => + ({ + async request( + method: string, + params: unknown, + requestOpts?: { expectFinal?: boolean; timeoutMs?: number | null }, + ) { + lastRequestOptions = { method, params, opts: requestOpts }; + return { ok: true }; + }, + start() { + opts.onHelloOk?.({ + features: { + methods: helloMethods ?? [], + events: [], + }, + } as unknown as Parameters>[0]); + }, + stop() {}, + async stopAndWait() { + stopStarted = true; + await new Promise((resolve) => { + releaseStop = () => { + stopFinished = true; + resolve(); + }; + }); + }, + }) as never, + loadConfig: loadConfig as unknown as () => OpenClawConfig, + loadOrCreateDeviceIdentity: () => deviceIdentityState.value, + resolveGatewayPort: resolveGatewayPort as unknown as ( + cfg?: OpenClawConfig, + env?: NodeJS.ProcessEnv, + ) => number, + }); + + const promise = callGateway({ method: "health" }).then(() => { + callResolved = true; + }); + + await vi.waitFor(() => { + expect(stopStarted).toBe(true); + }); + expect(callResolved).toBe(false); + + releaseStop(); + await promise; + + expect(stopFinished).toBe(true); + expect(callResolved).toBe(true); + }); + it("fails fast when remote mode is missing remote url", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index ce2612b4eb8..b2b8f549de0 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -92,6 +92,14 @@ const gatewayCallDeps = { ...defaultGatewayCallDeps, }; +async function stopGatewayClient(client: GatewayClient): Promise { + try { + await client.stopAndWait({ timeoutMs: 1_000 }); + } catch { + client.stop(); + } +} + function resolveGatewayClientDisplayName(opts: CallGatewayBaseOptions): string | undefined { if (opts.clientDisplayName) { return opts.clientDisplayName; @@ -520,11 +528,11 @@ async function executeGatewayRequestWithScopes(params: { timeoutMs: opts.timeoutMs, }); ignoreClose = true; + await stopGatewayClient(client); stop(undefined, result); - client.stop(); } catch (err) { ignoreClose = true; - client.stop(); + await stopGatewayClient(client); stop(err as Error); } }, @@ -533,15 +541,17 @@ async function executeGatewayRequestWithScopes(params: { return; } ignoreClose = true; - client.stop(); - stop(new Error(formatGatewayCloseError(code, reason, params.connectionDetails))); + void stopGatewayClient(client).finally(() => { + stop(new Error(formatGatewayCloseError(code, reason, params.connectionDetails))); + }); }, }); const timer = setTimeout(() => { ignoreClose = true; - client.stop(); - stop(new Error(formatGatewayTimeoutError(timeoutMs, params.connectionDetails))); + void stopGatewayClient(client).finally(() => { + stop(new Error(formatGatewayTimeoutError(timeoutMs, params.connectionDetails))); + }); }, safeTimerTimeoutMs); client.start();