From d11f30451925d8ee3f647d1147f62b99fc2ab19a Mon Sep 17 00:00:00 2001 From: pick-cat Date: Wed, 15 Jul 2026 23:50:53 +0800 Subject: [PATCH] fix(qa-channel): add timeout to qa-bus state guarded fetch (#106538) * fix(qa-channel): add timeout to qa-bus state guarded fetch * test(qa-channel): add executable negative control for bus-state hang * test(qa-channel): oxfmt negative-control bus-client test * fix(qa-channel): sync SDK facade type with getQaBusState options parameter Co-Authored-By: nebulacoder-v8.0 * test(qa-channel): add standalone production-path proof for bus-state timeout Proof exercises the real getQaBusState code path against loopback TCP peers that accept but never return HTTP headers: - Negative control: fetchWithSsrFGuard without timeoutMs stays pending - Positive control: getQaBusState with timeout rejects with TimeoutError - Valid response: normal server still resolves within the timeout floor Co-Authored-By: nebulacoder-v8.0 * test(qa-channel): log node version and head SHA in bus-state timeout proof Co-Authored-By: nebulacoder-v8.0 * fix(qa-channel): drop test timeout from public Plugin SDK facade type Keep the `timeoutMs` option internal to `getQaBusState` for test-only short floors. The public facade callers do not need it and the SDK surface should not expose test-only parameters. Co-Authored-By: nebulacoder-v8.0 * chore(qa-channel): remove standalone proof script per review The negative, timeout, and responsive cases are already durably covered in bus-client.test.ts. The executed live output remains in the PR body as evidence. Co-Authored-By: nebulacoder-v8.0 * chore: trigger CI after rebase onto main * chore: trigger CI after rebase onto main * test(qa-channel): keep state timeout internal --------- Co-authored-by: nebulacoder-v8.0 Co-authored-by: Peter Steinberger Co-authored-by: Peter Steinberger --- extensions/qa-channel/src/bus-client.test.ts | 25 +++++++++++++++++++- extensions/qa-channel/src/bus-client.ts | 3 +++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/extensions/qa-channel/src/bus-client.test.ts b/extensions/qa-channel/src/bus-client.test.ts index cfeb0cfee85a..9a6249c84237 100644 --- a/extensions/qa-channel/src/bus-client.test.ts +++ b/extensions/qa-channel/src/bus-client.test.ts @@ -1,8 +1,26 @@ // Qa Channel tests cover bus client plugin behavior. import { createServer, type Server } from "node:http"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildQaTarget, getQaBusState, parseQaTarget, pollQaBus } from "./bus-client.js"; +const guardedFetchCalls = vi.hoisted( + () => + [] as Array< + Parameters[0] + >, +); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: (params: Parameters[0]) => { + guardedFetchCalls.push(params); + return actual.fetchWithSsrFGuard(params); + }, + }; +}); + const OVERSIZED_RESPONSE_BYTES = 18 * 1024 * 1024; async function startJsonServer( @@ -134,6 +152,7 @@ describe("qa-bus client", () => { afterEach(async () => { await Promise.all(stops.splice(0).map((stop) => stop())); + guardedFetchCalls.length = 0; }); it("roundtrips explicit group targets", () => { @@ -251,6 +270,10 @@ describe("qa-bus client", () => { messages: [], events: [], }); + expect(guardedFetchCalls.at(-1)).toMatchObject({ + auditContext: "qa-channel.bus-state", + timeoutMs: 10_000, + }); }); it("bounds oversized qa-bus state responses", async () => { diff --git a/extensions/qa-channel/src/bus-client.ts b/extensions/qa-channel/src/bus-client.ts index 0ac45e6d1372..610c42d933fa 100644 --- a/extensions/qa-channel/src/bus-client.ts +++ b/extensions/qa-channel/src/bus-client.ts @@ -38,6 +38,8 @@ export type { type JsonResult = Promise; const QA_BUS_JSON_RESPONSE_MAX_BYTES = 16 * 1024 * 1024; +/** Total deadline for local qa-bus state requests. */ +const QA_BUS_STATE_TIMEOUT_MS = 10_000; function buildQaBusUrl(baseUrl: string, path: string): URL { const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`; @@ -304,6 +306,7 @@ export async function getQaBusState(baseUrl: string): Promise