Repository navigation
fix(netty): stop inflating a suspended HTTP/1.1 response - #2362
Conversation
|
Heads-up: #2357, opened before this PR, which I missed, rewrites the same If you'd like #2357 first, I'd rebuild the demand bounding on top of its inflate loop instead of Netty's pull |
|
Related to "Why HTTP/2 is unchanged" above: netty/netty#17774 makes |
|
Thanks for the review. I've pushed four commits on top, one per fix, and updated the description: the Compatibility section now states that Netty 4.2.17 is required, and Behavior changes covers the replay fix, which also changes HTTP/2. The |
|
With #2357 now approved, would you prefer to land it first? As @pavel-ptashyts suggested there, I'm happy to rebuild the demand bounding here on top of its inflate loop, keeping its pooling and direct-input optimizations and the fixes from this review. I can prepare that locally in the meantime. |
|
Thanks, the four fixes look right. I re-ran my replay probe against b5db977 and it passes with auto-read on and off. #2357 is merged now, so let's go with your plan and rebuild on its inflate loop. Two asks.
Your other three PRs probably need a rebase after #2357 too, worth a look while you are at it. |
… its body control (#2367) ## Summary - When a request is replayed, for example by an `IOExceptionFilter`, detach the old channel from the future before completing the old response's body control. - Fixes two bugs on `main`: on HTTP/2, the handler that has just been told to retry receives frames of the abandoned response; on HTTP/1.1 with auto-read off, the old connection is never drained or closed. - Split out of #2362 at the maintainer's request, since it does not depend on either decompressor. ## Problem `NettyRequestSender.replayRequest` completed the old response's `ResponseBodyControl` first, and only then detached the old channel from the future. - **HTTP/2:** completing the control resumes a suspended stream channel, and the stream channel delivers the DATA frames queued in it on the spot. The channel still named the future, so the handler that had just received `onRetry` got parts of the abandoned response before the replayed response's status. - **HTTP/1.1 with auto-read off:** `replayRequest` installs a drain callback that waits for the end of the old response, but after the replay nothing read the old channel. The drain never ran, and the connection was never drained or closed. ## Change - **HTTP/2:** mark the stream channel as discarded before completing the control, then close it as before. - **HTTP/1.1:** install the drain callback before completing the control, and when auto-read is off, issue one read so the drain starts. With auto-read on, HTTP/1.1 on `main` delivers nothing of the old response after `onRetry`, because NIO reads are asynchronous; the new order also holds when the old response holds back input that is delivered on the spot, which #2362 introduces. ## AI disclosure Claude Code on behalf of Matthias Kurz. The commit includes `Co-Authored-By` per `AGENTS.md`. ## Test plan - [x] `Http2ResponseBodyControlTest.replayDoesNotDeliverFramesQueuedForTheReplacedStream`: queues DATA frames in a suspended stream channel, injects an I/O error, and checks that after `onRetry` the handler receives the replayed body, not the queued frames. Fails without the change. - [x] `ResponseBodyControlTest.ioExceptionReplayDrainsTheOldConnectionWithAutoReadDisabled`: with auto-read off, an I/O error replays a suspended response; the old connection must be drained and closed once its last chunk arrives. Fails without the change. Over HTTPS the bug does not show, since `SslHandler` issues reads of its own, so the test uses plain HTTP. - [x] Broken variants: completing the control before marking the HTTP/2 stream discarded fails only the HTTP/2 test; dropping the read fails only the HTTP/1.1 test. - [x] Both test classes, compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's `-Djvm`): 20 tests each, 0 failures. - [x] `./mvnw -B -ntp -Dgpg.skip=true clean verify` on JDK 11: 1,821 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Netty's HttpContentDecompressor, and the inflate loop that replaces it for gzip and deflate since AsyncHttpClient#2357, inflate each chunk completely as soon as it is read. Suspending a response through ResponseBodyControl stops further socket reads but not that, so one read of a highly compressible body still became tens of megabytes of body parts nobody had asked for. Check before each part whether the response is suspended. If it is, keep the input the inflater has not used, and whether it still holds output, together with every message that arrives after it, and inflate it on the next read request, such as the one resume() issues, before the read is passed on. To see read requests, the handler also implements ChannelOutboundHandler and passes every other outbound event through; its superclass and constructor stay as they are. A held chunk is retained, not copied, since the inflater may still point into it. Parts are cut where JdkZlibDecoder cuts them, but never past 64 KiB, so that a suspension takes effect within one part even when a large chunk inflates at once: each inflate() call is capped at what is left of a part, which also keeps a forwarding threshold of 0 making progress. The end of a fully inflated body still reaches a suspended response, as for an unencoded body, and a channel that closes while input is held hands the rest on at once. The differential tests against Netty now expect more parts where Netty hands on parts larger than 64 KiB. New unit tests cover suspension, removal, closure and the limit, a direct or pooled direct chunk paused mid-way, and output held without input; a fresh JVM checks a forwarding threshold of 0; the integration tests are those from before the rebuild. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While a channel is drained after a redirect, an authentication challenge or a replay, AsyncHttpClientHandler discards every body part until the last one. The decompressor still inflated a compressed body in full and copied each part before it was dropped. A body beyond maxDecompressedResponseSize failed the drain, which closed the connection and could fail the request that had moved on with the same future. main behaves the same way. Before the next part, check whether the channel is being drained, is discarded or belongs to an exchange that is over. If so, drop the rest of the body's input instead of inflating it. Its end is still handed on, so a drain callback still runs. When a response filter replays the request on a compressed response that inflates far beyond the limit, the replayed request succeeds and the drained connection returns to the pool. A unit test covers a draining and a discarded channel. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HttpObjectDecoder reports a chunk it cannot parse as a LastHttpContent with a failed decoder result, and HttpHandler fails the response on it. The inflate loop handed on a new LastHttpContent that carried the trailers only, as Netty's HttpContentDecoder does, so a compressed body cut off by such a chunk completed normally. Keep the result on the end of the body, also when it is handed on after a suspension. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
br, zstd and snappy still went through Netty's HttpContentDecompressor, which decompresses each chunk as soon as it is read, so a suspended response could still receive parts nobody had asked for. Decompress them in a new PullDecompression class, one part at a time with Netty's pull-based Decompressor, under the same checks before each part as gzip and deflate. Small outputs, such as snappy's frames, are merged into a part sized for them; outputs larger than 64 KiB, such as zstd's when io.netty.compression.defaultMaxForwardBytes is raised, are split. Netty added the Decompressor API in 4.2.17 and still marks it unstable. Only PullDecompression refers to it, and it is loaded only once one of these codings shows up, so gzip and deflate keep working with older Netty 4.2 releases. A test hides the API from a class loader to check both sides. The test of reads requested with auto-read off now covers these codings too. The test that mixes this handler's messages with Netty's in one read uses a transfer coding instead of snappy, since that is still left to Netty. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ResponseBodyControl.suspend() did not say that a suspension also stops decompressing an HTTP/1.1 body, nor what it still does not hold back. Document that this holds for the content codings AHC decompresses, not for a transfer coding such as Transfer-Encoding: gzip, and that only parts with content wait for a resume: the end of a fully decompressed body, with its trailers and completion, arrives even while suspended, as for an unencoded body. If the connection closes while input is held back, the rest is decompressed and delivered at once, bounded only by maxDecompressedResponseSize. The README notes that br, zstd and snappy need Netty 4.2.17, and the config and ChannelManager comments mention the suspension. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b5db977 to
497aad1
Compare
|
Thanks. Done as you suggested:
|
|
@hyperxpro looking forward to your feedback, thanks! 😉 |
|
👍 LGTM Thanks! |
zstd is left to Netty's push decoder when io.netty.compression.defaultMaxForwardBytes is zero or negative, so a suspension does not hold it back then. Say so in ResponseBodyControl.suspend(), the Http1ContentDecompressor class Javadoc and the README, and rewrap the suspend() paragraph, which had a short line. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Added one small docs commit (8838a42): since zstd is left to Netty's push decoder when |
|
@hyperxpro Now I am done 😉 |
|
Thank you very much! |
## Summary - A `ResponseBodyControl.suspend()` queued from another thread no longer takes effect after a `resume()` that the event loop made in the meantime, which could leave a response suspended with nothing left to resume it. - A `resume()` is still always applied, so it is never lost to a `suspend()` that a callback makes after it was queued. - Document when control calls take effect. No API change. ## Problem `NettyResponseBodyControl` applies a call made on the event loop before it returns, and queues a call made on any other thread to the event loop. Queued calls take effect in the order they were queued, without regard to calls the event loop made inline in the meantime. The stall: 1. A consumer thread calls `suspend()`, which is queued. 2. Before it runs, a body callback on the event loop calls `resume()`, which takes effect immediately. 3. The queued `suspend()` then runs and suspends reads again. Nothing calls `resume()` after that, so the response stalls until the request timeout. The callback's `resume()` was the latest decision, but the older `suspend()` overrode it. This pattern occurs in reactive consumers that suspend from whichever thread finds the buffer full. Play WS hit a variant of it, and avoids it today by suspending only on the event loop. ## Change - `suspend()` records a suspend request, and `resume()` clears it. On the event loop, `suspend()` suspends at once. From another thread, it queues the suspension. - A queued suspension is skipped if, when it would take effect, the most recent call to `suspend()` or `resume()` was a resume. - `resume()` is always applied, and calls made on the event loop take effect before they return, as before. This is not "the latest call wins". Only suspensions can be skipped. Skipping a resume instead would reintroduce a lost-wakeup stall: a consumer takes the last buffered part and calls `resume()`, a callback on an older view calls `suspend()` before that resume is applied, and the resume must still win. A test pins this case. Calls made concurrently on different threads are not ordered beyond these rules. For example, with suspend, resume and suspend queued from other threads, the first suspension still takes effect briefly, because the most recent call is a suspend when it runs; the queued resume and the second suspension then follow. The `ResponseBodyControl` Javadoc now states when calls take effect: - a call on the event loop takes effect before it returns; - calls from other threads take effect later on the loop, possibly after further callbacks; - a `resume()` always takes effect; - a `suspend()` from another thread is skipped if the most recent call was a resume; - concurrent calls on different threads are not ordered otherwise. ## Compatibility No public API change. Behavior changes only for a `suspend()` from another thread whose queued suspension is overtaken by a `resume()`: it no longer takes effect. All other call orders behave as before. ## Related The separate pull request [#2359](#2359), "feat: add ResponseBodyControl.execute to decide in sequence with body delivery", lets a consumer make its suspend/resume decision on the event loop in the first place. The two changes are independent and merge cleanly with each other. ## AI disclosure Claude Code on behalf of Matthias Kurz. The commits include `Co-Authored-By` per `AGENTS.md`. ## Test plan New tests, for HTTP/1.1, for HTTP/2, and as unit tests in `NettyResponseBodyControlTest` (with Netty auto-read on and off): - an earlier `suspend()` from another thread does not undo a later `resume()`; - a `resume()` from another thread is not lost to a later `suspend()`. `NettyResponseBodyControlTest` also covers: - sequences of calls queued from another thread: suspend, resume, suspend ends suspended, and resume, suspend, resume ends resumed, with the exact number of suspensions started and ended; - a `suspend()` after a skipped one still takes effect; - a `suspend()` on the event loop takes effect even if a `resume()` from another thread clears its request between recording and acting on it. The channel's event loop is a proxy whose `inEventLoop()` runs that resume at exactly that moment. The unit tests also check that the suspension start/end hooks stay balanced and that exactly one read is requested per resume. Calls from another thread go through a single-thread executor owned by each test, not the common pool. Broken variants, each run against the 31 control tests: - [x] A queued suspension always applied, as on `main`: 7 failures and 1 error (HTTP/2, by timeout). These are the tests that an earlier `suspend()` must not undo a later `resume()`, the sequence test, and the test of a suspension after a skipped one. - [x] The latest call always wins, so a queued `resume()` is dropped for a later `suspend()`: 5 failures and 1 error (HTTP/2, by timeout). These are the tests that a `resume()` must not be lost, and the sequence test. - [x] A skipped suspension that still runs the suspension start hook: 4 failures, all in `NettyResponseBodyControlTest`, through the counts of started suspensions. The integration tests don't catch this variant. - [x] A `suspend()` on the event loop that still goes through the queued-suspend check, as in the first revision: 1 failure, the race test. Runs: - [x] `ResponseBodyControlTest`, `Http2ResponseBodyControlTest` and `NettyResponseBodyControlTest` on JDK 11: 31 tests passed. - [x] `./mvnw -B -ntp -Dgpg.skip=true clean verify` on JDK 11: 1,804 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used. - [x] The same 31 tests, compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's `-Djvm`): 0 failures on each. - [x] All four open AHC changes merged together (raw `Cookie` headers on retries, `ResponseBodyControl.execute`, suspend/resume order, demand-bounded decompression): they merge without conflicts, together and in every pair, and `./mvnw -B -ntp -Dgpg.skip=true clean install` on JDK 11 passes with 1,861 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. Rerun with the review fixes of this pull request and #2362. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
## Summary - Upgrades Netty from 4.2.19.Final to 4.2.20.Final. Its [security fixes](https://github.com/netty/netty/releases/tag/netty-4.2.20.Final) include several in modules AHC uses: three out-of-bounds reads in `netty-buffer`, unbounded memory usage in `netty-codec-http2`, insufficient control character neutralization in `netty-codec-http`, insufficient entropy in `netty-resolver-dns`, and an mTLS bypass via session resumption in `netty-handler`. - On 4.2.20, two of AHC's tests fail, both because of Netty changes. This PR adapts AHC to them. ## Change - **Upgrade Netty to 4.2.20.Final, and check the second gzip magic byte:** `JdkZlibDecoder` now rejects a gzip header with a wrong second magic byte (netty/netty#17972). The gzip inflate loop from #2357 follows `JdkZlibDecoder`, and `Http1ContentDecompressorTest` compares the two, so it now checks that byte too. Both bytes are read unsigned, since `0x8b` is negative as a signed byte. - **Keep zstd working at a non-positive threshold:** since netty/netty#17896, the `ZstdDecoder` that `HttpContentDecompressor` creates sizes its output buffers by `io.netty.compression.defaultMaxForwardBytes`. At 0 it hands on an empty body without an error; below 0 it fails with `IllegalArgumentException: initialCapacity : -1`. AHC leaves zstd to that decoder while the property is not positive (#2362), so it now replaces it with a `ZstdDecoder` whose buffers hold `MAX_PART_SIZE`. The size limit still applies, and the body is still decompressed eagerly there, as before. At the default value of the property nothing changes. netty/netty#18289 fixes the decoder in Netty; this workaround is harmless with that fix and can go once AHC requires a Netty version that has it. ## Notes - Netty 4.2.0 to 4.2.4 have no `ZstdDecoder(int)` constructor. Overriding AHC's Netty with one of those and also setting the property to 0 or below would fail zstd responses with a `NoSuchMethodError`. ## AI disclosure Claude Code on behalf of Matthias Kurz. Both commits include `Co-Authored-By` per `AGENTS.md`. ## Test plan - [x] `main` at `5542c9fc4` with `-Dnetty.version=4.2.20.Final`: 1,857 tests, 2 failures: - `Http1ContentDecompressorTest.decodesLikeNettyForEveryBodyAndSplit`, case "gzip second magic byte ignored": Netty now fails that body, AHC did not. - `Http1ContentDecompressorForwardThresholdTest`: "the zstd body differs". - [x] `Http1ContentDecompressorForwardThresholdTest` now runs at thresholds 0 and -1, each in a fresh JVM, and also checks the size limit for zstd and that every exchange releases its input and buffers. On 4.2.20 without the zstd commit it fails at both ("the zstd body differs", "the zstd body did not end"). With it, it passes on 4.2.19 and 4.2.20. - [x] `./mvnw -B -ntp -Dgpg.skip=true clean verify` on JDK 11, on `main` `3e95ec8c2`: 1,905 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used. - [x] `Http1ContentDecompressorForwardThresholdTest` and `Http1ContentDecompressorTest` compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's `-Djvm`): 36 tests each, 0 failures. - [x] This branch merged with #2369: `clean verify` on JDK 11, 1,914 tests, 0 failures, 0 errors, 11 skipped; Revapi passed (run before #2359, #2360 and #2361 were merged, with those three merged in as well). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This is the first of the two follow-ups promised on #2362 ([discussion](#2362 (comment))). A compressed HTTP/1.1 response body that ends in the middle of its compressed stream now fails the request, instead of completing as if it were whole. Netty's `HttpContentDecompressor` behaves like `main` here, so this is a behavior change and gets its own PR. ## Problem A body can end early, for example when the connection closes in the middle of a close-delimited body. On `main` such a body completed normally: - gzip: a member cut off in its header, data or trailer completed, so its CRC32 and length were never checked. - deflate: a stream that never finished completed. - br, zstd and snappy: Netty's `Decompressor` was never told that the input had ended, so its own truncation check never ran. ## Change Once a body has been decompressed in full, `Http1ContentDecompressor` checks that it ends where a gzip member or deflate stream ends, and `PullDecompression` calls `Decompressor.endOfInput()`, which throws for a truncated br, zstd or snappy stream. Output the decompressor still produces after that waits for a resume like any other part. The check only runs once held input and output have been handed on, also after a suspension and when the connection closes while input is held back. A truncated body fails the request with a `DecompressionException`. For zstd, Netty wraps zstd-jni's `ZstdIOException`, and since AHC fails a request with the innermost cause, that looked like a failed connection and went to the `IOExceptionFilter`s, where a replaying filter could leave the first connection open. `PullDecompression` now throws a plain `DecompressionException("Truncated zstd stream")` instead and keeps zstd-jni's exception as a suppressed one. Still accepted: - a body without any bytes, for every coding (`endOfInput()` is only called once input was added, and only in `NEED_INPUT`) - concatenated gzip members - a few bytes after a complete gzip member, fewer than a gzip header, which `JdkZlibDecoder` leaves unread Not checked: - a body nobody wants, such as the rest of a response drained for a replay, or one whose exchange is over. That includes a body too short to have produced any output, so the end of the body checks again whether it is wanted. - a last chunk that failed to parse, so that its own failure surfaces. A premature close of a `Content-Length` or chunked body produces no `LastHttpContent` at all. - bodies left to Netty's `HttpContentDecompressor`: br, zstd and snappy with a Netty older than 4.2.17, zstd at a non-positive `io.netty.compression.defaultMaxForwardBytes`, and transfer codings such as `Transfer-Encoding: gzip` - HTTP/2 response bodies Some cuts also go unnoticed. snappy has no end marker, so a body cut between two of its chunks looks complete. A zstd body of several frames completes when it is cut inside a frame that started in the same chunk as the end of the previous one, because zstd-jni only notices the new frame on a fresh read. The class Javadoc and the README describe all of this. The second follow-up, output left over when the connection closes, comes separately. ## Tests Fail on `main`, pass here: - `Http1DecompressionTruncationTest.aBodyThatTheConnectionEndsInTheMiddleOfItsStreamFails`: through the client, a close-delimited body still decodes in full for all five codings, and the same body cut short by 3 bytes fails with a `DecompressionException`. - `Http1DecompressionTruncationTest.aCutOffBodyIsNotPassedToTheIOExceptionFilters`: a cut-off `Content-Length` body with a replaying `IOExceptionFilter` fails with a `DecompressionException`, the filter is not called, no second request is sent and the client closes the connection. - `Http1ContentDecompressorTest.decodesLikeNettyForEveryBodyAndSplit`: the truncated gzip and deflate bodies (header, header name, data, missing trailer, partial trailer, second member, zlib, raw, missing Adler-32, one byte) now fail with `Truncated gzip stream` or `Truncated deflate stream` across all splits and buffer kinds, with no `IOException` in the chain. Every other body still decodes like Netty. - `Http1ContentDecompressorTest.truncatedBrZstdAndSnappyBodiesFailAndBodiesWithoutBytesEnd`: br, zstd and snappy bodies cut in half or by one byte fail, and bodies without bytes still end. - `Http1ContentDecompressorSuspensionTest.aTruncatedBodyFailsAtItsEndAfterTheResumes` (gzip and zstd) and `aTruncatedBodyHeldWhenTheConnectionClosesFails`: the parts before the cut are still handed on, then the body fails. - `reusesTheInflaterAcrossResponsesOnOneConnection` and `aBufferThatCannotGrowFailsLikeNetty` sent cut-off bodies on purpose and now expect the failure. Pass on `main` too, and fail if the end of the body stops checking whether it is wanted: - `Http1DecompressionTruncationTest.aReplayIsNotFailedByTheCutOffBodyItDrains`: a `ResponseFilter` replays a 503 whose gzip body is only 6 bytes, and the replay still gets its 200. - `Http1ContentDecompressorSuspensionTest.aShortBodyThatIsNotWantedEndsEvenIfItIsCutOff`: the first 1 to 12 bytes of a body that is discarded, drained or whose exchange is over end without a failure, for every coding. Each of these fails without the code it covers: - `aFailedLastChunkStaysFailed`, for the check of the last chunk's decoder result - `outputThatOnlyFollowsTheEndOfInputIsHandedOn` and `outputThatOnlyFollowsTheEndOfInputWaitsForResumes`, for output that only comes after `endOfInput()` - `reusesTheInflaterAcrossResponsesOnOneConnection`, for the reset of the gzip member state between responses `./mvnw -B -ntp -Dgpg.skip=true clean verify` on JDK 11, merged with `main` at `f9eafb982` (so with #2375 and #2380): 1,960 tests, 0 failures, 0 errors, 11 skipped, Revapi passed. That run predates a test-only fix in the new client test. The `Http1ContentDecompressor*Test`, `Http2ContentDecompressorTest` and `Http1Decompression*Test` classes also pass on JDK 17, 21 and 25, 101 tests each. Claude Code on behalf of Matthias Kurz. The commits carry `Co-Authored-By` as `AGENTS.md` asks. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
ResponseBodyControl, stop decompressing its body, for the content codings AHC decompresses: gzip, deflate, br, zstd and snappy. Body parts are at most 64 KiB, so a handler that suspends after each part receives one part perresume().Http1ContentDecompressorkeeps its superclass and constructor, and also implementsChannelOutboundHandlerto see read requests. Revapi needs no entries.Decompressor, in a class of their own. Only these codings use that API, which Netty added in 4.2.17; without it they go to Netty's own decoders, as before.Problem
Suspending a response stops further socket reads, but Netty's
HttpContentDecompressor, and the inflate loop that replaces it for gzip and deflate since #2357, decompress every chunk completely as soon as it is read. So one socket read of a highly compressible body still became many parts that nobody had asked for.For example, one 64 KiB read of gzipped zeros (about 1032:1) becomes some 67 MB of body parts for a handler that has suspended the response. Only
maxDecompressedResponseSize(256 MiB by default) bounds it.With 64 MiB of gzipped zeros and a handler that suspends inside each body callback,
mainbefore #2357 delivered 1,955,380 bytes in 30 more parts past the suspending part, and then up to 33,713,376 bytes per resume. This branch without its suspension check delivers the same 1,955,380 bytes in 30 parts.AHC 2 behaved the same way, so this is hardening, not a regression fix. It matters to consumers that bound their buffering through
ResponseBodyControl, such as Play WS's streaming API.Change
gzip and deflate, on #2357's loop:
JdkZlibDecodercuts them, but eachinflate()call is capped at what is left of a 64 KiB part, so no part is larger.resume()issues. It hands on what was held back first, and lets the read reach the socket only if the response is not suspended again. This works the same with auto-read on and off. While paused,channelReadCompletedoes not request a read.br, zstd and snappy:
PullDecompressiondecompresses them one part at a time with Netty'sDecompressor, under the same checks. Small outputs, such as snappy's frames, are merged into a part sized for them. Outputs larger than 64 KiB, such as zstd's whenio.netty.compression.defaultMaxForwardBytesis raised, are split into retained slices, so the bound applies to delivered parts, not to allocations. Only this class refers to Netty'sDecompressor, and it is loaded only once one of these codings shows up. Without the API, which Netty added in 4.2.17, these codings go toHttpContentDecompressor's own decoders, as before. So does zstd whenio.netty.compression.defaultMaxForwardBytesis 0 or less, where Netty's pull decompressor hands out no output.For all five content codings:
channelInactive, even to a suspended response, because Netty tears the pipeline down with the channel. Suspension no longer bounds that output; onlymaxDecompressedResponseSizedoes.ABORT, timeout), or while the channel is drained (after a redirect, an authentication challenge or a replay), the rest of the body is dropped instead of decompressed. Its end still reaches a drain callback.HttpObjectDecoderreports a chunk it cannot parse as aLastHttpContentwith a failed result. The end of the body now keeps that result, so the request fails instead of completing with a truncated body. A transfer coding such asTransfer-Encoding: gzipstill goes through Netty's decoder, which drops it, as onmain.A suspension made from another thread takes effect when its queued task runs. Until then, the event loop can keep reading and decompressing, as before.
Why HTTP/2 decompression is unchanged
After
END_STREAM, Netty's HTTP/2 stream channel hands every queued frame over on the next read, regardless ofautoRead, and then closes. So a handler in the stream pipeline cannot hold compressed input back, and would have to inflate up to a whole stream window at once (16 MiB by default). That is exactly the decompression-bomb case, so a partial fix would be misleading. It needs either a Netty change (honor demand afterEND_STREAMand close only when drained), proposed in netty/netty#17774, or AHC delivering the body independently of the stream channel.Behavior changes
maxDecompressedResponseSize, not by demand. This applies to any close that finds input held back, not only to bodies delimited by the close.maxDecompressedResponseSizefailed the drain, closed its connection and could fail the request that followed on the same future, for example when aResponseFilterreplays it. Redirects and 401 or 407 challenges with a compressed body usually close the connection instead of draining it, because the decompressed response looks chunked to them, as onmain.Transfer-Encoding: gzip) is still decoded by Netty'sHttpContentDecompressor, as onmain: as soon as it is read, also while suspended or drained, and in Netty's parts.ResponseBodyControl.suspend()says so. After a failure, such as the size limit, what is left of the body is dropped, its end included, as onmain.Compatibility
Http1ContentDecompressorkeeps its superclass,HttpContentDecompressor, and its constructor. It now also implementsChannelOutboundHandler, and gains the public methods of that interface, which pass every outbound event through and only look at read requests. Revapi reports no problem.DecompressorAPI, added in 4.2.17, and fall back to Netty's own decoders without it. Netty still marks it as unstable (@UnstableApi) while it migrates its codecs to it (netty/netty#16743), so a future Netty release may require changes toPullDecompression. The README says what the version buys.Follow-ups
endOfInput(). That changes behaviormainhas, so it gets its own pull request.Review fixes
Four commits on top, one per review finding, each with a test that fails without it: inflate buffers are capped at the part size, so a large codec chunk no longer gives every 64 KiB part a buffer twice the chunk's size; br, zstd and snappy fall back to Netty's own decoders when the
DecompressorAPI is missing instead of failing; zstd is left to Netty at a forwarding threshold of 0, where its pull decompressor hands out nothing and the loop never ended; and the README and Javadoc say when these codings wait for demand.AI disclosure
Claude Code on behalf of Matthias Kurz. The commits include
Co-Authored-ByperAGENTS.md.Test plan
New and changed tests:
Http1DecompressionSuspensionTest(10 tests, live server): the bound with auto-read on and off, connection reuse, a chunked body with trailers, a body ended by connection close, cancel,ABORT,HEADand empty gzip responses, a replay while input is held back with auto-read on and off, and aResponseFilterreplay of a body that inflates far beyond the limit, whose drained connection must go back to the pool.Http1ContentDecompressorSuspensionTest(18 tests,EmbeddedChannel):refCnt1 after the decoder has returned), body correct after resuming, released at the end;Http1ContentDecompressorTest(Inflate HTTP/1.1 gzip and deflate without an EmbeddedChannel #2357's, 34 tests): its comparisons with Netty now expect more parts where Netty hands on a part larger than 64 KiB, which happens in 3 of its scenarios; bodies, errors, the end of the response and releases are compared as before, and every part must be at most 64 KiB. New: a failed decoder result on the last chunk; br, zstd and snappy decoded like Netty's decoders, with every split of Inflate HTTP/1.1 gzip and deflate without an EmbeddedChannel #2357's helper, from heap and direct buffers; the size limit for those codings; merged parts sized for their content; read requests with auto-read off compared with Netty's for those codings too; and a class loader that hides Netty'sDecompressorAPI, under which gzip and deflate decode and a zstd response fails with aLinkageError. The test that mixes this handler's messages with Netty's in one read now uses a transfer coding, since snappy is no longer left to Netty.Http1ContentDecompressorForwardThresholdTest(1 test): in a fresh JVM with-Dio.netty.compression.defaultMaxForwardBytes=0, which hands on the output of every inflate step, a 200,000-byte gzip body is decoded at once and with a suspension after each part. The parent waits at most 60 seconds and kills the child.Http1ContentDecompressorLargeOutputTest(1 test): in a fresh JVM with-Dio.netty.compression.defaultMaxForwardBytes=262144, checks that zstd output really exceeds 64 KiB, that parts stay within 64 KiB, one per resume, and that a tracking allocator sees every buffer released, also when the handler is removed while it holds the rest of a split output.Broken variants, each run against these test classes:
channelInactiveordering test.ResponseFilterreplay test and both unit variants.Runs:
./mvnw -B -ntp -Dgpg.skip=true clean verifyon JDK 11: 1,856 tests, 0 failures, 0 errors, 11 skipped; Revapi passed. No test-skipping flags were used.ResponseBodyControlTestandHttp2ResponseBodyControlTest, compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's-Djvm): 83 tests, 0 failures on each. The fresh-JVM test's child runs on the same JDK.ResponseBodyControl.execute, rawCookieheaders on retries, suspend/resume order): they merge without conflicts, in every pair, and./mvnw -B -ntp -Dgpg.skip=true clean installon JDK 11 passes with 1,903 tests, 0 failures, 0 errors, 11 skipped; Revapi passed.PullDecompression.