mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): prevent failed plugin icons from retaining connections (#121129)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchCatalogIconBlobUrl, fetchPluginIconBlobUrl } from "./icon-loader.ts";
|
||||
|
||||
@@ -8,12 +10,53 @@ const auth = {
|
||||
};
|
||||
|
||||
function imageResponse(): Response {
|
||||
return new Response(new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: "image/png" }), {
|
||||
return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/png" },
|
||||
});
|
||||
}
|
||||
|
||||
async function listenOnLoopback(server: Server): Promise<string> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return `http://127.0.0.1:${address.port}`;
|
||||
}
|
||||
|
||||
async function closeServer(server: Server): Promise<void> {
|
||||
if (!server.listening) {
|
||||
return;
|
||||
}
|
||||
const closed = new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
server.closeAllConnections();
|
||||
await closed;
|
||||
}
|
||||
|
||||
function useLoopbackFetch(baseUrl: string): void {
|
||||
const nativeFetch = globalThis.fetch.bind(globalThis);
|
||||
vi.stubGlobal("fetch", (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const requestUrl =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return nativeFetch(new URL(requestUrl, baseUrl), init);
|
||||
});
|
||||
}
|
||||
|
||||
function rejectedResponse(status: number, cancel: () => Promise<void>): Response {
|
||||
return {
|
||||
body: { cancel },
|
||||
bodyUsed: false,
|
||||
ok: false,
|
||||
status,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe("catalog icon loader", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -76,4 +119,100 @@ describe("catalog icon loader", () => {
|
||||
).resolves.toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes streaming auth failures before retrying and returning", async () => {
|
||||
let requestCount = 0;
|
||||
const closedResponses = new Set<number>();
|
||||
const server = createServer((request, response) => {
|
||||
const requestIndex = requestCount++;
|
||||
request.socket.once("close", () => closedResponses.add(requestIndex));
|
||||
response.writeHead(requestIndex === 0 ? 401 : 404, {
|
||||
"content-type": "text/plain",
|
||||
});
|
||||
response.write("stream remains open");
|
||||
});
|
||||
const baseUrl = await listenOnLoopback(server);
|
||||
useLoopbackFetch(baseUrl);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
fetchPluginIconBlobUrl({
|
||||
auth: {
|
||||
hello: { auth: { deviceToken: "stale-device-token" } },
|
||||
settings: { token: "fallback-token" },
|
||||
},
|
||||
basePath: "",
|
||||
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
|
||||
pluginId: "streaming-errors",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
await vi.waitFor(() => expect(closedResponses).toEqual(new Set([0, 1])), {
|
||||
timeout: 1_000,
|
||||
});
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it("closes a streaming response rejected by MIME type", async () => {
|
||||
let socketClosed = false;
|
||||
const server = createServer((request, response) => {
|
||||
request.socket.once("close", () => {
|
||||
socketClosed = true;
|
||||
});
|
||||
response.writeHead(200, { "content-type": "text/plain" });
|
||||
response.write("not an icon");
|
||||
});
|
||||
const baseUrl = await listenOnLoopback(server);
|
||||
useLoopbackFetch(baseUrl);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
fetchPluginIconBlobUrl({
|
||||
auth,
|
||||
basePath: "",
|
||||
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
|
||||
pluginId: "wrong-mime",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
await vi.waitFor(() => expect(socketClosed).toBe(true), { timeout: 1_000 });
|
||||
} finally {
|
||||
await closeServer(server);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not wait for a stalled response cancellation before auth fallback", async () => {
|
||||
let resolveCancellation!: () => void;
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
resolveCancellation = resolve;
|
||||
});
|
||||
const cancel = vi.fn(() => cancellation);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(rejectedResponse(401, cancel))
|
||||
.mockResolvedValueOnce(rejectedResponse(404, cancel));
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
await expect(
|
||||
fetchPluginIconBlobUrl({
|
||||
auth: {
|
||||
hello: { auth: { deviceToken: "stale-device-token" } },
|
||||
settings: { token: "fallback-token" },
|
||||
},
|
||||
basePath: "",
|
||||
gatewayUrl: window.location.origin.replace(/^http/u, "ws"),
|
||||
pluginId: "stalled-cancellation",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(cancel).toHaveBeenCalledTimes(2);
|
||||
resolveCancellation();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,6 +331,14 @@ type FetchProxiedIconParams = {
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
function cancelUnreadResponseBody(response: Response): void {
|
||||
if (!response.bodyUsed) {
|
||||
// Cancellation is best-effort cleanup; a stalled stream must not block
|
||||
// auth fallback or completion of the rejected icon request.
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProxiedIconBlobUrl(
|
||||
params: FetchProxiedIconParams,
|
||||
routeUrl: string,
|
||||
@@ -354,6 +362,9 @@ async function fetchProxiedIconBlobUrl(
|
||||
signal: params.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Retry and rejection paths never consume the stream. Release it without
|
||||
// delaying the auth fallback or the rejected icon result.
|
||||
cancelUnreadResponseBody(response);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
continue;
|
||||
}
|
||||
@@ -361,6 +372,7 @@ async function fetchProxiedIconBlobUrl(
|
||||
}
|
||||
const contentType = normalizeMimeType(response.headers.get("content-type"));
|
||||
if (!ALLOWED_PLUGIN_ICON_MIME_TYPES.has(contentType)) {
|
||||
cancelUnreadResponseBody(response);
|
||||
return null;
|
||||
}
|
||||
const source = await response.blob();
|
||||
|
||||
Reference in New Issue
Block a user