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:
Alix-007
2026-08-11 07:12:41 +08:00
committed by GitHub
parent 3cd034f7a8
commit a287c2d96b
2 changed files with 152 additions and 1 deletions
+140 -1
View File
@@ -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();
});
});
+12
View File
@@ -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();