fix(cli): wait for gateway client teardown before exit (#70691)

Verified:
- pnpm test src/gateway/call.test.ts

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Tak Hoffman
2026-04-23 12:03:37 -05:00
committed by GitHub
parent fb47c1d6bf
commit 77a1cbd5ff
3 changed files with 80 additions and 6 deletions

View File

@@ -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.

View File

@@ -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<NonNullable<typeof opts.onHelloOk>>[0]);
},
stop() {},
async stopAndWait() {
stopStarted = true;
await new Promise<void>((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: {} },

View File

@@ -92,6 +92,14 @@ const gatewayCallDeps = {
...defaultGatewayCallDeps,
};
async function stopGatewayClient(client: GatewayClient): Promise<void> {
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<T>(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<T>(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();