Skip to content

Drop held input of a body cut off by the close - #2386

Open
mkurz wants to merge 3 commits into
AsyncHttpClient:mainfrom
mkurz:fix/decompression-premature-close
Open

mkurz wants to merge 3 commits into
AsyncHttpClient:mainfrom
mkurz:fix/decompression-premature-close

Conversation

@mkurz

@mkurz mkurz commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Part of the second follow-up promised on fix(netty): stop inflating a suspended HTTP/1.1 response #2362 (discussion), output left over when the connection closes.
  • When the connection closes while a suspended HTTP/1.1 response holds compressed input back, and the body has not ended, that input is now dropped instead of decompressed and handed on at once. That response cannot complete anyway. The same goes for a body whose last chunk could not be parsed; its failed end is still handed on, so the request fails with the parser's error.
  • A body that has ended is handed on at the close as before. That is the case from the discussion, so this does not finish the follow-up; see "Still open" below.

Problem

Http1ContentDecompressor holds compressed input back while a response is suspended. When the connection closes, Netty tears the pipeline down in the same task, so on main everything held back is decompressed and handed on at once, bounded only by maxDecompressedResponseSize.

For a body that the close cut off, a Content-Length or 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:

Transport Body cut in half Complete body, then close Body delimited by the close
NIO 1 part; the close is not read yet 1 part 1 part
epoll, io_uring on main 129 parts (8 MiB), then RemotelyClosedException 257 parts (16 MiB), completed 257 parts (16 MiB), completed
epoll, io_uring with this PR 1 part, then RemotelyClosedException unchanged unchanged

NIO honors auto-read, so it reads nothing, the close included, until a resume. epoll keeps reading after EPOLLRDHUP regardless 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

  • In channelInactive, the input held back is decompressed only if the body has ended successfully: a LastHttpContent with a successful decoder result, held with the input or queued behind it. That covers the end of a body delimited by the connection, which HttpObjectDecoder hands on just before the close. Otherwise the input is dropped with the rest of the response state.
  • A last chunk that failed to parse is the end of a broken body. Its input is dropped too, and the failed end is still handed on, so that HttpHandler fails the request with the parser's error, without a retry.
  • A dropped body is not decompressed first, so a corrupt or oversized remainder no longer fails it with a 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 an IOExceptionFilter that replays it) or fails with a RemotelyClosedException, and the IOExceptionFilters see a ChannelClosedException. As for a body without a content coding, the method does not decide whether it is retried, so a POST can be retried too.
  • The Javadoc of 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 maxDecompressedResponseSize as 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-By per AGENTS.md.

Test plan

  • Fail on main, pass with this PR. The epoll tests were run against main's code, where all four cases fail with the size limit's DecompressionException. The unit tests use a test hook that main lacks, so they were checked with main'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, and channelInactive passed 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; a Content-Length and a chunked body, half of it sent, then closed; no retries): the handler gets 1 part, the request fails with RemotelyClosedException, and the IOExceptionFilter sees one ChannelClosedException.
    • Http1DecompressionSuspensionTest.aSuspendedBodyThatTheConnectionCutsOffIsRetried (epoll, a GET, default retries): the server sees 1 + maxRequestRetry requests, each delivers its first part only, and the request then fails with RemotelyClosedException.
    • Http1DecompressionSuspensionTest.aSuspendedBodyWhoseLastChunkCannotBeParsedFailsWithTheParsersError (epoll; half a chunked body, then zz as the chunk size line, then the close; 1 MiB limit, default retries): the handler gets 1 part, the request fails with the NumberFormatException from HttpObjectDecoder, and the server sees 1 request.
  • Pass on main and 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, aTruncatedBodyHeldWhenTheConnectionClosesFails and aHeldDirectInputIsReleasedWhenTheResponseEndsEarly, for a held last chunk.
  • Each change breaks the tests it should:
    • counting a failed end as the end, as the first commit did: the two failed-chunk tests fail;
    • counting any queued message as the end: inputHeldBackOfABodyThatTheCloseCutsOffIsDropped fails for all five codings, and so do the epoll tests;
    • dropping a failed end instead of handing it on: the two failed-chunk tests fail.
  • ./mvnw -B -ntp -Dgpg.skip=true clean verify on JDK 11: 2,009 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used.
  • The Http1ContentDecompressor*Test, Http2ContentDecompressorTest, Http1Decompression*Test and ResponseBodyControlTest classes compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's -Djvm): 140 tests each, 0 failures.

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>
@mkurz

mkurz commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

mkurz and others added 2 commits November 10, 2026 00:04
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants