From e6b011823ebbbbbd49e8cfe0dadd326bab424147 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 29 May 2026 16:56:55 -0400 Subject: [PATCH] fix(signal): cap client request timeouts --- extensions/signal/src/client.test.ts | 21 +++++++++++++++++++++ extensions/signal/src/client.ts | 12 +++++++----- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/extensions/signal/src/client.test.ts b/extensions/signal/src/client.test.ts index f6353ba94e5..d1ef5b1fbe6 100644 --- a/extensions/signal/src/client.test.ts +++ b/extensions/signal/src/client.test.ts @@ -17,6 +17,8 @@ let signalCheck: typeof import("./client.js").signalCheck; let signalRpcRequest: typeof import("./client.js").signalRpcRequest; let streamSignalEvents: typeof import("./client.js").streamSignalEvents; +const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; + const servers: http.Server[] = []; async function readRequestBody(req: IncomingMessage): Promise { @@ -51,6 +53,7 @@ beforeAll(async () => { }); afterEach(async () => { + vi.restoreAllMocks(); await Promise.all( servers.splice(0).map( (server) => @@ -221,6 +224,24 @@ describe("signalRpcRequest", () => { }), ).rejects.toThrow("Signal HTTP exceeded deadline after 25ms"); }); + + it("caps oversized RPC request timeouts before scheduling", async () => { + const timeoutSpy = vi + .spyOn(globalThis, "setTimeout") + .mockReturnValue(1 as unknown as ReturnType); + vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined); + const baseUrl = await withSignalServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ jsonrpc: "2.0", result: { version: "0.13.22" }, id: "test-id" })); + }); + + await signalRpcRequest("version", undefined, { + baseUrl, + timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000, + }); + + expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); + }); }); describe("signalCheck", () => { diff --git a/extensions/signal/src/client.ts b/extensions/signal/src/client.ts index c7751194a29..e98a75a3ac7 100644 --- a/extensions/signal/src/client.ts +++ b/extensions/signal/src/client.ts @@ -3,6 +3,7 @@ import http, { type ClientRequest, type IncomingMessage } from "node:http"; import https from "node:https"; import { generateSecureUuid } from "openclaw/plugin-sdk/core"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime"; export type SignalRpcOptions = { baseUrl: string; @@ -106,7 +107,7 @@ function normalizeSignalSseTimeoutMs(timeoutMs: number): number | null { if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { return null; } - return timeoutMs; + return resolveTimerTimeoutMs(timeoutMs, DEFAULT_TIMEOUT_MS); } function requestSignalHttpText( @@ -120,13 +121,14 @@ function requestSignalHttpText( }, ): Promise { assertSignalHttpProtocol(url, "HTTP"); + const timeoutMs = resolveTimerTimeoutMs(options.timeoutMs, DEFAULT_TIMEOUT_MS); const client = url.protocol === "https:" ? https : http; return new Promise((resolve, reject) => { let settled = false; let request: ClientRequest | undefined; const deadline = setTimeout(() => { - request?.destroy(new Error(`Signal HTTP exceeded deadline after ${options.timeoutMs}ms`)); - }, options.timeoutMs); + request?.destroy(new Error(`Signal HTTP exceeded deadline after ${timeoutMs}ms`)); + }, timeoutMs); deadline.unref?.(); const cleanup = () => { clearTimeout(deadline); @@ -180,8 +182,8 @@ function requestSignalHttpText( }); }, ); - request.setTimeout(options.timeoutMs, () => { - request?.destroy(new Error(`Signal HTTP timed out after ${options.timeoutMs}ms`)); + request.setTimeout(timeoutMs, () => { + request?.destroy(new Error(`Signal HTTP timed out after ${timeoutMs}ms`)); }); request.on("error", rejectOnce); if (options.body !== undefined) {