From d68f19876bbfec391b39886a4ec48bf8a5db5139 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:58:22 +0200 Subject: [PATCH 1/3] Decode a lone response body part in place Response#getResponseBody went through getResponseBodyAsBytes, which concatenates every body part into a freshly allocated array. A body that arrived in a single read was therefore copied twice: once to concatenate a single part with nothing, and once to decode. That is the common case for responses small enough to land in one socket read, and it is the path the default AsyncCompletionHandlerBase puts every caller of executeRequest(request) on. Decode straight from the part when there is exactly one. A rough probe on JDK 17 over a single-part ASCII body measured 496 -> 47 ns at 512 B, 2277 -> 373 ns at 4 KB, 2963 -> 1450 ns at 16 KB and 24913 -> 12248 ns at 128 KB, alongside one fewer whole-body allocation. Several parts are still concatenated before decoding, never decoded one at a time, because a multi-byte character can straddle a part boundary. The new test pins that by splitting a two-byte UTF-8 character across two parts and asserting both shapes decode alike. getResponseBodyAsBytes and getResponseBodyAsByteBuffer are deliberately left alone: they hand the array to the caller, so they keep copying rather than expose a part's own array. A second test pins that too. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5 --- .../asynchttpclient/netty/NettyResponse.java | 8 +++++ .../netty/NettyAsyncResponseTest.java | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java index 31c7cb2f04..6cab997f31 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java @@ -226,6 +226,14 @@ public String getResponseBody() { @Override public String getResponseBody(Charset charset) { + // Decode a lone body part straight from its own bytes. getResponseBodyAsBytes concatenates every part + // into a fresh array first, so a body that arrived in a single read was copied twice, once to + // concatenate and once to decode. The array does not escape this method, so decoding the part's own + // one is safe. Several parts are still concatenated before decoding rather than decoded one at a + // time, because a multi-byte character can straddle a part boundary. + if (bodyParts.size() == 1) { + return new String(bodyParts.get(0).getBodyPartBytes(), charset); + } return new String(getResponseBodyAsBytes(), charset); } diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java index 5172bae7af..898d7b6665 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java @@ -19,6 +19,7 @@ import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.cookie.Cookie; import org.asynchttpclient.HttpResponseBodyPart; +import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; @@ -30,6 +31,7 @@ import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertTrue; public class NettyAsyncResponseTest { @@ -90,4 +92,36 @@ public void testGetResponseBodyAsByteBuffer() { assertEquals("Hello World", body.toString(StandardCharsets.UTF_8)); body.release(); } + + @Test + public void testGetResponseBodyDecodesOnePartAndSplitPartsIdentically() { + byte[] utf8 = {'c', 'a', 'f', (byte) 0xC3, (byte) 0xA9, ' ', (byte) 0xC3, (byte) 0xBC, 'b', 'e', 'r'}; + String expected = new String(utf8, StandardCharsets.UTF_8); + // 0xC3 0xA9 encodes U+00E9; split between its two bytes so neither half decodes on its own + int split = 4; + + List onePart = new LinkedList<>(); + onePart.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8), true)); + NettyResponse single = new NettyResponse(new NettyResponseStatus(null, null, null), null, onePart); + + List splitParts = new LinkedList<>(); + splitParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8, 0, split), false)); + splitParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(utf8, split, utf8.length - split), true)); + NettyResponse multiple = new NettyResponse(new NettyResponseStatus(null, null, null), null, splitParts); + + assertEquals(expected, single.getResponseBody(StandardCharsets.UTF_8)); + assertEquals(expected, multiple.getResponseBody(StandardCharsets.UTF_8)); + } + + @Test + public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() { + List bodyParts = new LinkedList<>(); + bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer("Hello World".getBytes(StandardCharsets.UTF_8)), true)); + NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, bodyParts); + + // getResponseBody may decode a lone part in place, but getResponseBodyAsBytes hands the array to the + // caller, so it must keep copying rather than expose the part's own array. + assertNotSame(response.getResponseBodyAsBytes(), response.getResponseBodyAsBytes()); + assertNotSame(bodyParts.get(0).getBodyPartBytes(), response.getResponseBodyAsBytes()); + } } From 93cae0406503dd18fffe94c8ae0b4b2a8f4f91e0 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Tue, 4 Aug 2026 08:30:51 +0200 Subject: [PATCH 2/3] Share the lone part shortcut with the stream Review feedback on #2303. getResponseBodyAsStream wraps the bytes in a ByteArrayInputStream, which never exposes the array it holds, so it can read a lone part's own array for the same reason getResponseBody can. Both now go through one private sharedBodyBytes(), which keeps the guard and the constraint on it in a single place. Cut the comment down. Two of its five lines described what the previous version did, which belongs in a commit message, and the file carries no other comments. Add a case for a Lazy part over a slice. Lazy is where the shortcut is least obvious, because its getBodyPartBytes returns only the readable region rather than the whole backing array, so a later change reaching for getBodyByteBuf().array() would read the surrounding bytes instead. The case covers both the decode and the stream path. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5 --- .../asynchttpclient/netty/NettyResponse.java | 23 +++++++++++-------- .../netty/NettyAsyncResponseTest.java | 15 ++++++++++++ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java index 6cab997f31..2dda619417 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java @@ -224,22 +224,25 @@ public String getResponseBody() { return getResponseBody(withDefault(extractContentTypeCharsetAttribute(getContentType()), UTF_8)); } + /** + * The body as bytes, for callers that keep the array to themselves. A lone part's own array is returned + * rather than a copy of it, so a caller that let it out would let the part's buffer be mutated through it; + * {@link #getResponseBodyAsBytes()} is the copying variant for those. Several parts are concatenated + * because a multi-byte character can straddle a part boundary. + */ + private byte[] sharedBodyBytes() { + return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes(); + } + @Override public String getResponseBody(Charset charset) { - // Decode a lone body part straight from its own bytes. getResponseBodyAsBytes concatenates every part - // into a fresh array first, so a body that arrived in a single read was copied twice, once to - // concatenate and once to decode. The array does not escape this method, so decoding the part's own - // one is safe. Several parts are still concatenated before decoding rather than decoded one at a - // time, because a multi-byte character can straddle a part boundary. - if (bodyParts.size() == 1) { - return new String(bodyParts.get(0).getBodyPartBytes(), charset); - } - return new String(getResponseBodyAsBytes(), charset); + return new String(sharedBodyBytes(), charset); } @Override public InputStream getResponseBodyAsStream() { - return new ByteArrayInputStream(getResponseBodyAsBytes()); + // ByteArrayInputStream never exposes the array it wraps, so it can read the part's own. + return new ByteArrayInputStream(sharedBodyBytes()); } @Override diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java index 898d7b6665..134923327f 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java @@ -21,6 +21,7 @@ import org.asynchttpclient.HttpResponseBodyPart; import org.junit.jupiter.api.Test; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Date; @@ -113,6 +114,20 @@ public void testGetResponseBodyDecodesOnePartAndSplitPartsIdentically() { assertEquals(expected, multiple.getResponseBody(StandardCharsets.UTF_8)); } + @Test + public void testGetResponseBodyReadsOnlyALazyPartsReadableRegion() throws IOException { + // A Lazy part's getBodyPartBytes returns just the readable region, not the whole backing array, so a + // single-part shortcut must go through it rather than reach for getBodyByteBuf().array(). + byte[] backing = "XXXHello WorldYYY".getBytes(StandardCharsets.UTF_8); + List bodyParts = new LinkedList<>(); + bodyParts.add(new LazyResponseBodyPart(Unpooled.wrappedBuffer(backing, 3, 11), true)); + NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, bodyParts); + + assertEquals("Hello World", response.getResponseBody(StandardCharsets.UTF_8)); + assertEquals("Hello World", + new String(response.getResponseBodyAsStream().readAllBytes(), StandardCharsets.UTF_8)); + } + @Test public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() { List bodyParts = new LinkedList<>(); From 927dec7319d6635b29b8854e4835148ba9385f1e Mon Sep 17 00:00:00 2001 From: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:12:16 +0530 Subject: [PATCH 3/3] Apply suggestions from code review Co-authored-by: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com> --- .../asynchttpclient/netty/NettyResponse.java | 3 +-- .../netty/NettyAsyncResponseTest.java | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java index 2dda619417..8d80bcbb16 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java @@ -241,8 +241,7 @@ public String getResponseBody(Charset charset) { @Override public InputStream getResponseBodyAsStream() { - // ByteArrayInputStream never exposes the array it wraps, so it can read the part's own. - return new ByteArrayInputStream(sharedBodyBytes()); + return new ByteArrayInputStream(getResponseBodyAsBytes()); } @Override diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java index 134923327f..5ce4982d53 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import java.io.IOException; +import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Date; @@ -139,4 +140,27 @@ public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() { assertNotSame(response.getResponseBodyAsBytes(), response.getResponseBodyAsBytes()); assertNotSame(bodyParts.get(0).getBodyPartBytes(), response.getResponseBodyAsBytes()); } + + @Test + public void testGetResponseBodyAsStreamDoesNotShareTheBodyPartArray() throws IOException { + List bodyParts = new LinkedList<>(); + bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer("Hello World".getBytes(StandardCharsets.UTF_8)), true)); + NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, bodyParts); + + // On JDK 11 ByteArrayInputStream.transferTo passes its own array to the OutputStream, so a stream over + // a part's array would put that array in the caller's hands. + byte[][] handedOut = new byte[1][]; + response.getResponseBodyAsStream().transferTo(new OutputStream() { + @Override + public void write(int b) { + } + + @Override + public void write(byte[] b, int off, int len) { + handedOut[0] = b; + } + }); + + assertNotSame(bodyParts.get(0).getBodyPartBytes(), handedOut[0]); + } }