Skip to content

hpack: decode header blocks from any InputStream, without copying - #1251

Open
pjfanning wants to merge 2 commits into
apache:mainfrom
pjfanning:hpack-stream-agnostic-decoder
Open

hpack: decode header blocks from any InputStream, without copying#1251
pjfanning wants to merge 2 commits into
apache:mainfrom
pjfanning:hpack-stream-agnostic-decoder

Conversation

@pjfanning

Copy link
Copy Markdown
Member

Follows #1231, which removed the decoder's assumption that a single read() fills the buffer. This removes the remaining reasons the decoder needed a ByteArrayInputStream, and then stops copying the header block.

Why the copy was there

HeaderDecompression did payload.compact.asInputStream, with the comment "only compact ByteString supports InputStream with mark/reset". That was accurate — asInputStream differs by ByteString implementation:

implementation stream mark/reset available()
ByteString1C (compact) UnsynchronizedByteArrayInputStream yes exact
ByteString1 (array slice) UnsynchronizedByteArrayInputStream yes exact
ByteStrings (rope) SequenceInputStream no current chunk only

A 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, where ByteString1.compact is ByteString1C(toArray) — a full copy — even though its stream already had everything the decoder needed.

Three dependencies on ByteArrayInputStream semantics remained after #1231:

  • decodeULE128 used in.mark(5) / in.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

On a SequenceInputStream the first throws IOException (so: COMPRESSION_ERROR GOAWAY), and the other two read 0 at a chunk boundary — the loop would exit early and silently truncate the block.

What changed

The decoder now only reads forward:

  • Main loop. while (in.available() > 0) becomes while (true). The end of the stream at READ_HEADER_REPRESENTATION is the normal end of a header block and returns; in every other state it is truncated input.
  • decodeULE128. No mark/reset. Running out of input inside a varint is a decompression failure rather than a rewind-and-ask-for-more.
  • readByte / skipFully helpers. readByte turns the end of the stream into a decompression failure; skipFully skips a run in one go and copes with skip() legitimately returning 0 (the old code relied on the available()-driven loop to retry, which would now spin).
  • Literal name/value. The available() >= length guards are gone — fix: read HPACK string literals with readNBytes #1231 made readStringLiteral use readNBytes and compare the length, so a short literal is already reported.

HeaderDecompression then hands the payload straight over: no compact(), 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. Previously decode() returned early and the partially decoded headers were emitted as a normal ParsedHeadersFrame. 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. parseAndEmit is the only caller and always assembles the complete block first, so none of it was reachable here.

Tests

HpackDecoderSpec gains five cases:

  • decoding over a SequenceInputStream that supports neither mark/reset nor a whole-block available() (asserts markSupported() == false), at chunk sizes 4, 3 and 1 — so string literals and varints straddle chunk boundaries
  • a block truncated inside a string literal, and one truncated inside a length prefix, both expected to fail

The chunked cases fail on main with a decompression failure and pass here.

  • HpackDecoderSpec — 8 passed
  • http-core/testOnly org.apache.pekko.http.impl.engine.http2.* — 18 passed
  • http2-tests/test — 352 passed, 25 ignored, 26 pending
  • scalafmtCheckAll, javafmtCheckAll, headerCheck, http-core/mimaReportBinaryIssues — clean

Unrelated thing noticed while in here

HeaderDecompression.parseAndEmit calls decoder.endHeaderBlock() inside the try, so when a ParsingException escapes the HeaderListener (e.g. a malformed content-type), endHeaderBlock() is skipped and the decoder's reset() never runs — leaving state and headerSize mid-block for the next HEADERS frame on that connection. Pre-existing and untouched here; happy to file it separately.

🤖 Generated with Claude Code

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>
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>
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.

1 participant