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/3] 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/3] 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 From 156282c396f8c917e481e52b9c7f7ec12d763d45 Mon Sep 17 00:00:00 2001 From: Pavel Ptashyts <49400901+pavel-ptashyts@users.noreply.github.com> Date: Sun, 6 Sep 2026 09:43:38 +0200 Subject: [PATCH 3/3] Say what the shared body view does not promise Review feedback on the response body view. The contract was written as though the sharing were the response's to give. It is not. Whether a lone body part exists to share from is a fact about how the body arrived - how the origin chunked it, whether a proxy re-chunked it, whether it was compressed - so the same body from the same server can be shared on one response and copied on the next, and the caller sees no difference. And where an array is shared it is not the response's alone: it is reachable from the part handed to onBodyPartReceived, it is what a TransferCompletionHandler gives each TransferListener, and it is what getResponseBodyAsByteBuf wraps. A write through any of those changes what this returns, and a write through this changes what they see. None of that can be designed away while the method returns a byte array, so the javadoc says it, and says the identity between calls is not guaranteed either. It no longer promises anything on behalf of getResponseBodyAsBytes, which is an interface method whose own contract guarantees no independent array. It records that leaving the default in place while implementing getResponseBodyAsBytes in terms of this method makes the two call each other, which is the first thing an implementor would otherwise write. getResponseBody(Charset) goes back through the private helper rather than the new public method, so overriding the view cannot change what a response's text says as well; the helper keeps the note about multi-byte characters straddling a part boundary, which is the only place that reason is written down, and now says that the array does leave the client rather than claiming it does not. An empty body returns a shared empty array rather than walking the aggregating path to allocate one and a buffer to wrap it. Nothing can be written through a zero-length array. The tests asserted identity where they meant content, so an implementation that shared a corrupted array satisfied them, and one took its expectation from the array it had handed to the part, which would have stopped being an oracle the moment the part stopped copying. The lazy coverage this branch added is dropped: a Response built from lazy parts holds buffers at refCnt 0 on a real request, since channelRead releases in a finally and LazyResponseBodyPart never retains, and a test that keeps one alive by hand signs off on a mode that does not work. That branch is still covered through getResponseBody(Charset), which shares the helper. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5 --- .../java/org/asynchttpclient/Response.java | 30 +++++++--- .../asynchttpclient/netty/NettyResponse.java | 38 +++++++++++- .../netty/NettyAsyncResponseTest.java | 59 ++++++------------- 3 files changed, 76 insertions(+), 51 deletions(-) diff --git a/client/src/main/java/org/asynchttpclient/Response.java b/client/src/main/java/org/asynchttpclient/Response.java index 9835d2cf4..3fc871b49 100644 --- a/client/src/main/java/org/asynchttpclient/Response.java +++ b/client/src/main/java/org/asynchttpclient/Response.java @@ -56,17 +56,33 @@ public interface Response { byte[] getResponseBodyAsBytes(); /** - * Returns the entire response body as a byte array that may share its storage with this response. + * Returns the entire response body as a byte array whose storage the implementation may share with whatever + * else holds the body. * - *

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 returned array must be treated as read-only, and the response is not its only holder. Where it is a + * body part's own array, it is the array reachable from the part handed to + * {@link AsyncHandler#onBodyPartReceived}, the one each + * {@link org.asynchttpclient.handler.TransferListener} is given by a + * {@link org.asynchttpclient.handler.TransferCompletionHandler}, and the one + * {@link #getResponseBodyAsByteBuf()} wraps. Writing to it changes what all of those see, and a write + * through any of them changes what this returns. * - *

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. + *

Whether anything is shared at all is not something to rely on. It depends on how the body happened to + * arrive - how the origin chunked it, whether a proxy re-chunked it, whether it was compressed - and on the + * body parts the implementation was given, none of which is visible from here. The same body from the + * same server may be shared on one response and copied on the next. No array identity is guaranteed between + * calls either. * - *

