Repository navigation
Conversation
When the connection closed while a suspended response held compressed input back, Http1ContentDecompressor decompressed all of it at once and handed it on. For a body that the close cut off, that work is wasted: the response cannot complete, and the request fails or is retried right after. The input held back is now dropped unless the end of the body has arrived, either held with the input or queued behind it. A body that has ended is handed on at the close as before: a held last chunk, the end of a body delimited by the connection, and a last chunk that failed to parse. A dropped body is not decompressed first, so it no longer fails on a corrupt or oversized remainder. The request is handled like any other whose connection closed before the end of its body: it is retried or fails with a RemotelyClosedException, and the IOExceptionFilters see a ChannelClosedException. Whether an orderly close by the server (a TCP FIN) is noticed while reads are suspended depends on the transport: native transports such as epoll notice it, NIO only once the input held back has been used up. A close on the client's side, for example when the client is closed, reaches the decompressor on every transport. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I also have a local prototype for what is still open here: a body that has ended, held back when the server closes the connection in an orderly way (a TCP FIN). While compressed input is held back, it turns on half-closure, so that close only shuts the input down. The rest of the body then still waits for demand, and the connection is not reused. Tests cover epoll and io_uring, over plaintext and TLS; kqueue is untested. A reset, a close on the client's side and HTTP/2 are not covered. Would you like it as a separate PR after this one? |
| // Whether the end of the paused body has arrived, with the input that is held or behind it. HttpObjectDecoder hands | ||
| // on the end of a body delimited by the connection before the connection's close. | ||
| private boolean bodyEnded() { | ||
| if (heldLast != null) { |
There was a problem hiding this comment.
A failed LastHttpContent counts as the end here too. A server that sends half the body, then a bad chunk size line and closes gets the old behaviour back: on epoll with a 64 MiB limit 513 parts reach the suspended handler, and with a 1 MiB limit the request fails with the size limit DecompressionException instead of the parser's error. I think we should only count a successful end. For a failed one we can drop the held input and still pass the failed last content on, so HttpHandler fails the request with the decoder's error.
There was a problem hiding this comment.
Agreed. With 1e2f74c only a successful end lets the input held back be handed on at the close. For a failed one, the held input is dropped and the failed end is still handed on, so HttpHandler fails the request with the parser's error. aSuspendedBodyWhoseLastChunkCannotBeParsedFailsWithTheParsersError is your case on epoll: half the body, then zz as the chunk size line, then the close, with a 1 MiB limit. The handler gets 1 part, the request fails with the NumberFormatException from HttpObjectDecoder, and the server sees 1 request. inputHeldBackOfABodyWhoseLastChunkFailedIsDroppedAtTheClose covers all five codings.
| * {@code Transfer-Encoding: gzip}, is decompressed as soon as it is read. | ||
| * back, the rest of a body that has ended is decompressed and delivered at once, bounded only by | ||
| * {@link AsyncHttpClientConfig#getMaxDecompressedResponseSize()}. The rest of a body that the close cut off is | ||
| * dropped, and the request fails as for any connection closed before the end of the body. Native transports such |
There was a problem hiding this comment.
Not quite what happens. With the default maxRequestRetry the request is retried first. And for a body delimited by the connection Netty always supplies a last content at the close, so the held input is decompressed and handed on and the request then fails with a DecompressionException. Maybe: the rest of a Content-Length or chunked body that the close cut off is dropped, and the request is retried or fails as for any connection closed before the end of the body.
There was a problem hiding this comment.
Agreed. 0614603 takes your wording for a Content-Length or chunked body. It also says that a body delimited by the connection ends with it, so its held input is decompressed, and the request fails with a DecompressionException if the compressed stream is incomplete.
| * back, the rest of a body that has ended is decompressed and delivered at once, bounded only by | ||
| * {@link AsyncHttpClientConfig#getMaxDecompressedResponseSize()}. The rest of a body that the close cut off is | ||
| * dropped, and the request fails as for any connection closed before the end of the body. Native transports such | ||
| * as epoll notice an orderly close by the server (a TCP FIN) even while reads are suspended; NIO notices it only |
There was a problem hiding this comment.
After a FIN, epoll, io_uring and kqueue read what is left even while reads are suspended, so an unencoded body that arrived before the close is delivered in full to a suspended handler. "Notice an orderly close" undersells that, and it reads oddly next to the first sentence of this Javadoc.
There was a problem hiding this comment.
Agreed. 0614603 gives this its own paragraph: after a FIN, epoll, io_uring and kqueue read what is left even while reads are suspended, so a body without a content coding that arrived before the close is delivered in full. NIO reads nothing until a resume.
| try (AsyncHttpClient client = asyncHttpClient(config() | ||
| .setUseNativeTransport(true) | ||
| .setUseOnlyEpollNativeTransport(true) | ||
| .setMaxRequestRetry(0) |
There was a problem hiding this comment.
With the default retries this is the case that is now retried where main failed it with the size limit error: 6 requests, POST included, in my run. Nothing covers that. Can we run one case with the default and assert how many requests the server saw?
There was a problem hiding this comment.
Added aSuspendedBodyThatTheConnectionCutsOffIsRetried in 1e2f74c. It sends a GET with the default config: the server sees 1 + maxRequestRetry requests (6), each delivers its first part only, and the request then fails with RemotelyClosedException. The retry after a closed connection doesn't check the method (isReplayPossible), so a POST can be retried here too, as when the close cuts off a body without a content coding.
| public void inputHeldBackOfABodyThatTheCloseCutsOffIsDropped(String contentEncoding) { | ||
| // Decompressing what is held back would exceed the limit. | ||
| SuspendedExchange exchange = new SuspendedExchange(new Http1ContentDecompressor(false, 256 * 1024), | ||
| contentEncoding, compress(contentEncoding, new byte[1024 * 1024]), 4096, false, true, Input.HEAP, |
There was a problem hiding this comment.
1 MiB of zeros compresses to a single 4096 byte fragment for gzip, deflate, br and zstd, so nothing is ever queued behind the held input in those four cases. A bodyEnded() that returns true for any queued message passes all of them and only the snappy case fails. A smaller fragment size, or a payload that compresses less, would cover the queued case for every coding.
There was a problem hiding this comment.
Good catch. In 1e2f74c the test uses 512 KiB of random bytes, which none of the codings compresses, and checks that input is queued behind the input held back. With an end check that counts any queued message, all five cases fail now, as does the new failed-chunk test.
A last chunk that HttpObjectDecoder could not parse counted as the end of a paused body, so at the close the input held back was decompressed and handed on at once. A server that sends half a body, then a bad chunk size line and closes got the old behaviour back: the rest of the body reached the suspended handler, or the request failed with the size limit instead of the parser's error. Only a successful end now lets the input held back be handed on at the close. For a failed one it is dropped, and the failed end is still handed on, so that the request fails with the parser's error. The test of a body cut off by the close used zeros, which every coding but snappy compresses into a single fragment, so no input was queued behind the input held back. It now uses a payload that barely compresses, and checks that input is queued. Add a test that a body cut off by the close is retried with the default retries. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Javadoc of ResponseBodyControl.suspend() said that a body cut off by the close fails, but the request is retried first with the default retries, and a body delimited by the connection always ends at the close, so its held input is decompressed and fails the request if the compressed stream is incomplete. It also said that native transports notice a close by the server while reads are suspended, but they read everything that is left, so a body without a content coding that arrived before the close is delivered in full to a suspended handler. Describe both, and that a last chunk that could not be parsed fails the request with that error. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Problem
Http1ContentDecompressorholds compressed input back while a response is suspended. When the connection closes, Netty tears the pipeline down in the same task, so onmaineverything held back is decompressed and handed on at once, bounded only bymaxDecompressedResponseSize.For a body that the close cut off, a
Content-Lengthor chunked body that never reached its end, that is wasted work: the response cannot complete, and AHC fails the request (RemotelyClosedException) or retries it right after. A suspended handler still receives the whole remainder first.Whether an orderly close by the server (a TCP FIN) is noticed while the response is suspended depends on the transport. A handler that suspends after its first part and never resumes, 16 MiB of zeros as gzip, and a server that closes the connection:
mainRemotelyClosedExceptionRemotelyClosedExceptionNIO honors auto-read, so it reads nothing, the close included, until a resume. epoll keeps reading after
EPOLLRDHUPregardless of auto-read (EpollRecvByteAllocatorHandle.continueReading), io_uring behaves the same here, and so does kqueue (KQueueRecvByteAllocatorHandle.continueReading), so they read everything the server sent before its close. AHC selects epoll on Linux whenever the native library is on the classpath. A close on the client's side, for example when the client is closed, reaches the decompressor on every transport.Change
channelInactive, the input held back is decompressed only if the body has ended successfully: aLastHttpContentwith a successful decoder result, held with the input or queued behind it. That covers the end of a body delimited by the connection, whichHttpObjectDecoderhands on just before the close. Otherwise the input is dropped with the rest of the response state.HttpHandlerfails the request with the parser's error, without a retry.DecompressionException, which is never retried. A body that the close cut off is handled like any other whose connection closed before the end of its body: it is retried (maxRequestRetry, or anIOExceptionFilterthat replays it) or fails with aRemotelyClosedException, and theIOExceptionFilters see aChannelClosedException. As for a body without a content coding, the method does not decide whether it is retried, so a POST can be retried too.ResponseBodyControl.suspend()and of the class say so.suspend()also says that after an orderly close by the server, epoll, io_uring and kqueue read what is left even while reads are suspended, so a body without a content coding that arrived before the close is delivered in full, and that NIO reads nothing until a resume.Still open: a body that has ended, including one delimited by the connection, is still decompressed and handed on at once when the connection closes while its input is held back, on native transports, bounded by
maxDecompressedResponseSizeas the Javadoc already said. Keeping it to demand needs the input held back to outlive the close. Either the channel allows half-closure, so that the server's close only shuts its input down and the pipeline stays until the body has been delivered and the channel is closed, or the input held back is moved out of the pipeline before it is torn down. Either is a larger change, left for a separate PR. HTTP/2 is separate.AI disclosure
Claude Code on behalf of Matthias Kurz. The commits include
Co-Authored-ByperAGENTS.md.Test plan
main, pass with this PR. The epoll tests were run againstmain's code, where all four cases fail with the size limit'sDecompressionException. The unit tests use a test hook thatmainlacks, so they were checked withmain's close handling put back:Http1ContentDecompressorSuspensionTest.inputHeldBackOfABodyThatTheCloseCutsOffIsDropped(gzip, deflate, br, zstd, snappy): a body without its end, suspended after its first part, with a limit that decompressing the rest would exceed. 512 KiB of random bytes, so that input is also queued behind the input held back for every coding, which the test checks. At the close nothing more is handed on, no error, every input buffer released, andchannelInactivepassed on.Http1ContentDecompressorSuspensionTest.inputHeldBackOfABodyWhoseLastChunkFailedIsDroppedAtTheClose(same five codings and payload): a failed last chunk queued behind the input held back. At the close nothing more is handed on, and the end is handed on with the parser's error.Http1DecompressionSuspensionTest.aSuspendedBodyThatTheConnectionCutsOffFailsWithoutTheRestOfIt(Linux with epoll; aContent-Lengthand a chunked body, half of it sent, then closed; no retries): the handler gets 1 part, the request fails withRemotelyClosedException, and theIOExceptionFiltersees oneChannelClosedException.Http1DecompressionSuspensionTest.aSuspendedBodyThatTheConnectionCutsOffIsRetried(epoll, a GET, default retries): the server sees 1 +maxRequestRetryrequests, each delivers its first part only, and the request then fails withRemotelyClosedException.Http1DecompressionSuspensionTest.aSuspendedBodyWhoseLastChunkCannotBeParsedFailsWithTheParsersError(epoll; half a chunked body, thenzzas the chunk size line, then the close; 1 MiB limit, default retries): the handler gets 1 part, the request fails with theNumberFormatExceptionfromHttpObjectDecoder, and the server sees 1 request.mainand with this PR:Http1ContentDecompressorSuspensionTest.inputHeldBackOfABodyThatHasEndedIsHandedOnAtTheClose(gzip, zstd; the end of a body delimited by the connection): the whole body is handed on at the close, then the end.channelClosureHandsOnHeldInputBeforeChannelInactive,aTruncatedBodyHeldWhenTheConnectionClosesFailsandaHeldDirectInputIsReleasedWhenTheResponseEndsEarly, for a held last chunk.inputHeldBackOfABodyThatTheCloseCutsOffIsDroppedfails for all five codings, and so do the epoll tests;./mvnw -B -ntp -Dgpg.skip=true clean verifyon JDK 11: 2,009 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used.Http1ContentDecompressor*Test,Http2ContentDecompressorTest,Http1Decompression*TestandResponseBodyControlTestclasses compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's-Djvm): 140 tests each, 0 failures.