From 850dfe97f9c9ef865486cb1e5ab5d764d9174495 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:08:09 +0000 Subject: [PATCH 1/5] http: route identity chunked bodies through the multi-packet decode path handle_response_body_chunked_encoding_from_single_packet decodes into a 16 KiB scratch buffer, then either hands the decoded bytes straight to decompress_bytes (compressed: skips allocating compressed_body) or append_slice_exact's them into body_out_str (identity: scratch copy followed by a second memcpy into the destination). _from_multiple_packets appends the raw bytes into body_out_str once, decodes in the tail, and truncates. For identity bodies that is one memcpy instead of two, so gate the single-packet dispatch on encoding.is_compressed(). bench/snippets/fetch-chunked-small.mjs covers 256 B / 4 KiB / 15 KiB chunked bodies under identity and gzip. On a release build the change is inside run-to-run noise: the avoided memcpy is <=1.5 us against a ~40 us localhost round-trip. --- bench/snippets/fetch-chunked-small.mjs | 76 +++++++++++++++++++ src/http/lib.rs | 11 ++- test/js/web/fetch/fetch-retry-chunked.test.ts | 68 ++++++++++++++++- 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 bench/snippets/fetch-chunked-small.mjs diff --git a/bench/snippets/fetch-chunked-small.mjs b/bench/snippets/fetch-chunked-small.mjs new file mode 100644 index 000000000000..304d77cfa4ad --- /dev/null +++ b/bench/snippets/fetch-chunked-small.mjs @@ -0,0 +1,76 @@ +// Benchmark fetch() decoding small Transfer-Encoding: chunked bodies that +// arrive with their headers in a single read. Exercises the <=16 KiB +// dispatch in the HTTP client's chunked-body handler for both identity and +// gzip Content-Encoding. +import { bench, group, run } from "../runner.mjs"; +import net from "node:net"; +import zlib from "node:zlib"; + +function chunked(buf) { + return Buffer.concat([ + Buffer.from(buf.length.toString(16) + "\r\n"), + buf, + Buffer.from("\r\n0\r\n\r\n"), + ]); +} + +function makeReply(body, gzip) { + const payload = gzip ? zlib.gzipSync(body, { level: 6 }) : body; + return Buffer.concat([ + Buffer.from( + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + (gzip ? "Content-Encoding: gzip\r\n" : "") + + "Connection: keep-alive\r\n" + + "\r\n", + ), + chunked(payload), + ]); +} + +const sizes = [256, 4096, 15 * 1024]; +const bodies = Object.fromEntries(sizes.map(n => [n, Buffer.alloc(n, "x")])); +const replies = {}; +for (const n of sizes) { + replies[`i${n}`] = makeReply(bodies[n], false); + replies[`g${n}`] = makeReply(bodies[n], true); +} + +const server = net.createServer(sock => { + sock.setNoDelay(true); + let pending = Buffer.alloc(0); + sock.on("data", chunk => { + pending = Buffer.concat([pending, chunk]); + while (true) { + const end = pending.indexOf("\r\n\r\n"); + if (end < 0) break; + const head = pending.subarray(0, end).toString("latin1"); + pending = pending.subarray(end + 4); + const m = head.match(/^GET \/(\w+)/); + sock.write(replies[m[1]]); + } + }); +}); + +await new Promise(r => server.listen(0, r)); +const base = `http://127.0.0.1:${server.address().port}`; + +// Warm the keep-alive connection so the first iteration doesn't pay connect. +for (const key of Object.keys(replies)) await fetch(`${base}/${key}`).then(r => r.arrayBuffer()); + +for (const n of sizes) { + group(`chunked ${n}B`, () => { + bench("identity → arrayBuffer()", async () => { + const r = await fetch(`${base}/i${n}`); + await r.arrayBuffer(); + }); + bench("gzip → arrayBuffer()", async () => { + const r = await fetch(`${base}/g${n}`); + await r.arrayBuffer(); + }); + }); +} + +await run(); +server.close(); +process.exit(0); diff --git a/src/http/lib.rs b/src/http/lib.rs index 7288f064ab96..cd4d15513e7c 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -4781,8 +4781,17 @@ impl<'a> HTTPClient<'a> { &mut self, incoming_data: &[u8], ) -> crate::Result { + // The single-packet path decodes into a 16 KiB scratch so a small + // compressed body can be handed straight to `decompress_bytes` without + // ever allocating `compressed_body`. For identity bodies that shortcut + // doesn't exist: scratch copy + `append_slice_exact` is one memcpy more + // than `_from_multiple_packets`' append + decode-in-tail + truncate, so + // route identity bodies there unconditionally. let small_len = 16 * 1024usize; - if incoming_data.len() <= small_len && self.state.get_body_buffer().list.is_empty() { + if self.state.encoding.is_compressed() + && incoming_data.len() <= small_len + && self.state.get_body_buffer().list.is_empty() + { self.handle_response_body_chunked_encoding_from_single_packet(incoming_data) } else { self.handle_response_body_chunked_encoding_from_multiple_packets(incoming_data) diff --git a/test/js/web/fetch/fetch-retry-chunked.test.ts b/test/js/web/fetch/fetch-retry-chunked.test.ts index 5dd9c53c48f6..366262f2a844 100644 --- a/test/js/web/fetch/fetch-retry-chunked.test.ts +++ b/test/js/web/fetch/fetch-retry-chunked.test.ts @@ -4,9 +4,10 @@ // body_out_str == None state itself is not deterministically reachable from // fetch(). -import { expect, test } from "bun:test"; +import { describe, expect, test } from "bun:test"; import type { AddressInfo } from "node:net"; import net from "node:net"; +import zlib from "node:zlib"; // The server drops every third request without responding, so the client // that adopted the pooled socket observes on_close with response_stage == @@ -59,3 +60,68 @@ test("chunked uncompressed body over a retried keep-alive connection", async () await new Promise(r => server.close(() => r())); } }); + +// The <=16 KiB chunked-body dispatcher routes identity bodies through the +// append + decode-in-tail path and compressed bodies through the scratch +// decode path. These pin both paths for a body that fits in one read and for +// one that is split so the first read yields -2 (needs more data). +describe("small chunked body decode", () => { + function chunked(buf: Buffer): Buffer { + return Buffer.concat([Buffer.from(buf.length.toString(16) + "\r\n"), buf, Buffer.from("\r\n0\r\n\r\n")]); + } + + async function serve(reply: Buffer, fn: (url: string) => Promise): Promise { + const sockets = new Set(); + const server = net.createServer(sock => { + sockets.add(sock); + sock.on("close", () => sockets.delete(sock)); + sock.on("error", () => {}); + sock.once("data", () => sock.write(reply)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as AddressInfo; + try { + await fn(`http://127.0.0.1:${port}/`); + } finally { + for (const s of sockets) s.destroy(); + await new Promise(r => server.close(() => r())); + } + } + + function reply(body: Buffer, extraHeader: string): Buffer { + return Buffer.concat([ + Buffer.from( + "HTTP/1.1 200 OK\r\n" + + "Transfer-Encoding: chunked\r\n" + + "Connection: keep-alive\r\n" + + extraHeader + + "\r\n", + ), + chunked(body), + ]); + } + + for (const n of [1, 256, 4096, 15 * 1024]) { + const body = Buffer.alloc(n, "x"); + const gz = zlib.gzipSync(body, { level: 6 }); + + test.concurrent(`identity ${n}B`, async () => { + await serve(reply(body, ""), async url => { + const res = await fetch(url); + expect(res.status).toBe(200); + expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true); + }); + }); + + test.concurrent(`gzip ${n}B`, async () => { + await serve(reply(gz, "Content-Encoding: gzip\r\n"), async url => { + const res = await fetch(url); + expect(res.status).toBe(200); + expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true); + }); + }); + } +}); From 4a504a9208cc94400e82e7c42b7d128cc36324a1 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:10:57 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch-retry-chunked.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/js/web/fetch/fetch-retry-chunked.test.ts b/test/js/web/fetch/fetch-retry-chunked.test.ts index 366262f2a844..2a24f69d5ee3 100644 --- a/test/js/web/fetch/fetch-retry-chunked.test.ts +++ b/test/js/web/fetch/fetch-retry-chunked.test.ts @@ -94,11 +94,7 @@ describe("small chunked body decode", () => { function reply(body: Buffer, extraHeader: string): Buffer { return Buffer.concat([ Buffer.from( - "HTTP/1.1 200 OK\r\n" + - "Transfer-Encoding: chunked\r\n" + - "Connection: keep-alive\r\n" + - extraHeader + - "\r\n", + "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "Connection: keep-alive\r\n" + extraHeader + "\r\n", ), chunked(body), ]); From 2181ace1b7ce27f7cbe34e5c12caaec8f97ce691 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:17:48 +0000 Subject: [PATCH 3/5] test: trim stale -2 claim from small chunked body decode comment --- test/js/web/fetch/fetch-retry-chunked.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/js/web/fetch/fetch-retry-chunked.test.ts b/test/js/web/fetch/fetch-retry-chunked.test.ts index 2a24f69d5ee3..9603545a910c 100644 --- a/test/js/web/fetch/fetch-retry-chunked.test.ts +++ b/test/js/web/fetch/fetch-retry-chunked.test.ts @@ -63,8 +63,7 @@ test("chunked uncompressed body over a retried keep-alive connection", async () // The <=16 KiB chunked-body dispatcher routes identity bodies through the // append + decode-in-tail path and compressed bodies through the scratch -// decode path. These pin both paths for a body that fits in one read and for -// one that is split so the first read yields -2 (needs more data). +// decode path. These pin both paths for a body that fits in one read. describe("small chunked body decode", () => { function chunked(buf: Buffer): Buffer { return Buffer.concat([Buffer.from(buf.length.toString(16) + "\r\n"), buf, Buffer.from("\r\n0\r\n\r\n")]); From a18079ad0f9acfd5fef2dd4029fa7053b7ac8448 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:43:24 +0000 Subject: [PATCH 4/5] test: move small chunked decode tests to fetch-gzip.test.ts fetch-retry-chunked.test.ts is scoped to the BUN-3BZF retry guard; fetch-gzip.test.ts already hosts the chunked + Content-Encoding decode coverage and has createNetServer / the chunked framing pattern. Collapsed the four gzip sizes into one control case using incompressible randomBytes so the compressed payload is actually multi-KiB rather than a ~50 B gzipped run of 'x'. --- test/js/web/fetch/fetch-gzip.test.ts | 51 +++++++++++++++ test/js/web/fetch/fetch-retry-chunked.test.ts | 63 +------------------ 2 files changed, 52 insertions(+), 62 deletions(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 701fbf4208d8..5c95a17a912e 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -1,6 +1,7 @@ import { Socket } from "bun"; import { beforeAll, describe, expect, it } from "bun:test"; import { bunEnv, bunExe, gcTick } from "harness"; +import { randomBytes } from "node:crypto"; import { once } from "node:events"; import { createServer } from "node:http"; import { createServer as createNetServer } from "node:net"; @@ -906,3 +907,53 @@ describe("empty compressed responses", () => { }); } }); + +// The <=16 KiB chunked-body dispatcher routes identity bodies through the +// append + decode-in-tail path and compressed bodies through the scratch +// decode path. These pin both paths for a body that fits in one read. +describe("small chunked body decode", () => { + function chunked(buf: Buffer): Buffer { + return Buffer.concat([Buffer.from(buf.length.toString(16) + "\r\n"), buf, Buffer.from("\r\n0\r\n\r\n")]); + } + + async function serve(extraHeader: string, body: Buffer, expected: Buffer): Promise { + const reply = Buffer.concat([ + Buffer.from( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n" + extraHeader + "\r\n", + ), + chunked(body), + ]); + const sockets = new Set(); + const server = createNetServer(sock => { + sockets.add(sock); + sock.on("close", () => sockets.delete(sock)); + sock.on("error", () => {}); + sock.once("data", () => sock.write(reply)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const { port } = server.address() as import("node:net").AddressInfo; + try { + const res = await fetch(`http://127.0.0.1:${port}/`); + expect(res.status).toBe(200); + expect(Buffer.from(await res.arrayBuffer()).equals(expected)).toBe(true); + } finally { + for (const s of sockets) s.destroy(); + await new Promise(r => server.close(() => r())); + } + } + + for (const n of [1, 4096, 15 * 1024]) { + const body = Buffer.alloc(n, "x"); + it.concurrent(`identity ${n}B`, () => serve("", body, body)); + } + + // One compressed case as the unchanged-path control; randomBytes keeps the + // compressed size near the input size so the test exercises the 16 KiB gate + // with a multi-KiB payload rather than a ~50 B gzipped run of 'x'. + const raw = randomBytes(4096); + const gz = gzipSync(raw, { level: 6 }); + it.concurrent(`gzip ${gz.length}B compressed`, () => serve("Content-Encoding: gzip\r\n", gz, raw)); +}); diff --git a/test/js/web/fetch/fetch-retry-chunked.test.ts b/test/js/web/fetch/fetch-retry-chunked.test.ts index 9603545a910c..5dd9c53c48f6 100644 --- a/test/js/web/fetch/fetch-retry-chunked.test.ts +++ b/test/js/web/fetch/fetch-retry-chunked.test.ts @@ -4,10 +4,9 @@ // body_out_str == None state itself is not deterministically reachable from // fetch(). -import { describe, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import type { AddressInfo } from "node:net"; import net from "node:net"; -import zlib from "node:zlib"; // The server drops every third request without responding, so the client // that adopted the pooled socket observes on_close with response_stage == @@ -60,63 +59,3 @@ test("chunked uncompressed body over a retried keep-alive connection", async () await new Promise(r => server.close(() => r())); } }); - -// The <=16 KiB chunked-body dispatcher routes identity bodies through the -// append + decode-in-tail path and compressed bodies through the scratch -// decode path. These pin both paths for a body that fits in one read. -describe("small chunked body decode", () => { - function chunked(buf: Buffer): Buffer { - return Buffer.concat([Buffer.from(buf.length.toString(16) + "\r\n"), buf, Buffer.from("\r\n0\r\n\r\n")]); - } - - async function serve(reply: Buffer, fn: (url: string) => Promise): Promise { - const sockets = new Set(); - const server = net.createServer(sock => { - sockets.add(sock); - sock.on("close", () => sockets.delete(sock)); - sock.on("error", () => {}); - sock.once("data", () => sock.write(reply)); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const { port } = server.address() as AddressInfo; - try { - await fn(`http://127.0.0.1:${port}/`); - } finally { - for (const s of sockets) s.destroy(); - await new Promise(r => server.close(() => r())); - } - } - - function reply(body: Buffer, extraHeader: string): Buffer { - return Buffer.concat([ - Buffer.from( - "HTTP/1.1 200 OK\r\n" + "Transfer-Encoding: chunked\r\n" + "Connection: keep-alive\r\n" + extraHeader + "\r\n", - ), - chunked(body), - ]); - } - - for (const n of [1, 256, 4096, 15 * 1024]) { - const body = Buffer.alloc(n, "x"); - const gz = zlib.gzipSync(body, { level: 6 }); - - test.concurrent(`identity ${n}B`, async () => { - await serve(reply(body, ""), async url => { - const res = await fetch(url); - expect(res.status).toBe(200); - expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true); - }); - }); - - test.concurrent(`gzip ${n}B`, async () => { - await serve(reply(gz, "Content-Encoding: gzip\r\n"), async url => { - const res = await fetch(url); - expect(res.status).toBe(200); - expect(Buffer.from(await res.arrayBuffer()).equals(body)).toBe(true); - }); - }); - } -}); From 087ed6b8a8f5e696212fdcd7cee5bce1441fb805 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 07:45:25 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- test/js/web/fetch/fetch-gzip.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 5c95a17a912e..38ffa04837fa 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -918,9 +918,7 @@ describe("small chunked body decode", () => { async function serve(extraHeader: string, body: Buffer, expected: Buffer): Promise { const reply = Buffer.concat([ - Buffer.from( - "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n" + extraHeader + "\r\n", - ), + Buffer.from("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n" + extraHeader + "\r\n"), chunked(body), ]); const sockets = new Set();