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 <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-14 22:15:03 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent f638cbd0f9
commit f1778ad36b
2 changed files with 149 additions and 8 deletions
@@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(async (_input, init) => {
const signal = init?.signal;
if (!signal) {
throw new Error("missing screenshot fetch signal");
}
return await new Promise<Response>((_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<typeof fetch>(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<Blob>((_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);
});
});
+20 -8
View File
@@ -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<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => {