From f0821dddc7285130b78d69b918ba90b13bb4c109 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 31 Aug 2026 12:27:47 +0200 Subject: [PATCH 1/2] Expose a shared response body view Byte-array consumers currently pay for an aggregate copy even when a response has a single body part. Add an explicit read-only view accessor so callers can opt into sharing while the existing accessor retains its defensive-copy contract. Reuse the view for string decoding and cover eager, lazy, multipart, and third-party Response implementations. Refs #2321 Codex on behalf of Pavel Ptashyts Co-Authored-By: OpenAI Codex --- .../java/org/asynchttpclient/Response.java | 17 ++++ .../asynchttpclient/netty/NettyResponse.java | 17 ++-- .../netty/NettyAsyncResponseTest.java | 91 +++++++++++++++++-- 3 files changed, 107 insertions(+), 18 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/Response.java b/client/src/main/java/org/asynchttpclient/Response.java index 77512094d..9835d2cf4 100644 --- a/client/src/main/java/org/asynchttpclient/Response.java +++ b/client/src/main/java/org/asynchttpclient/Response.java @@ -55,6 +55,23 @@ public interface Response { */ byte[] getResponseBodyAsBytes(); + /** + * Returns the entire response body as a byte array that may share its storage with this response. + * + *

The returned array must be treated as read-only. Modifying it may change the content subsequently returned + * by this response's other body accessors. + * + *

The implementation is not required to return shared storage. Depending on the response representation, this + * method may still return a copy. No array identity is guaranteed between calls. + * + *

Use {@link #getResponseBodyAsBytes()} when an independently owned, mutable array is required. + * + * @return the entire response body as a possibly shared byte array + */ + default byte[] getResponseBodyAsBytesView() { + return getResponseBodyAsBytes(); + } + /** * Return the entire response body as a ByteBuffer. * diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java index 8d80bcbb1..edf97c8e2 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java @@ -193,6 +193,11 @@ public byte[] getResponseBodyAsBytes() { return getResponseBodyAsByteBuffer().array(); } + @Override + public byte[] getResponseBodyAsBytesView() { + return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes(); + } + @Override public ByteBuffer getResponseBodyAsByteBuffer() { @@ -224,19 +229,9 @@ 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) { - return new String(sharedBodyBytes(), charset); + return new String(getResponseBodyAsBytesView(), charset); } @Override diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java index 5ce4982d5..095a349f8 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.asynchttpclient.Response; import org.junit.jupiter.api.Test; import java.io.IOException; @@ -32,9 +33,14 @@ import java.util.TimeZone; import static io.netty.handler.codec.http.HttpHeaderNames.SET_COOKIE; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; public class NettyAsyncResponseTest { @@ -113,6 +119,8 @@ public void testGetResponseBodyDecodesOnePartAndSplitPartsIdentically() { assertEquals(expected, single.getResponseBody(StandardCharsets.UTF_8)); assertEquals(expected, multiple.getResponseBody(StandardCharsets.UTF_8)); + assertArrayEquals(utf8, single.getResponseBodyAsBytesView()); + assertArrayEquals(utf8, multiple.getResponseBodyAsBytesView()); } @Test @@ -120,25 +128,94 @@ public void testGetResponseBodyReadsOnlyALazyPartsReadableRegion() throws IOExce // 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); + ByteBuf slice = Unpooled.wrappedBuffer(backing).slice(3, 11); + int readerIndex = slice.readerIndex(); + int writerIndex = slice.writerIndex(); + int refCnt = slice.refCnt(); + try { + List bodyParts = new LinkedList<>(); + bodyParts.add(new LazyResponseBodyPart(slice, true)); + NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, bodyParts); + + assertArrayEquals("Hello World".getBytes(StandardCharsets.UTF_8), response.getResponseBodyAsBytesView()); + assertEquals("Hello World", response.getResponseBody(StandardCharsets.UTF_8)); + assertEquals("Hello World", + new String(response.getResponseBodyAsStream().readAllBytes(), StandardCharsets.UTF_8)); + assertEquals(readerIndex, slice.readerIndex()); + assertEquals(writerIndex, slice.writerIndex()); + assertEquals(refCnt, slice.refCnt()); + } finally { + slice.release(); + } + } + + @Test + public void testGetResponseBodyAsBytesViewReadsDirectLazyPart() { + byte[] backing = "XXXHello WorldYYY".getBytes(StandardCharsets.UTF_8); + ByteBuf direct = Unpooled.directBuffer(backing.length); + direct.writeBytes(backing); + ByteBuf slice = direct.slice(3, 11); + int readerIndex = slice.readerIndex(); + int writerIndex = slice.writerIndex(); + int refCnt = slice.refCnt(); + try { + List bodyParts = new LinkedList<>(); + bodyParts.add(new LazyResponseBodyPart(slice, true)); + NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, bodyParts); + + assertArrayEquals("Hello World".getBytes(StandardCharsets.UTF_8), response.getResponseBodyAsBytesView()); + assertEquals(readerIndex, slice.readerIndex()); + assertEquals(writerIndex, slice.writerIndex()); + assertEquals(refCnt, slice.refCnt()); + } finally { + direct.release(); + } + } + + @Test + public void testGetResponseBodyAsBytesViewSharesOneEagerPart() { List bodyParts = new LinkedList<>(); - bodyParts.add(new LazyResponseBodyPart(Unpooled.wrappedBuffer(backing, 3, 11), true)); + bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer("Hello World".getBytes(StandardCharsets.UTF_8)), 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)); + byte[] view = response.getResponseBodyAsBytesView(); + assertSame(bodyParts.get(0).getBodyPartBytes(), view); + assertSame(view, response.getResponseBodyAsBytesView()); } @Test public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() { + byte[] expected = "Hello World".getBytes(StandardCharsets.UTF_8); List bodyParts = new LinkedList<>(); - bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer("Hello World".getBytes(StandardCharsets.UTF_8)), true)); + bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(expected), 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()); + byte[] firstCopy = response.getResponseBodyAsBytes(); + byte[] secondCopy = response.getResponseBodyAsBytes(); + assertNotSame(firstCopy, secondCopy); + assertNotSame(bodyParts.get(0).getBodyPartBytes(), firstCopy); + + firstCopy[0] = 'X'; + assertArrayEquals(expected, response.getResponseBodyAsBytes()); + assertArrayEquals(expected, response.getResponseBodyAsBytesView()); + } + + @Test + public void testGetResponseBodyAsBytesViewReturnsEmptyArray() { + NettyResponse response = new NettyResponse(new NettyResponseStatus(null, null, null), null, new LinkedList<>()); + + assertArrayEquals(new byte[0], response.getResponseBodyAsBytesView()); + } + + @Test + public void testGetResponseBodyAsBytesViewDefaultImplementationDelegates() { + byte[] expected = "Hello World".getBytes(StandardCharsets.UTF_8); + Response response = mock(Response.class, CALLS_REAL_METHODS); + doReturn(expected).when(response).getResponseBodyAsBytes(); + + assertSame(expected, response.getResponseBodyAsBytesView()); } @Test From eb067d81085d5068d0e1656f5555df7481832b04 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:34:25 +0200 Subject: [PATCH 2/2] Make default response test portable Mockito 4 cannot invoke interface default methods through CALLS_REAL_METHODS on JDK 21 and newer. Invoke the Response default method explicitly so delegation remains covered on every supported JDK. Refs #2321 Codex on behalf of Pavel Ptashyts Co-Authored-By: OpenAI Codex --- .../netty/NettyAsyncResponseTest.java | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java index 095a349f8..70edcfaa0 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java @@ -24,6 +24,8 @@ import java.io.IOException; import java.io.OutputStream; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Date; @@ -38,9 +40,8 @@ import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.CALLS_REAL_METHODS; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; public class NettyAsyncResponseTest { @@ -210,12 +211,17 @@ public void testGetResponseBodyAsBytesViewReturnsEmptyArray() { } @Test - public void testGetResponseBodyAsBytesViewDefaultImplementationDelegates() { + public void testGetResponseBodyAsBytesViewDefaultImplementationDelegates() throws Throwable { byte[] expected = "Hello World".getBytes(StandardCharsets.UTF_8); - Response response = mock(Response.class, CALLS_REAL_METHODS); - doReturn(expected).when(response).getResponseBodyAsBytes(); + Response response = mock(Response.class); + when(response.getResponseBodyAsBytes()).thenReturn(expected); - assertSame(expected, response.getResponseBodyAsBytesView()); + byte[] actual = (byte[]) MethodHandles.privateLookupIn(Response.class, MethodHandles.lookup()) + .findSpecial(Response.class, "getResponseBodyAsBytesView", MethodType.methodType(byte[].class), Response.class) + .bindTo(response) + .invokeExact(); + + assertSame(expected, actual); } @Test