From f1778ad36b32cd84ef2e2d8a2fe8cc655cd0b491 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Wed, 15 Jul 2026 13:15:03 +0800 Subject: [PATCH] fix(ui): prevent browser screenshot fetch hangs (#107703) * fix(ui): prevent browser screenshot fetch hangs * test(ui): cover stalled screenshot body * test(ui): verify screenshot timer cleanup * test(ui): cover successful timer cleanup --------- Co-authored-by: Peter Steinberger --- .../components/browser/browser-client.test.ts | 129 ++++++++++++++++++ ui/src/components/browser/browser-client.ts | 28 ++-- 2 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/browser/browser-client.test.ts diff --git a/ui/src/components/browser/browser-client.test.ts b/ui/src/components/browser/browser-client.test.ts new file mode 100644 index 000000000000..d94eaa0409d4 --- /dev/null +++ b/ui/src/components/browser/browser-client.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchBrowserScreenshotDataUrl } from "./browser-client.ts"; + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("fetchBrowserScreenshotDataUrl", () => { + it("returns the fetched screenshot as a data URL", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const screenshot = new Blob(["image-bytes"], { type: "image/png" }); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: true, blob: async () => screenshot }) as Response), + ); + + await expect( + fetchBrowserScreenshotDataUrl({ + basePath: "/openclaw/", + authToken: null, + path: "/tmp/browser shot.png", + }), + ).resolves.toBe("data:image/png;base64,aW1hZ2UtYnl0ZXM="); + expect(vi.getTimerCount()).toBe(0); + }); + + it("rejects unsuccessful screenshot responses", async () => { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(null, { status: 404 })), + ); + + await expect( + fetchBrowserScreenshotDataUrl({ + basePath: "/openclaw", + authToken: null, + path: "/tmp/missing.png", + }), + ).rejects.toThrow("screenshot fetch failed (404)"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("aborts a stalled screenshot fetch after the request deadline", async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn(async (_input, init) => { + const signal = init?.signal; + if (!signal) { + throw new Error("missing screenshot fetch signal"); + } + return await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + // Fetch rejects with the exact abort reason. DOM types define it as an Error, + // although jsdom does not preserve that prototype relationship at runtime. + reject(signal.reason as Error); + }, + { once: true }, + ); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const request = fetchBrowserScreenshotDataUrl({ + basePath: "/openclaw", + authToken: null, + path: "/tmp/browser shot.png", + }); + const [url, init] = fetchMock.mock.calls[0] ?? []; + expect(url).toBe("/openclaw/__openclaw__/assistant-media?source=%2Ftmp%2Fbrowser+shot.png"); + expect(init?.signal?.aborted).toBe(false); + + const outcome = expect(request).rejects.toMatchObject({ name: "TimeoutError" }); + await vi.advanceTimersByTimeAsync(29_999); + expect(init?.signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + await outcome; + expect(init?.signal?.aborted).toBe(true); + }); + + it("aborts a stalled screenshot body after the request deadline", async () => { + vi.useFakeTimers(); + let bodyReadStarted = false; + const fetchMock = vi.fn(async (_input, init) => { + const signal = init?.signal; + if (!signal) { + throw new Error("missing screenshot fetch signal"); + } + return { + ok: true, + blob: async () => { + bodyReadStarted = true; + return await new Promise((_resolve, reject) => { + const rejectWithAbortReason = () => reject(signal.reason as Error); + if (signal.aborted) { + rejectWithAbortReason(); + return; + } + signal.addEventListener("abort", rejectWithAbortReason, { once: true }); + }); + }, + } as Response; + }); + vi.stubGlobal("fetch", fetchMock); + + const request = fetchBrowserScreenshotDataUrl({ + basePath: "/openclaw", + authToken: null, + path: "/tmp/browser shot.png", + }); + const [, init] = fetchMock.mock.calls[0] ?? []; + await Promise.resolve(); + await Promise.resolve(); + expect(bodyReadStarted).toBe(true); + expect(init?.signal?.aborted).toBe(false); + + const outcome = expect(request).rejects.toMatchObject({ name: "TimeoutError" }); + await vi.advanceTimersByTimeAsync(29_999); + expect(init?.signal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + + await outcome; + expect(init?.signal?.aborted).toBe(true); + }); +}); diff --git a/ui/src/components/browser/browser-client.ts b/ui/src/components/browser/browser-client.ts index 630982fb7d50..772abd3787fb 100644 --- a/ui/src/components/browser/browser-client.ts +++ b/ui/src/components/browser/browser-client.ts @@ -8,6 +8,7 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/recor import type { GatewayBrowserClient } from "../../api/gateway.ts"; const BROWSER_REQUEST_METHOD = "browser.request"; +const BROWSER_SCREENSHOT_FETCH_TIMEOUT_MS = 30_000; export type BrowserPanelTab = { /** @@ -320,15 +321,26 @@ export async function fetchBrowserScreenshotDataUrl(params: { if (params.authToken) { headers.set("Authorization", `Bearer ${params.authToken}`); } - const res = await fetch(`${basePath}/__openclaw__/assistant-media?${search.toString()}`, { - method: "GET", - headers, - credentials: "same-origin", - }); - if (!res.ok) { - throw new Error(`screenshot fetch failed (${res.status})`); + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new DOMException("screenshot fetch timed out", "TimeoutError")), + BROWSER_SCREENSHOT_FETCH_TIMEOUT_MS, + ); + let blob: Blob; + try { + const res = await fetch(`${basePath}/__openclaw__/assistant-media?${search.toString()}`, { + method: "GET", + headers, + credentials: "same-origin", + signal: controller.signal, + }); + if (!res.ok) { + throw new Error(`screenshot fetch failed (${res.status})`); + } + blob = await res.blob(); + } finally { + clearTimeout(timeout); } - const blob = await res.blob(); return await new Promise((resolve, reject) => { const reader = new FileReader(); reader.addEventListener("load", () => {