Skip to content

Add HTTP/2 support (RFC 9113 + HPACK + h2c + ALPN)#4

Open
robotdan wants to merge 201 commits into
mainfrom
robotdan/http2
Open

Add HTTP/2 support (RFC 9113 + HPACK + h2c + ALPN)#4
robotdan wants to merge 201 commits into
mainfrom
robotdan/http2

Conversation

@robotdan

Copy link
Copy Markdown
Contributor

Summary

Adds HTTP/2 support to the latte-java/http server: RFC 9113 frame codec, HPACK encoder/decoder, h2c (prior-knowledge and Upgrade), ALPN-negotiated h2 over TLS, gRPC interop, and h2spec conformance harness. Forked from FusionAuth/java-http; this is the largest single feature drop on the project so far.

Highlights

Wire protocol

  • HTTP/2 framing (DATA, HEADERS, PRIORITY, RST_STREAM, SETTINGS, PUSH_PROMISE inbound rejection, PING, GOAWAY, WINDOW_UPDATE, CONTINUATION).
  • HPACK static + dynamic table, Huffman codec (O(1) static-table lookup, Huffman-decode fast path).
  • Per-stream + connection-level flow control, replenish-when-half-empty strategy.

Transport

  • h2c prior-knowledge and h2c Upgrade dispatch via new ProtocolSelector.
  • ALPN-negotiated h2 over TLS with h1.1 fallback.
  • Configurable per-listener via withH2cPriorKnowledgeEnabled / withH2cUpgradeEnabled and the existing TLS cert/key wiring.

Conformance + interop

  • 143/147 h2spec passing (remaining failures pinned in H2SpecHarnessTest and documented in docs/specs/HTTP2.md bug ledger).
  • gRPC interop tests (unary, server-streaming, client-streaming, bidi) over h2c and TLS+h2.
  • 526-line HTTP2HeaderValidationTest covering RFC 9113 §8.1.2.*.

Security

  • Per-vector DoS limits: CONTINUATION flood (CVE-2024-27316), PING flood, RAPID_RESET (CVE-2023-44487), SETTINGS flood, WINDOW_UPDATE flood, empty-DATA flood. All configurable via HTTP2RateLimits + per-connection tracker.
  • Connection-error / stream-error discrimination per RFC 9113 §5.4.

Benchmarks

  • New scenarios: h2-hello, h2-compute, h2-io, h2-stream, h2-large-response, h2-connection-concurrency, TLS+h2 variants.
  • Vendor comparison (Helidon, Undertow, Jetty, Netty, Tomcat) — see docs/BENCHMARKS.md and docs/specs/HTTP2.md performance findings.

Pre-PR review fixes (last 12 commits: e131c22..e56a297)

  • Trailer race fix in HTTPInputStream (skip FixedLengthInputStream on h2 so EOF comes from END_STREAM, not byte count).
  • h2c-Upgrade body smuggling guard (Plan E placeholder rejection).
  • Outer reader catch emits GOAWAY(INTERNAL_ERROR) instead of bare TCP FIN.
  • Writer-thread death signals reader via writerDead flag + enqueueForWriter helper (replaces blocking writerQueue.put on the reader side).
  • Slow-handler deadlock fix: per-stream pipe.offer(timeout) + RST_STREAM(CANCEL), configurable via withHTTP2HandlerReadTimeout.
  • HPACK index 0 → GOAWAY(COMPRESSION_ERROR) per RFC 7541 §2.1 (was unchecked exception escaping as INTERNAL_ERROR).
  • Malformed content-length → RST_STREAM(PROTOCOL_ERROR) per RFC 9113 §8.1.2.6.
  • Closed-stream HEADERS race closed via synchronized(stream) around state check + applyEvent + enqueue.
  • RST_STREAM on already-closed stream tolerated (rapid-reset path).
  • Defensive null-check in HTTPInputStream.drain().
  • Socket leak fix in HTTPServerThread when ProtocolSelector.select() throws.
  • Pinned h2spec known-failure set in H2SpecHarnessTest to surface drift.
  • Added direct h2 response-trailers wire test.

Scope and limits

  • HTTP/3 is out of scope until JDK QUIC API ships — see docs/specs/HTTP3.md.
  • Server push (PUSH_PROMISE outbound) intentionally not implemented (deprecated by browsers; spec calls it out).
  • Plan E (h2c Upgrade with body → implicit stream 1) deferred; the smuggling guard added in this PR rejects such requests with 400 Bad Request until Plan E lands.

Test plan

  • latte clean int --excludePerformance --excludeTimeouts2903 / 2903 pass.
  • h2spec harness (int-h2spec target) — 143/147 pass; the 4 known failures (§6.5.3, §6.9.1, §6.9.2) are pinned in H2SpecHarnessTest.KNOWN_FAILING_SECTIONS so silent drift fails the test.
  • gRPC interop covers all four streaming patterns over h2c, plus unary over TLS+h2.
  • Pre-existing HTTP/1.1 surface (chunked, keep-alive, expect-continue, HEAD, compression, multipart) still passes — verified during pre-PR review.
  • Benchmarks: see docs/BENCHMARKS.md and docs/specs/HTTP2.md (2026-05-21 perf findings).

Notes for reviewers

  • The pre-PR review history is in docs/superpowers/plans/2026-05-21-http2-pre-pr-fixes.md if useful for context.
  • Files inherited from FusionAuth/java-http retain Apache-2.0 headers (e.g. HTTPInputStream.java); new files use MIT/SPDX per .claude/rules/copyright.md.

🤖 Generated with Claude Code

robotdan and others added 30 commits May 6, 2026 10:13
Three deliverables from the brainstorming session:

- docs/specs/HTTP2.md — long-lived RFC 9113 / RFC 7541 compliance matrix
  + architecture overview + peer comparison vs Jetty/Tomcat/Netty/
  Undertow/Helidon. Mirrors HTTP1.1.md in role.
- docs/superpowers/specs/2026-05-05-http2-design.md — dated
  implementation blueprint covering decisions made, class layout,
  threading model, frame layer, HPACK, stream state machine, flow
  control, trailers API, h2c-Upgrade handoff, security mitigations,
  configuration knobs, test plan, and HTTP/1.1 spec drift.
- docs/superpowers/specs/2026-05-05-http11-conformance-cleanup-design.md
  — small parallel sibling spec for HTTP1.1.md drift items found
  during the brainstorm review (mostly verification-only test gaps,
  plus one open item: 417 for unknown Expect values).

Scope locked during brainstorming: full transport surface (h2 ALPN
+ h2c prior-knowledge + h2c Upgrade with the 101 hook as a
prerequisite), trailers in API for both protocols, gRPC interop as
explicit goal, default-on for h2 over TLS, push deliberately out
of scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolve open implementation gaps surfaced during review: ALPN wiring on
SSLSocket, per-stream pipe (ArrayBlockingQueue<byte[]>), per-connection
frame buffers in HTTPBuffers, ClientConnection interface for cleaner-thread
integration, settings retroactive window adjustment coordination, DATA
fragmentation against peer MAX_FRAME_SIZE, full RFC 9110 §6.5.2 trailer
deny-list enumeration, h2 keep-alive / Connection / Transfer-Encoding /
Expect: 100-continue semantics, withHTTP2SettingsAckTimeout knob.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six task-by-task TDD plans covering the full HTTP/2 delivery:
- A: HTTP/1.1 conformance cleanup (Expect 417, verification tests)
- B: HTTP/1.1 trailers API + 101 Switching Protocols hook
- C: HTTP/2 protocol layer (frame codec, HPACK, state machine, flow control)
- D: HTTP/2 wire-up (HTTP2Connection, ProtocolSelector, ALPN, all 3 transport modes, DoS limits)
- E: HTTP/2 conformance + interop (h2spec, gRPC streaming patterns)
- F: HTTP/2 perf + polish (h2load benchmarks, JFR profiling)

Plans A–D are concrete; E–F are deliberately outline-shaped where work
depends on discovery output (h2spec failures, JFR hotspots).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
License headers: switch all new-file code samples in plans from Apache-2.0
boilerplate to "Copyright (c) 2026, The Latte Project" per CLAUDE.md
convention.

Plan B (h1.1 trailers + 101 hook):
- Task 6: rewrite trailer parsing to read line-by-line via parseTrailers()
  helper after the 0\r\n chunk; isolates from the chunk state machine so
  future refactors can't break trailer capture.
- Task 9: HTTPOutputStream takes HTTPRequest in constructor, not via
  setter — correctness-by-construction (caller can't forget).
- Task 10: add explicit pre-task socket-ownership audit step before
  writing switchProtocols implementation.

Plan C (HTTP/2 protocol layer):
- Task 4: clean up buffer sizing — single grow-on-demand byte[16384] form,
  capped at the negotiated MAX_FRAME_SIZE (not RFC ceiling). Drop the
  16 MB-up-front version that read as catastrophic.
- HPACKDynamicTableTest: add max_size=0 test (peer can advertise this).
- HTTP2FlowControlTest: add SETTINGS-induced negative window test;
  asserts signed >= comparison vs naive > 0.

Plan D (HTTP/2 wire-up):
- Task 5: add minimal HTTP2Connection stub as Step 0 so ProtocolSelector
  compiles. Wrap preface peek in try/catch SocketTimeoutException to
  fall through to h1.1 — fixes slowloris vector.
- Task 9: explicit HEADERS+CONTINUATION interleaving check in the reader
  loop (RFC 9113 §6.10); not implicit in buffer scoping.
- Tests: assertEquals(resp.version(), HTTP_2) on every JDK HttpClient h2
  test — JDK silently downgrades to h1.1 on ALPN failure.
- Task 14: graceful shutdown after GOAWAY bounded by existing
  configuration.getShutdownDuration(); no new knob.

Plan E (conformance + interop):
- Make export:false on test deps explicit so zero-dep promise stays
  visible in project.latte.
- Tighten gRPC interop wording: "framing-compatible with grpc-java
  clients," not "drop-in grpc-java server-side."

Spec docs:
- HTTP2.md: footnote on Helidon Níma row (Níma uses VTs as carrier
  threads for an event loop, not strict VT-per-stream); mention
  withShutdownDuration as the graceful-shutdown bound.
- http2-design.md: §"Settings retroactive window adjustment" reinforces
  signed comparison; new §"HEADERS/CONTINUATION interleaving" calls
  out the rule explicitly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per .claude/rules/copyright.md: brand-new files use exactly:
  Copyright (c) 2026 Latte Java
  SPDX-License-Identifier: MIT

Replace earlier "Copyright (c) 2026, The Latte Project" form across all
6 plans. No comma, no "All Rights Reserved", SPDX tag included for
machine-readable license detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…havior

Six raw-socket tests lock in RequestPreambleState behavior that HTTP1.1.md §6
listed as ⚠️ "needs test": bare CR in header value, whitespace before colon,
obs-fold, chunk-extensions, OPTIONS *, and empty Host — all six pass on first
run. Also adds expectResponseSubstring to BaseSocketTest for lenient 200-response
assertions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
assertResponse and assertResponseSubstring shared 38 lines of server-setup
and socket I/O; pull into a private sendAndCapture helper so future
configuration additions don't need to be applied in two places.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…est inner classes

- HTTP1.1.md: Clarify that only OPTIONS * was completed in Plan A; absolute-form and
  authority-form request-target tests remain.
- BaseSocketTest: Move protected Builder class before private ThrowingConsumer interface
  per code-conventions.md visibility ordering (public → protected → package → private).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add ForbiddenTrailers constant set with lowercased header names forbidden
  in trailers (framing, routing, auth, request modifiers, response control,
  caching, connection management).
- Add TE and Trailer header-name constants to Headers inner class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Match the existing pattern used by addHeader/getHeader.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On first EOF from the chunked delegate, copy all parsed trailers onto
the HTTPRequest so handlers can read them via req.getTrailerMap().
A separate chunkedDelegate reference is kept so the copy works even
when a compression wrapper (GZIPInputStream/InflaterInputStream) sits
in front of ChunkedInputStream. The trailersCopied guard prevents
redundant copies on repeated post-EOF reads.

No change to BaseSocketTest was needed — Builder.withHandler() already
existed and was used directly in the new RequestTrailersTest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Force chunked framing, auto-set Trailer: header, gate emission on
TE: trailers (RFC 9110 §6.5). HTTPOutputStream takes HTTPRequest in
constructor for correctness-by-construction.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ocation

Adds switchProtocols(protocol, additionalHeaders, handler) to HTTPResponse that records protocol-switch intent.
HTTPWorker.run() checks isProtocolSwitchPending() after the handler returns, emits the 101 Switching Protocols
preamble directly to the socket, then hands the socket to the ProtocolSwitchHandler and returns — leaving the
socket open and owned by the handler. Normal response.close() / keep-alive logic is bypassed for this path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion/Upgrade overrides

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ForbiddenTrailers was incorrectly positioned after ControlBytes, breaking
alphabetical ordering of nested classes. Moved it to its correct position
after DispositionParameters and before HeaderBytes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
voidmain and others added 30 commits June 27, 2026 20:27
Rewrite HTTPOutputStream into a protocol-agnostic response body pipeline
shared by HTTP/1 and HTTP/2 via a two-method output seam, giving HTTP/2
full gzip/deflate parity and deleting HTTPResponse.rawOutputStream.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ession

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The protocol-seam rewrite dropped the Javadoc on isCommitted(), setSuppressBody(),
and willCompress() and left the class doc stale. Restore them with current behavior,
document the remaining public methods (close/isCompress/setCompress/reset), and update
the class doc to describe the shared HTTP/1.1 + HTTP/2 pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The teardown finally has a receive-buffer drain whose purpose is to keep
close() from emitting a TCP RST when the peer has unread bytes in the OS
receive buffer. It was gated on `if (socketIn != null)`, but socketIn was
declared = null and never assigned: a refactor that moved input-stream
creation into ProtocolSelector dropped the original `socketIn = in`
assignment (present in 2829cc4) and orphaned the declaration + drain block.
The drain has been dead code ever since, so every h2 teardown closed with
unread data and the OS emitted RST instead of FIN.

Drain the inputStream field (exactly what socketIn used to hold). Fixes the
intermittent RST in HTTP2IdleStreamErrorsTest.invalid_preface_response_
completes_cleanly_with_goaway_not_rst (0/20 failures, was ~25-50%).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On server.close() the acceptor calls connection.shutdown() (which only
enqueues a GOAWAY on the async writer queue) and then interrupts the
connection's virtual thread. Interrupting a virtual thread blocked in a
socket read closes the socket (JDK virtual-thread I/O cancellation), so the
interrupt races the writer's flush of the GOAWAY. When the interrupt wins the
peer never sees the GOAWAY, which made HTTP2GoawayTest.goaway_on_graceful_
shutdown intermittently fail (~10%).

shutdown() now enqueues the GOAWAY plus the writer-shutdown sentinel and joins
the writer thread (captured as a field) with a 1s bound. The writer processes
its queue in order — GOAWAY first, flushed, then the sentinel — and exits, so
by the time shutdown() returns (before the acceptor's interrupt) the GOAWAY is
on the wire. Deterministic, no sleep. HTTP2GoawayTest 30/30; full suite
2941/2941 (both previously-flaky h2 timing tests now green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nts to HTTPValues from the private HTTP1Connection class
…gs class rather than the HTTP2Connection class.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce HTTP2WriterThread (the outbound queue + virtual thread + drain
loop, owning the closed flag and the enqueue policies) and rewrite the
coalescing unit test to drive its run() directly. HTTP2Connection still
uses its own writer code until the next task wires it through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HTTP2Connection now owns an HTTP2WriterThread instead of a raw queue +
thread handle + dead flag + inline loop. HTTP2OutputStream takes the
writer object and enqueues via enqueueBlocking. Deletes runWriterLoop,
enqueueForWriter, writerQueue, writerThread, writerDead, and
WRITER_BATCH_SIZE from HTTP2Connection. No wire-behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eld shadow

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Separate server configuration into shared options on Configurable plus
HTTP1Configuration and HTTP2Configuration sub-configs accessed via Consumer
lambdas. Drop HTTP/2 knobs that duplicate shared config (maxHeaderListSize
collapses into maxRequestHeaderSize) and the unwired keep-alive ping and
SETTINGS-ACK timeouts. Convert HTTP2RateLimits from record to builder class.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nobs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e-size upper-bound test, test rename)

Co-Authored-By: Claude Sonnet 4.6 <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