Provide an opt-in zero-copy response body view - #2322
Conversation
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 AsyncHttpClient#2321 Codex on behalf of Pavel Ptashyts Co-Authored-By: OpenAI Codex <noreply@openai.com>
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 AsyncHttpClient#2321 Codex on behalf of Pavel Ptashyts Co-Authored-By: OpenAI Codex <noreply@openai.com>
|
|
||
| @Override | ||
| public byte[] getResponseBodyAsBytesView() { | ||
| return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes(); |
There was a problem hiding this comment.
bodyParts.size() == 1 is not a property of the response, it is the number of non-empty chunks we happened to read. The same 48 byte body handed over in one chunk gives the caller our array, in two chunks gives them a copy. Any proxy that re-chunks flips it. gzip flips it the other way, because the decompressor output does not get split at httpClientCodecMaxChunkSize, so a 20000 byte body arrives as 4 parts uncompressed and 1 part compressed.
It is not even stable for a fixed server. On a fresh connection the split is at 2048 bytes, on a warm pooled one it is around 8192, so the caller gets a copy on request 1 and our array on request 2. A test that makes one client per test with a 4 KiB fixture never sees the sharing path.
| } | ||
|
|
||
| @Override | ||
| public byte[] getResponseBodyAsBytesView() { |
There was a problem hiding this comment.
This array is not only ours. EagerResponseBodyPart returns its bytes field by identity, and that is the same array we already passed to the user's AsyncHandler.onBodyPartReceived, the same one TransferCompletionHandler gives to every TransferListener, and the one ResumableAsyncHandler wraps writable in a ByteBuffer. So the caller can corrupt those, and they can corrupt the caller's view after we returned it.
| @Override | ||
| public String getResponseBody(Charset charset) { | ||
| return new String(sharedBodyBytes(), charset); | ||
| return new String(getResponseBodyAsBytesView(), charset); |
There was a problem hiding this comment.
This used to go through the private sharedBodyBytes(). Now it is a virtual call to a public method on a non-final class, so any subclass that overrides the view silently changes getResponseBody(), getResponseBody(Charset) and toString() as well, and the String and the bytes of one response can disagree. getStatusCode, getHeaders and isRedirected right next to it are final. Can we keep a private helper here and leave the public method for callers?
| return getResponseBody(withDefault(extractContentTypeCharsetAttribute(getContentType()), UTF_8)); | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
This comment was carrying two things. The reason multi part gets concatenated, a multi byte char can straddle a part boundary, which is the only place that is written down. And the invariant from #2303, that the array does not escape the method. #2303 landed a month ago and its tests are still in this file. If we are reversing it, the PR description should say why instead of just deleting the comment.
| /** | ||
| * Returns the entire response body as a byte array that may share its storage with this response. | ||
| * | ||
| * <p>The returned array must be treated as read-only. Modifying it may change the content subsequently returned |
There was a problem hiding this comment.
"this response's other body accessors" is too narrow. The array belongs to the HttpResponseBodyPart, not to the response, and it is reachable from the handler callback, from TransferListener and from getResponseBodyAsByteBuf(). It also goes the other way: bb.setByte(10, '9') on that ByteBuf changes what the caller's read-only view says. Worth spelling out who the other holders are.
| @Test | ||
| public void testGetResponseBodyAsBytesViewReadsDirectLazyPart() { | ||
| byte[] backing = "XXXHello WorldYYY".getBytes(StandardCharsets.UTF_8); | ||
| ByteBuf direct = Unpooled.directBuffer(backing.length); |
There was a problem hiding this comment.
direct is allocated, written and sliced before the try, so anything throwing in there leaks a direct buffer and the leak detector fails the build. Open the try right after the allocation.
| assertEquals("Hello World", | ||
| new String(response.getResponseBodyAsStream().readAllBytes(), StandardCharsets.UTF_8)); | ||
| byte[] view = response.getResponseBodyAsBytesView(); | ||
| assertSame(bodyParts.get(0).getBodyPartBytes(), view); |
There was a problem hiding this comment.
The javadoc says no array identity is guaranteed between calls and that the implementation may return a copy, and then we assert both of those here. Swap the fixture to LazyResponseBodyPart and the same assertion fails. One of the two has to give, otherwise the next person cannot tell which one is the contract.
|
|
||
| @Test | ||
| public void testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray() { | ||
| byte[] expected = "Hello World".getBytes(StandardCharsets.UTF_8); |
There was a problem hiding this comment.
expected is both the array we wrap into the part and the oracle we compare against. It holds today only because EagerResponseBodyPart copies. Add the obvious hasArray() fast path there and expected becomes the part's live storage, so firstCopy[0] = 'X' mutates it too and both assertions pass on a corrupt response. Unpooled.wrappedBuffer(expected.clone()) keeps it honest.
| } | ||
|
|
||
| @Test | ||
| public void testGetResponseBodyAsBytesViewReadsDirectLazyPart() { |
There was a problem hiding this comment.
This state cannot happen on a real request. AsyncHttpClientHandler.channelRead releases the message in a finally as soon as onBodyPartReceived returns and LazyResponseBodyPart never retains, so by the time anyone holds a Response the buffer is at refCnt 0 and this call throws IllegalReferenceCountException. With -Dio.netty.buffer.checkAccessible=false it does not throw, it returns recycled pool memory instead. The test keeps the buffer alive by hand, which makes LAZY look supported when it is not. Not something you introduced, but adding coverage here signs off on it.
| @Override | ||
| public byte[] getResponseBodyAsBytesView() { | ||
| return bodyParts.size() == 1 ? bodyParts.get(0).getBodyPartBytes() : getResponseBodyAsBytes(); | ||
| } |
There was a problem hiding this comment.
Nit: an empty body still goes down the aggregating path and allocates a byte[0] plus a ByteBuffer on every call. HEAD, 204 and 304 are common enough that a static final byte[] EMPTY short circuit is worth it, and it is the one case here that really is zero allocation.
Summary
Response#getResponseBodyAsBytesView()as a compatible default methodwith an explicit possibly-shared, read-only contract
aggregate copy without exposing
ByteBufstoragegetResponseBodyAsBytes(),getResponseBodyAsByteBuffer(), andgetResponseBodyAsStream()centralized
Closes #2321.
Compatibility and safety
The default implementation delegates to
getResponseBodyAsBytes(), so existingthird-party
Responseimplementations keep their current behavior. Netty usesthe optimized path only for exactly one body part. Empty and multipart bodies
continue through the existing aggregation path.
Tests cover eager identity and repeated access, defensive-copy isolation,
heap and direct lazy slices, reader/writer indices, reference counts, empty
bodies, multipart ordering and split UTF-8 characters, stream isolation, and
default-method delegation.
Verification
mvnw.cmd -B -ntp -pl client -Dtest=NettyAsyncResponseTest testmvnw.cmd clean verifyAllocation benchmark
JMH 1.37, Corretto 11.0.32.1, one fork, three 500 ms warmups, five
500 ms measurements,
gcprofiler. Values are allocated bytes per operation,rounded to whole bytes; zero means below the profiler's resolution.
getResponseBodyAsBytes()getResponseBodyAsBytesView()The eager single-part view measured about 2.3 ns/op regardless of body size,
versus 322 ns/op for 4 KiB and 11.0 us/op for 128 KiB defensive copies. Lazy
single-part allocation is halved as expected. Multipart bodies retain the
payload-sized aggregation allocation.
Codex on behalf of Pavel Ptashyts