From 0dbac0d5f962114bad5b4222e63be87af86b4663 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 08:50:24 +0200 Subject: [PATCH] fix(tools): release bounded web response readers --- src/agents/tools/web-shared.test.ts | 65 ++++++++++++++++++++++++++++- src/agents/tools/web-shared.ts | 3 ++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/agents/tools/web-shared.test.ts b/src/agents/tools/web-shared.test.ts index b1c6496efa99..85c82001ab19 100644 --- a/src/agents/tools/web-shared.test.ts +++ b/src/agents/tools/web-shared.test.ts @@ -1,5 +1,5 @@ -// Shared web helper tests cover timeout normalization and process-local cache -// expiry guards. +// Shared web helper tests cover timeout normalization, process-local cache +// expiry guards, and bounded response body cleanup. import { MAX_TIMER_TIMEOUT_MS, MAX_TIMER_TIMEOUT_SECONDS, @@ -7,6 +7,7 @@ import { import { afterEach, describe, expect, it, vi } from "vitest"; import { readCache, + readResponseText, resolvePositiveTimeoutSeconds, resolveTimeoutSeconds, withTimeout, @@ -18,6 +19,28 @@ afterEach(() => { vi.restoreAllMocks(); }); +function responseFromReader(params: { + chunks: string[]; + cancel: () => Promise; + releaseLock: () => void; +}): Response { + const chunks: Array> = params.chunks.map((chunk) => ({ + done: false, + value: new TextEncoder().encode(chunk), + })); + chunks.push({ done: true, value: undefined }); + const reader = { + read: async () => chunks.shift() ?? { done: true, value: undefined }, + cancel: params.cancel, + releaseLock: params.releaseLock, + } as ReadableStreamDefaultReader; + + return { + body: { getReader: () => reader }, + headers: new Headers({ "content-type": "text/plain; charset=utf-8" }), + } as Response; +} + describe("web shared timeout seconds", () => { it("caps timeoutSeconds at the shared timer-safe ceiling", () => { expect(resolveTimeoutSeconds(Number.MAX_SAFE_INTEGER, 30)).toBe(MAX_TIMER_TIMEOUT_SECONDS); @@ -86,3 +109,41 @@ describe("web shared withTimeout", () => { expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS); }); }); + +describe("readResponseText", () => { + it("releases bounded response readers after complete reads", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hello", " world"], + cancel, + releaseLock, + }); + + await expect(readResponseText(response, { maxBytes: 64 })).resolves.toEqual({ + text: "hello world", + truncated: false, + bytesRead: 11, + }); + expect(cancel).not.toHaveBeenCalled(); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); + + it("cancels and releases bounded response readers after truncation", async () => { + const cancel = vi.fn(async () => undefined); + const releaseLock = vi.fn(); + const response = responseFromReader({ + chunks: ["hello world"], + cancel, + releaseLock, + }); + + await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({ + text: "hello", + truncated: true, + bytesRead: 5, + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(releaseLock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/tools/web-shared.ts b/src/agents/tools/web-shared.ts index e7e84ed9087e..38edcb581cef 100644 --- a/src/agents/tools/web-shared.ts +++ b/src/agents/tools/web-shared.ts @@ -285,6 +285,9 @@ export async function readResponseText( // let cleanup turn a bounded read into a hung fetch. void reader.cancel().catch(() => undefined); } + try { + reader.releaseLock(); + } catch {} } const bytes = concatBytes(parts, bytesRead);