fix(web-fetch): report exact-limit response bodies as complete, not truncated [AI-assisted] (#102389)

* fix(web-fetch): report exact-limit response bodies as complete, not truncated

* fix(web-fetch): skip zero-byte chunks when confirming overflow past the byte cap

* fix(web-fetch): keep uncertain capped bodies truncated

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Supsumintong
2026-07-09 16:51:46 +08:00
committed by GitHub
parent 5a3422ea8d
commit 574f131554
2 changed files with 123 additions and 3 deletions
+105 -2
View File
@@ -23,14 +23,26 @@ function responseFromReader(params: {
chunks: string[];
cancel: () => Promise<void>;
releaseLock: () => void;
readError?: Error;
}): Response {
const chunks: Array<ReadableStreamReadResult<Uint8Array>> = params.chunks.map((chunk) => ({
done: false,
value: new TextEncoder().encode(chunk),
}));
chunks.push({ done: true, value: undefined });
if (!params.readError) {
chunks.push({ done: true, value: undefined });
}
const reader = {
read: async () => chunks.shift() ?? { done: true, value: undefined },
read: async () => {
const next = chunks.shift();
if (next) {
return next;
}
if (params.readError) {
throw params.readError;
}
return { done: true, value: undefined };
},
cancel: params.cancel,
releaseLock: params.releaseLock,
} as ReadableStreamDefaultReader<Uint8Array>;
@@ -147,6 +159,97 @@ describe("readResponseText", () => {
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("does not mark exact-limit streamed responses as truncated", async () => {
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const response = responseFromReader({
chunks: ["hello"],
cancel,
releaseLock,
});
await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({
text: "hello",
truncated: false,
bytesRead: 5,
});
expect(cancel).not.toHaveBeenCalled();
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("does not mark multi-chunk exact-limit streamed responses as truncated", async () => {
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const response = responseFromReader({
chunks: ["hel", "lo"],
cancel,
releaseLock,
});
await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({
text: "hello",
truncated: false,
bytesRead: 5,
});
expect(cancel).not.toHaveBeenCalled();
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("marks responses that exceed the limit as truncated after confirming overflow", async () => {
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const response = responseFromReader({
chunks: ["hello", "!"],
cancel,
releaseLock,
});
await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({
text: "hello",
truncated: true,
bytesRead: 5,
});
expect(cancel).toHaveBeenCalledTimes(1);
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("does not mark exact-limit responses as truncated when followed by zero-byte chunks", async () => {
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const response = responseFromReader({
chunks: ["hello", ""],
cancel,
releaseLock,
});
await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({
text: "hello",
truncated: false,
bytesRead: 5,
});
expect(cancel).not.toHaveBeenCalled();
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("keeps exact-limit responses truncated when the confirming read fails", async () => {
const cancel = vi.fn(async () => undefined);
const releaseLock = vi.fn();
const response = responseFromReader({
chunks: ["hello"],
cancel,
releaseLock,
readError: new Error("stream failed before EOF"),
});
await expect(readResponseText(response, { maxBytes: 5 })).resolves.toEqual({
text: "hello",
truncated: true,
bytesRead: 5,
});
expect(cancel).toHaveBeenCalledTimes(1);
expect(releaseLock).toHaveBeenCalledTimes(1);
});
it("does not invoke whole-body fallbacks when maxBytes is set", async () => {
const arrayBuffer = vi.fn(async () => new TextEncoder().encode("hello").buffer);
const text = vi.fn(async () => "hello");
+18 -1
View File
@@ -272,8 +272,25 @@ export async function readResponseText(
bytesRead += chunk.byteLength;
parts.push(chunk);
if (truncated || bytesRead >= maxBytes) {
if (truncated) {
break;
}
if (bytesRead >= maxBytes) {
// Reached the byte cap. A body that is exactly maxBytes bytes is
// complete only once EOF confirms it. Keep the conservative result
// if that confirming read fails or the body continues.
truncated = true;
while (true) {
const { done: atEnd, value: extra } = await reader.read();
if (atEnd) {
truncated = false;
break;
}
if (extra && extra.byteLength > 0) {
truncated = true;
break;
}
}
break;
}
}