hpack: decode header blocks from any InputStream, without copying - #1251
Open
pjfanning wants to merge 2 commits into
Open
hpack: decode header blocks from any InputStream, without copying#1251pjfanning wants to merge 2 commits into
pjfanning wants to merge 2 commits into
Conversation
Motivation: HeaderDecompression compacted every header block before decoding it, because the decoder needed an InputStream with mark/reset and an available() covering the whole block - which of ByteString's implementations only the array-backed ones provide. A HEADERS frame followed by CONTINUATION frames is assembled with `++`, so it is a rope whose asInputStream is a SequenceInputStream, and even a single-frame payload is usually a slice of a network buffer that compact() copies in full. apache#1231 removed the decoder's assumption that one read() fills the buffer. Three dependencies on ByteArrayInputStream semantics remained: - decodeULE128 used mark(5)/reset() to rewind a partially read varint - the main loop was driven by `while (in.available() > 0)` - the literal name and value states waited for `available() >= length` Modification: Read forward only. The main loop now ends when read() reports the end of the stream between representations, which is the normal end of a header block, and every other state treats the end of the stream as truncated input. decodeULE128 reads without marking, readByte reports the end of the stream as a decompression failure, and skipFully skips a run in one go while coping with skip() returning zero. The available() guards before readStringLiteral are gone because readNBytes already reports a short literal. HeaderDecompression then hands the payload straight to the decoder, so a header block is no longer copied. Result: No functional change for a well-formed block. A block that ends mid representation is now reported as a decompression failure - COMPRESSION_ERROR, per RFC 9113 section 4.3 - where it previously decoded to however many headers had been read so far. The decoder no longer supports being fed a block incrementally across calls; parseAndEmit is its only caller and always passes a complete block. Tests: - HpackDecoderSpec gains coverage over a SequenceInputStream that supports neither mark/reset nor a whole-block available(), at chunk sizes 4, 3 and 1, plus two truncated blocks. The chunked cases fail before this change with a decompression failure - 8 passed - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.http2.*" - 18 passed - sbt http2-tests/test - 352 passed, 25 ignored, 26 pending - scalafmtCheckAll, javafmtCheckAll, headerCheck, http-core/mimaReportBinaryIssues - clean References: Follows apache#1231 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pjfanning
marked this pull request as ready for review
August 30, 2026 12:11
Motivation: HeaderDecompression's HeaderListener threw ParsingException straight through the HPACK decoder, which left the connection unable to decode any later HEADERS frame: - Decoder.insertHeader calls the listener before adding the entry to the dynamic table, so the entry for the offending header was never added - decode() unwound at that point, so every representation after it in the block was never read and never added either - endHeaderBlock() was called inside the try, so it was skipped and the decoder kept the state and headerSize of the abandoned block The first two desynchronise the decoder's dynamic table from the peer's encoder's, which HPACK cannot recover from; the third resumes the next block part way through a representation. HeaderDecompression answers a parse failure with a bad request and keeps the connection open, so this is reachable with a single malformed header - an unknown method is enough. Modification: Catch ParsingException in the listener, remember the first ErrorInfo and return null so that decoding runs to the end of the block and the dynamic table keeps tracking the peer's. Report the remembered failure once the block is decoded. Call endHeaderBlock() in a finally as well, so anything else that unwinds - a malformed pseudo header raises Http2ProtocolException - still resets the decoder. The outer ParsingException handler stays as a fallback. Result: A request with an unparseable header still gets a bad request response, and subsequent requests on the same connection are decoded correctly. Tests: - New "keep the connection usable after a header parsing failure" in Http2ClientServerSpec sends a request with an unknown method, expects the bad request, then sends a valid request on the same connection. Without the fix the second request never reaches the handler at all - the spec times out waiting for it - and with the fix it is served normally - sbt "http2-tests/testOnly ...Http2ClientServerSpec" - 8 passed - sbt "http-core/testOnly org.apache.pekko.http.impl.engine.http2.*" - 13 passed - sbt http2-tests/test - 353 passed, 25 ignored, 26 pending - scalafmtCheckAll, headerCheck, http-core/mimaReportBinaryIssues - clean References: Noticed while working on apache#1251 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows #1231, which removed the decoder's assumption that a single
read()fills the buffer. This removes the remaining reasons the decoder needed aByteArrayInputStream, and then stops copying the header block.Why the copy was there
HeaderDecompressiondidpayload.compact.asInputStream, with the comment "only compact ByteString supports InputStream with mark/reset". That was accurate —asInputStreamdiffers byByteStringimplementation:available()ByteString1C(compact)UnsynchronizedByteArrayInputStreamByteString1(array slice)UnsynchronizedByteArrayInputStreamByteStrings(rope)SequenceInputStreamA HEADERS frame plus CONTINUATION frames is assembled with
++(HeaderDecompression.scala:158), so it is a rope. And a single-frame payload is normally a slice of a larger network buffer, whereByteString1.compactisByteString1C(toArray)— a full copy — even though its stream already had everything the decoder needed.Three dependencies on
ByteArrayInputStreamsemantics remained after #1231:decodeULE128usedin.mark(5)/in.reset()to rewind a partially read varintwhile (in.available() > 0)available() >= lengthOn a
SequenceInputStreamthe first throwsIOException(so:COMPRESSION_ERRORGOAWAY), and the other two read0at a chunk boundary — the loop would exit early and silently truncate the block.What changed
The decoder now only reads forward:
while (in.available() > 0)becomeswhile (true). The end of the stream atREAD_HEADER_REPRESENTATIONis the normal end of a header block and returns; in every other state it is truncated input.decodeULE128. Nomark/reset. Running out of input inside a varint is a decompression failure rather than a rewind-and-ask-for-more.readByte/skipFullyhelpers.readByteturns the end of the stream into a decompression failure;skipFullyskips a run in one go and copes withskip()legitimately returning 0 (the old code relied on theavailable()-driven loop to retry, which would now spin).available() >= lengthguards are gone — fix: read HPACK string literals with readNBytes #1231 madereadStringLiteralusereadNBytesand compare the length, so a short literal is already reported.HeaderDecompressionthen hands the payload straight over: nocompact(), so header blocks are no longer copied.Behaviour change worth reviewing
A header block that ends mid-representation now raises a decompression failure —
COMPRESSION_ERROR, which RFC 9113 §4.3 requires for a header block decoding error. Previouslydecode()returned early and the partially decoded headers were emitted as a normalParsedHeadersFrame. I think the new behaviour is the correct one, but it is a change, so flagging it rather than burying it.Related: the decoder no longer supports being fed a block incrementally across
decode()calls. That machinery came from the upstream twitter/netty decoder, which was designed for incremental network feeding.parseAndEmitis the only caller and always assembles the complete block first, so none of it was reachable here.Tests
HpackDecoderSpecgains five cases:SequenceInputStreamthat supports neither mark/reset nor a whole-blockavailable()(assertsmarkSupported() == false), at chunk sizes 4, 3 and 1 — so string literals and varints straddle chunk boundariesThe chunked cases fail on main with a decompression failure and pass here.
HpackDecoderSpec— 8 passedhttp-core/testOnly org.apache.pekko.http.impl.engine.http2.*— 18 passedhttp2-tests/test— 352 passed, 25 ignored, 26 pendingscalafmtCheckAll,javafmtCheckAll,headerCheck,http-core/mimaReportBinaryIssues— cleanUnrelated thing noticed while in here
HeaderDecompression.parseAndEmitcallsdecoder.endHeaderBlock()inside thetry, so when aParsingExceptionescapes theHeaderListener(e.g. a malformedcontent-type),endHeaderBlock()is skipped and the decoder'sreset()never runs — leavingstateandheaderSizemid-block for the next HEADERS frame on that connection. Pre-existing and untouched here; happy to file it separately.🤖 Generated with Claude Code