Inflate HTTP/1.1 gzip and deflate without an EmbeddedChannel - #2357
pavel-ptashyts wants to merge 8 commits into
Conversation
HttpContentDecompressor builds an EmbeddedChannel and a new Inflater for every compressed response, and its JdkZlibDecoder copies each direct input buffer onto the heap before inflating it. On a client that reads many small gzip responses this shows up as steady allocation and native zlib init/free on the event loop. Http1ContentDecompressor already lives in the pipeline of a single connection, so it now inflates gzip, x-gzip, deflate and x-deflate itself: one Inflater per wrapper for the life of the connection, reset between responses, fed the input buffer as it is. Header and trailer checks, concatenated members, zlib-or-raw detection and the quiet end of a truncated stream follow JdkZlibDecoder, and a differential test holds the output and errors to Netty's across many chunk splits. Other encodings still go through Netty. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Inflater kept per connection pins zlib's native window on every idle pooled connection, so a client with thousands of keep-alive connections would hold that memory for nothing. Inflaters now live in a small per-event-loop pool between responses. A handler further on may remove this one while a decoded chunk is being forwarded; decoding now stops there instead of carrying on with state that was already handed back. Tests cover removal mid-chunk, close mid-response, a full response, encodings left to Netty alternating with gzip on one connection, composite input, the deflate limit and more read-request shapes. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Checking ctx.isRemoved() after forwarding decoded content only caught a handler further on removing this one. Any way of ending the response from that callback, such as closing the channel, should stop the decoding that made the call, so each dropped response now bumps a counter that the decoding loop checks. The forwarding threshold reads the same system property as Netty's JdkZlibDecoder, and the differential test now also holds the number of decoded parts to Netty's. New tests cover the per-thread inflater pool and its cap, reuse of an inflater handed back by a removal, a close from a handler further on, and read requests for a read that mixes Netty's messages with this handler's, including a failed body. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Netty queues channelInactive, so a handler further on that closes the channel while decoded content is forwarded did not end the response: the rest of the body was still inflated and forwarded. The response is now abandoned when the channel went inactive during the forward. A channel that was already inactive when the message arrived is left alone, because HttpClientCodec ends a body without a length from channelInactive and that last part must still decode. A buffer that cannot grow fails with Netty's DecompressionException rather than an IndexOutOfBoundsException. Tests add a body ending with the connection, Transfer-Encoding gzip, an empty body, read-only input, and the inflater going back to the pool on close and removal. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ZlibDecoder.prepareDecompressBuffer grows the output buffer after every unfinished inflate step, so an allocator that caps heap buffers fails there. Growing only at the start of the next step let a capped buffer pass where Netty throws; the growth now happens at the same point. A channel closed while the response head is forwarded now drops the body already queued behind the head in the same read. The class notes that gzip and deflate no longer reach newContentDecoder and do not follow io.netty.noJdkZlibDecoder. Tests check that every input buffer is released, add pooled direct input and a capped allocator. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pool tests now run on FastThreadLocalThreads, which is what Netty event loops are, so they take the same thread-local path as production, and a test checks that FastThreadLocal.removeAll() drops the idle inflaters. Another checks that the size limit counts every member of a concatenated gzip body together. The two places that fail a response share one helper, a guard that could never be false is gone, and a comment says why the inflater may keep pointing at an input buffer after it is released. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The removeAll() test only showed that the pool was dropped: asking for the pool again creates an empty one, so it passed even without the end() calls in onRemoval. Tests now reach the pool itself and check that the pooled inflater was ended. A message that is not HttpContent while a response is being inflated now goes to HttpContentDecoder instead of failing a cast. Closes AsyncHttpClient#2356 Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An ended Inflater throws NullPointerException on JDK 11 to 21 and IllegalStateException on JDK 25, which failed the pool cleanup test on every JDK 25 CI job. An open inflater with no input throws nothing, so any RuntimeException still shows that the pooled inflater was ended. Claude Code on behalf of Pavel Ptashyts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
CI failed on JDK 25 only (Linux, macOS, Windows), all in one test: 645506f makes the test accept any Review round 8 on 645506f, both at Claude Code on behalf of Pavel Ptashyts |
Summary
Http1ContentDecompressornow inflatesgzip,x-gzip,deflateandx-deflateitself instead ofthrough Netty's
HttpContentDecompressor, which builds anEmbeddedChanneland a newInflaterfor everycompressed response and copies every direct input buffer onto the heap. Every other encoding (
br,zstd,snappy, theTransfer-Encodingfallback) still goes through Netty exactly as before.Closes #2356.
Inflateris borrowed from a small per-event-loop pool (FastThreadLocal, at most 16 idle perwrapper) for the length of one response and reset when it is given back. Idle inflaters are ended when
the event-loop thread's thread locals are removed. Nothing is held per connection, so idle pooled
connections keep no native zlib state.
Inflater.setInput(ByteBuffer)(Java 11+) for direct ones; only a composite buffer with several components is copied.
EmbeddedChannel; decoded buffers are forwarded asDefaultHttpContent, cut at the same 64 KiBthreshold (
io.netty.compression.defaultMaxForwardBytes) asJdkZlibDecoder.maxDecompressedResponseSizeis counted inside the inflate loop, across concatenated members, with thesame
DecompressionExceptionmessage as before.Compatibility
The gzip/deflate handling is a port of
JdkZlibDecoderasHttpContentDecompressorconfigures it(
decompressConcatenated = true,ZLIB_OR_NONEfordeflate,maxAllocation = 0): the same header andtrailer checks and messages, only the first magic byte checked, concatenated members, zlib-vs-raw detection,
a truncated stream ending quietly, fewer than 10 trailing bytes after a member ignored, output buffer growth
at the same points. Header rewriting, 100-continue pass-through and the
read()requests issued withautoReadoff followHttpContentDecoder, including reads that mix its messages with this handler's.A differential test feeds Netty's
HttpContentDecompressorand this handler the same bodies (33 bodiesincluding corrupt and truncated ones) cut into chunks in up to 7 ways, as heap, direct, pooled direct,
composite and read-only buffers, and requires the same headers, decoded bytes, number of decoded parts,
end of response, exception type and message, and that every buffer is released.
Deliberate differences:
throws; this handler throws without forwarding that partial chunk. The exchange fails with the same
exception. As a side effect, with
autoReadoff a read is requested after the failing chunk where Nettymight not request one.
channel, whose
channelInactiveNetty only queues) stops the decoding of that response; the rest of itsbody is not inflated and no
LastHttpContentfollows, so the exchange is completed bychannelInactive.A body that ends with the connection still decodes its last part, which
HttpClientCodecemits after thechannel has gone inactive.
-Dio.netty.noJdkZlibDecoder=trueno longer switches gzip/deflate to JZlib, and a subclass overridingnewContentDecoder(String)no longer sees these four encodings. Both are noted in the class Javadoc.Known and left as is:
HttpContentDecompressorthat never ends keeps Netty'sEmbeddedChanneluntil thenext response that Netty handles, or until the handler or channel goes away, because
HttpContentDecoder.cleanup()is private. Such a response normally closes the connection.FastThreadLocalThreads (a customEventLoopGroupthread factory) idle inflatersare freed by the JDK
Cleanerrather than when the thread exits.EmbeddedChannel; on NIO/epollisActive()also turns falseinside
close()andchannelInactiveis queued the same way.Tests
Http1ContentDecompressorTest(28 tests): the differential test above; header rewriting andkeepEncodingHeader; full responses; trailers; 100-continue;Transfer-Encoding: gzip; alternating withsnappy(left to Netty) on one connection, including a Netty-handled response that never ends; the limit forgzip, deflate and concatenated members; a capped allocator; the per-thread pool, its cap, reuse after a
removal and
end()on thread-local removal; removal and close from a handler further on mid-chunk and whilethe head is forwarded; close and removal mid-response; read requests with
autoReadoff. The existingHttp1DecompressionLimitTestis unchanged and passes.Mutation checks during development: breaking the cross-chunk header path, the removal/close guards, the
activeAtDecodecondition, the head check, the concatenated-member limit andend()inonRemovaleachfail a test. Removing the end-of-step buffer growth makes the inflate loop spin, so that path cannot be
dropped.
Verification
./mvnw clean verify -Dgpg.skip=trueon OpenJDK 11.0.32 (Ubuntu, WSL2) at a19d7f27e: 1,819 tests,0 failures, 0 errors, 11 skipped; Error Prone, NullAway, Javadoc and Revapi pass. GPG signing was skipped
because no key is available in that environment.
Benchmark
Http1ContentDecompressorBenchmark: one gzip response per operation on a keep-aliveEmbeddedChannel,JSON-like body, compressed input in 8 KiB direct pooled chunks, pooled allocator. JMH 1.37, OpenJDK
11.0.32, 2 forks, 5 x 2 s warmup, 10 x 2 s measurement,
-prof gc. Allocation excludes pooled buffers onboth sides. The benchmark runs on JMH worker threads, so it takes
FastThreadLocal's slower path; eventloops take the fast one.
HttpContentDecompressorHttp1ContentDecompressorPer response that is 1.6 to 9 us less CPU and 60 to 93% less allocation. The remaining ~1.2 KB/op is mostly
the benchmark's own response, header and content objects, which both sides allocate.
Review
Reviewed per the two-reviewer loop used by the author's team: Codex
gpt-6.1-solas guided reviewer andClaude
claude-sonnet-5-5as independent reviewer, both atmediumeffort, read-only, a fresh session onthe current HEAD each round, models confirmed from the CLI output. Seven rounds; every actionable finding
was fixed or answered with the reasons listed above.
Closes #2356)Round 7 nits not taken, with reasons: a 100-continue head without its
LastHttpContentbefore a gzipresponse cannot come out of
HttpClientCodec; the inflater's reference to a released input is never readbefore the next
setInput()/reset(), and released unpooled direct memory is freed explicitly; thecomments state invariants the code cannot.
Claude Code on behalf of Pavel Ptashyts
🤖 Generated with Claude Code