From a287c2d96b49873774c08e55b3cb92cf3f57ed01 Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Tue, 11 Aug 2026 07:12:41 +0800
Subject: [PATCH] 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>
---
ui/src/pages/plugins/icon-loader.test.ts | 141 ++++++++++++++++++++++-
ui/src/pages/plugins/icon-loader.ts | 12 ++
2 files changed, 152 insertions(+), 1 deletion(-)
diff --git a/ui/src/pages/plugins/icon-loader.test.ts b/ui/src/pages/plugins/icon-loader.test.ts
index f94b488ce5d3..8bb3beb223da 100644
--- a/ui/src/pages/plugins/icon-loader.test.ts
+++ b/ui/src/pages/plugins/icon-loader.test.ts
@@ -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 {
+ await new Promise((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 {
+ if (!server.listening) {
+ return;
+ }
+ const closed = new Promise((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): 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();
+ 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((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();
+ });
});
diff --git a/ui/src/pages/plugins/icon-loader.ts b/ui/src/pages/plugins/icon-loader.ts
index b67e0d8d74f7..c5525a0dbf32 100644
--- a/ui/src/pages/plugins/icon-loader.ts
+++ b/ui/src/pages/plugins/icon-loader.ts
@@ -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();