From fcec95ffd7a589d2b1a3af646bba2de371c67e97 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 09:26:03 +0200 Subject: [PATCH] fix(signal): cancel status-only response bodies --- .../signal/src/client-container.test.ts | 19 ++++++++++++++ extensions/signal/src/client-container.ts | 26 ++++++++++++++----- 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/extensions/signal/src/client-container.test.ts b/extensions/signal/src/client-container.test.ts index 2a338c3dff5..82279e8ea70 100644 --- a/extensions/signal/src/client-container.test.ts +++ b/extensions/signal/src/client-container.test.ts @@ -130,6 +130,22 @@ describe("containerCheck", () => { expectFirstFetchCall("http://localhost:8080/v1/about", "GET"); }); + it("cancels /v1/about response bodies after simple health checks", async () => { + const cancel = vi.fn(async () => undefined); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: { cancel }, + }); + + await expect(containerCheck("http://localhost:8080")).resolves.toEqual({ + ok: true, + status: 200, + error: null, + }); + expect(cancel).toHaveBeenCalledTimes(1); + }); + it("returns ok:false when /v1/about returns 404", async () => { mockFetch.mockResolvedValue({ ok: false, @@ -635,9 +651,11 @@ describe("containerFetchAttachment", () => { }); it("returns null on non-ok response", async () => { + const cancel = vi.fn(async () => undefined); mockFetch.mockResolvedValue({ ok: false, status: 404, + body: { cancel }, }); const result = await containerFetchAttachment("attachment-123", { @@ -645,6 +663,7 @@ describe("containerFetchAttachment", () => { }); expect(result).toBeNull(); + expect(cancel).toHaveBeenCalledTimes(1); }); it("encodes attachment ID in URL", async () => { diff --git a/extensions/signal/src/client-container.ts b/extensions/signal/src/client-container.ts index ce77aa0b950..79b7c034443 100644 --- a/extensions/signal/src/client-container.ts +++ b/extensions/signal/src/client-container.ts @@ -111,6 +111,12 @@ async function readCappedResponseBuffer(res: Response, maxResponseBytes: number) }); } +async function releaseUnreadResponseBody(res: Response | undefined): Promise { + if (res?.bodyUsed !== true) { + await res?.body?.cancel().catch(() => undefined); + } +} + /** * Check if bbernhard container REST API is available. */ @@ -120,8 +126,9 @@ export async function containerCheck( account?: string, ): Promise<{ ok: boolean; status?: number | null; error?: string | null }> { const normalized = normalizeBaseUrl(baseUrl); + let res: Response | undefined; try { - const res = await fetchWithTimeout(`${normalized}/v1/about`, { method: "GET" }, timeoutMs); + res = await fetchWithTimeout(`${normalized}/v1/about`, { method: "GET" }, timeoutMs); if (!res.ok) { return { ok: false, status: res.status, error: `HTTP ${res.status}` }; } @@ -136,6 +143,8 @@ export async function containerCheck( status: null, error: err instanceof Error ? err.message : String(err), }; + } finally { + await releaseUnreadResponseBody(res); } } @@ -253,14 +262,19 @@ export async function containerFetchAttachment( ): Promise { const baseUrl = normalizeBaseUrl(opts.baseUrl); const url = `${baseUrl}/v1/attachments/${encodeURIComponent(attachmentId)}`; + let res: Response | undefined; - const res = await fetchWithTimeout(url, { method: "GET" }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); + try { + res = await fetchWithTimeout(url, { method: "GET" }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS); - if (!res.ok) { - return null; + if (!res.ok) { + return null; + } + + return await readCappedResponseBuffer(res, normalizeMaxResponseBytes(opts.maxResponseBytes)); + } finally { + await releaseUnreadResponseBody(res); } - - return readCappedResponseBuffer(res, normalizeMaxResponseBytes(opts.maxResponseBytes)); } /**