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", () => {