Skip to content

Commit 7893706

Browse files
committed
fix(gateway): bound unanswered client requests
1 parent 01674c5 commit 7893706

File tree

3 files changed

+52
-2
lines changed

3 files changed

+52
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ Docs: https://docs.openclaw.ai
1515
### Fixes
1616

1717
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
18+
- Gateway/client requests: reject unanswered gateway RPC calls after a bounded timeout and clear their pending state, so stalled connections no longer leak hanging `GatewayClient.request()` promises indefinitely.
19+
- Telegram/webhook auth: validate the Telegram webhook secret before reading or parsing request bodies, so unauthenticated requests are rejected immediately instead of consuming up to 1 MB first. Thanks @space08.
20+
- Build/plugin-sdk bundling: bundle plugin-sdk subpath entries in one shared build pass so published packages stop duplicating shared chunks and avoid the recent plugin-sdk memory blow-up. (#45426) Thanks @TarasShyn.
21+
- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.
1822
- Ollama/reasoning visibility: stop promoting native `thinking` and `reasoning` fields into final assistant text so local reasoning models no longer leak internal thoughts in normal replies. (#45330) Thanks @xi7ang.
1923
- Android/onboarding QR scan: switch setup QR scanning to Google Code Scanner so onboarding uses a more reliable scanner instead of the legacy embedded ZXing flow. (#45021) Thanks @obviyus.
2024
- Browser/existing-session: accept text-only `list_pages` and `new_page` responses from Chrome DevTools MCP so live-session tab discovery and new-tab open flows keep working when the server omits structured page metadata.

src/gateway/client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ type Pending = {
4444
resolve: (value: unknown) => void;
4545
reject: (err: unknown) => void;
4646
expectFinal: boolean;
47+
timeout: NodeJS.Timeout | null;
4748
};
4849

4950
type GatewayClientErrorShape = {
@@ -78,6 +79,7 @@ export type GatewayClientOptions = {
7879
url?: string; // ws://127.0.0.1:18789
7980
connectDelayMs?: number;
8081
tickWatchMinIntervalMs?: number;
82+
requestTimeoutMs?: number;
8183
token?: string;
8284
bootstrapToken?: string;
8385
deviceToken?: string;
@@ -136,6 +138,7 @@ export class GatewayClient {
136138
private lastTick: number | null = null;
137139
private tickIntervalMs = 30_000;
138140
private tickTimer: NodeJS.Timeout | null = null;
141+
private readonly requestTimeoutMs: number;
139142

140143
constructor(opts: GatewayClientOptions) {
141144
this.opts = {
@@ -145,6 +148,10 @@ export class GatewayClient {
145148
? undefined
146149
: (opts.deviceIdentity ?? loadOrCreateDeviceIdentity()),
147150
};
151+
this.requestTimeoutMs =
152+
typeof opts.requestTimeoutMs === "number" && Number.isFinite(opts.requestTimeoutMs)
153+
? Math.max(1, opts.requestTimeoutMs)
154+
: 30_000;
148155
}
149156

150157
start() {
@@ -586,6 +593,9 @@ export class GatewayClient {
586593
return;
587594
}
588595
this.pending.delete(parsed.id);
596+
if (pending.timeout) {
597+
clearTimeout(pending.timeout);
598+
}
589599
if (parsed.ok) {
590600
pending.resolve(parsed.payload);
591601
} else {
@@ -638,6 +648,9 @@ export class GatewayClient {
638648

639649
private flushPendingErrors(err: Error) {
640650
for (const [, p] of this.pending) {
651+
if (p.timeout) {
652+
clearTimeout(p.timeout);
653+
}
641654
p.reject(err);
642655
}
643656
this.pending.clear();
@@ -711,10 +724,15 @@ export class GatewayClient {
711724
}
712725
const expectFinal = opts?.expectFinal === true;
713726
const p = new Promise<T>((resolve, reject) => {
727+
const timeout = setTimeout(() => {
728+
this.pending.delete(id);
729+
reject(new Error(`gateway request timeout for ${method}`));
730+
}, this.requestTimeoutMs);
714731
this.pending.set(id, {
715732
resolve: (value) => resolve(value as T),
716733
reject,
717734
expectFinal,
735+
timeout,
718736
});
719737
});
720738
this.ws.send(JSON.stringify(frame));

src/gateway/client.watchdog.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createServer as createHttpsServer } from "node:https";
22
import { createServer } from "node:net";
3-
import { afterEach, describe, expect, test } from "vitest";
4-
import { WebSocketServer } from "ws";
3+
import { afterEach, describe, expect, test, vi } from "vitest";
4+
import { WebSocket, WebSocketServer } from "ws";
55
import { rawDataToString } from "../infra/ws.js";
66
import { GatewayClient } from "./client.js";
77

@@ -85,6 +85,34 @@ describe("GatewayClient", () => {
8585
}
8686
}, 4000);
8787

88+
test("times out unresolved requests and clears pending state", async () => {
89+
vi.useFakeTimers();
90+
try {
91+
const client = new GatewayClient({
92+
requestTimeoutMs: 25,
93+
});
94+
const send = vi.fn();
95+
(client as unknown as { ws: WebSocket | { readyState: number; send: () => void } }).ws = {
96+
readyState: WebSocket.OPEN,
97+
send,
98+
};
99+
100+
const requestPromise = client.request("status");
101+
const requestExpectation = expect(requestPromise).rejects.toThrow(
102+
"gateway request timeout for status",
103+
);
104+
expect(send).toHaveBeenCalledTimes(1);
105+
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(1);
106+
107+
await vi.advanceTimersByTimeAsync(25);
108+
109+
await requestExpectation;
110+
expect((client as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0);
111+
} finally {
112+
vi.useRealTimers();
113+
}
114+
});
115+
88116
test("rejects mismatched tls fingerprint", async () => {
89117
const key = [
90118
"-----BEGIN PRIVATE KEY-----", // pragma: allowlist secret

0 commit comments

Comments
 (0)