Use {@link #getResponseBodyAsBytes()} when an independently owned, mutable array is required. + *

A caller that needs an array it may modify should copy what it receives. {@link + * #getResponseBodyAsBytes()} is the accessor to reach for first, but it is implemented by whoever implements + * this interface, so read its contract rather than assuming it hands over an array of its own. * - * @return the entire response body as a possibly shared byte array + *

Implementation note: the default implementation of this method returns + * {@link #getResponseBodyAsBytes()}. An implementation that leaves that default in place must not implement + * {@code getResponseBodyAsBytes()} in terms of this method, or the two call each other. Overriding both is + * fine. + * + * @return the entire response body, possibly sharing storage with the response */ default byte[] getResponseBodyAsBytesView() { return getResponseBodyAsBytes(); diff --git a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java index edf97c8e2..0c080aee6 100755 --- a/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java +++ b/client/src/main/java/org/asynchttpclient/netty/NettyResponse.java @@ -51,6 +51,8 @@ */ public class NettyResponse implements Response { + private static final byte[] EMPTY_BODY = new byte[0]; + private final List bodyParts; private final HttpHeaders headers; private final HttpResponseStatus status; @@ -193,9 +195,18 @@ public byte[] getResponseBodyAsBytes() { return getResponseBodyAsByteBuffer().array(); } + /** + * Returns a lone body part's array; concatenates into one of its own when there are several, or an empty + * array when there are none. Which of those a given response takes is not a property of the body: see + * {@link Response#getResponseBodyAsBytesView()}, whose contract is deliberately weaker than this. + *

+ * Whether a lone part hands over storage of its own is the part's business rather than this response's. + * {@link EagerResponseBodyPart} returns the array it holds; {@link LazyResponseBodyPart} copies out of its + * buffer on every call, so a response made of lazy parts never shares whatever this says. + */ @Override public byte[] getResponseBodyAsBytesView() { - return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes(); + return sharedBodyBytes(); } @Override @@ -229,9 +240,32 @@ public String getResponseBody() { return getResponseBody(withDefault(extractContentTypeCharsetAttribute(getContentType()), UTF_8)); } + /** + * The body as bytes, without a copy where there is one part to take it from. Several parts are concatenated + * because a multi-byte character can straddle a part boundary, which is why the string accessors cannot + * simply decode the first part. + *

+ * The array does leave the client, through {@link #getResponseBodyAsBytesView()}, which is why that method + * documents it as read-only and names the other holders. {@link #getResponseBodyAsBytes()} stays the + * copying accessor for callers who want an array of their own. + *

+ * Private, and called directly by the accessors below rather than through + * {@link #getResponseBodyAsBytesView()}, so that overriding the view does not silently change what this + * response's text says as well. + */ + private byte[] sharedBodyBytes() { + if (bodyParts.isEmpty()) { + // A HEAD, a 204 or a 304 otherwise walks the aggregating path to allocate an empty array and a + // buffer to wrap it, on every call. Nothing can be written through a zero-length array, so one + // shared instance serves every empty body. + return EMPTY_BODY; + } + return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes(); + } + @Override public String getResponseBody(Charset charset) { - return new String(getResponseBodyAsBytesView(), charset); + return new String(sharedBodyBytes(), 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 70edcfaa0..0b6320d4f 100644 --- a/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java +++ b/client/src/test/java/org/asynchttpclient/netty/NettyAsyncResponseTest.java @@ -129,57 +129,26 @@ 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(); - } - } + 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); - @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(); - } + assertEquals("Hello World", response.getResponseBody(StandardCharsets.UTF_8)); + assertEquals("Hello World", + new String(response.getResponseBodyAsStream().readAllBytes(), StandardCharsets.UTF_8)); } @Test public void testGetResponseBodyAsBytesViewSharesOneEagerPart() { + // NettyResponse's own behaviour, not the interface contract: Response#getResponseBodyAsBytesView + // guarantees no identity, deliberately, because whether a body arrives as one part is not up to it. + // What is worth pinning here is that when this response can share, it does, and does not copy instead. 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); byte[] view = response.getResponseBodyAsBytesView(); + assertArrayEquals("Hello World".getBytes(StandardCharsets.UTF_8), view); assertSame(bodyParts.get(0).getBodyPartBytes(), view); assertSame(view, response.getResponseBodyAsBytesView()); } @@ -188,15 +157,21 @@ public void testGetResponseBodyAsBytesViewSharesOneEagerPart() { public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() { byte[] expected = "Hello World".getBytes(StandardCharsets.UTF_8); List bodyParts = new LinkedList<>(); - bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(expected), true)); + // A clone into the part, so that expected stays an oracle: handing the part this very array would make + // it the part's own storage the moment EagerResponseBodyPart stopped copying, and a corrupt response + // would then satisfy both assertions below. + bodyParts.add(new EagerResponseBodyPart(Unpooled.wrappedBuffer(expected.clone()), 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. byte[] firstCopy = response.getResponseBodyAsBytes(); byte[] secondCopy = response.getResponseBodyAsBytes(); + assertArrayEquals(expected, firstCopy); + assertArrayEquals(expected, secondCopy); assertNotSame(firstCopy, secondCopy); assertNotSame(bodyParts.get(0).getBodyPartBytes(), firstCopy); + assertNotSame(bodyParts.get(0).getBodyPartBytes(), secondCopy); firstCopy[0] = 'X'; assertArrayEquals(expected, response.getResponseBodyAsBytes());