fix(tools): release bounded web response readers

This commit is contained in:
Vincent Koc
2026-06-19 08:50:24 +02:00
parent b972feb3f7
commit 0dbac0d5f9
2 changed files with 66 additions and 2 deletions

View File

@@ -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<void>;
releaseLock: () => void;
}): Response {
const chunks: Array<ReadableStreamReadResult<Uint8Array>> = 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<Uint8Array>;
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);
});
});

View File

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