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();