feat(core): add HTTP/2 flow-controlled bodies

This commit is contained in:
Zakaria El Orche
2026-08-13 19:00:19 +00:00
parent c96d51f7ea
commit 8d5340a0b4
21 changed files with 1679 additions and 101 deletions
+30
View File
@@ -951,3 +951,33 @@ the lifecycle benchmark remains at the allocation noise floor.
advertised limit or reader size from measurements; do not add a sleep-based dispatch delay.
---
## DEC-28 — Align the receive window with a coalescing bounded DATA pool
**Context.** The demultiplexer cannot block waiting for an application handler, but delaying
WINDOW_UPDATE only provides backpressure after the peer has spent the window it already owns. A
pool smaller than that outstanding credit can be exhausted legitimately. Allocating one buffer per
DATA frame is also unsafe because many tiny or heavily padded frames can consume little payload
storage while exhausting an object-per-frame pool.
**Decision.** Advertise 1 MiB at both the connection and stream receive levels and back the
connection with exactly 64 reusable 16 KiB buffers (also 1 MiB). Adjacent DATA payloads coalesce
into available tail space; padding contributes to flow credit but not storage. WINDOW_UPDATE is
sent at half-window consumption, never merely on receipt. Bodies at or below 64 KiB with a known
length use one reusable contiguous stream buffer and dispatch at END_STREAM; all other bodies
dispatch immediately onto the same protocol-neutral `RequestBody` over a blocking pooled source.
Response DATA uses the same serialized writer with a progress cursor. A stream object itself is
the executor task for initial handling and resumptions, so no per-resume closure is created.
WINDOW_UPDATE only schedules work; it never reads an application `InputStream` on the demux thread.
**Consequence.** Outstanding peer credit and worst-case pooled payload storage match exactly,
small frames do not multiply objects, slow consumers withhold credit naturally, and streaming
responses resume without recursive writer completion or demux blocking. The 100 MiB bidirectional
integration test remains bounded, while JMH measures the streaming request and response paths at
the allocation noise floor.
**Revisit when.** Production memory/throughput measurements justify a different window. Change the
window and pool byte capacity together; never raise credit independently of bounded storage.
---
+48
View File
@@ -0,0 +1,48 @@
# HTTP/2 bodies and flow control
HTTP/2 applies flow control independently to the connection and to every stream. Flash advertises
a 1 MiB receive window at both levels and sends WINDOW_UPDATE only after the application has
consumed at least half a window. A DATA frame decrements both windows by its complete payload
length, including the pad-length byte and padding; only its unpadded data reaches the handler.
## Request bodies
Known bodies up to 64 KiB remain in one reusable contiguous stream buffer. Their handler is
dispatched at END_STREAM, and `RequestBody.bytes()` performs the only allocation: the byte array
returned to application code. For a 1,024-byte body JMH reports exactly 1,040 B/op, the array plus
its object header, with no framework allocation around it.
Larger or unknown-length bodies dispatch after request headers. DATA is copied out of the frame
reader into a connection-owned pool of 64 reusable 16 KiB buffers. Small adjacent frames coalesce
inside a buffer, so the pool is bounded by bytes rather than frame count. The existing
`RequestBody.stream()` blocks only the handler's virtual thread when data is absent. Buffers return
to the pool as reads consume them, and that consumption reopens both receive windows. If a handler
does not read its body, the normal post-handler drain performs the same bounded consumption.
The connection window and pool both cover exactly 1 MiB, so the peer can never hold more credit
than the server can store before backpressure takes effect. Per-stream accepted body bytes remain
bounded by `MAX_REQUEST_BODY_SIZE`. Declared content length is parsed without a String and checked
against the unpadded DATA total at END_STREAM.
## Responses
Fixed byte arrays, known-length streams and unknown-length streams all use one resumable
`Http2ResponseWriter`. It emits DATA frames no larger than the peer's frame limit, the available
connection window, the available stream window and the reusable 16 KiB relay buffer. A
WINDOW_UPDATE schedules the stream on the shared virtual-thread executor; application streams are
never read by the demultiplexer.
`Response.chunked(InputStream)` means unknown-length streaming at the application API. HTTP/2 has
no chunked transfer coding, so Flash emits ordinary DATA followed by END_STREAM and never sends a
`transfer-encoding` field. `Response.stream(InputStream, length)` emits `content-length` and fails
the stream if the source ends before that length.
## Verification
- A real Java HTTP/2 client uploads and downloads 100 MiB over TLS; both directions are validated
byte-for-byte without materializing the test payload.
- A synthetic 100 MiB response proves serialized scratch storage stays below 64 KiB.
- h2spec sections 5, 6.1, 6.9 and 8: 50 passed, one h2spec-skipped case, zero failures.
- Clean Maven build with JMH sources: 633 tests, no failures.
- JMH request streaming: 159.408 ns/op, 0.001 B/op, no GC.
- JMH response streaming frame: 219.090 ns/op, 0.002 B/op, no GC.
+20 -21
View File
@@ -71,8 +71,8 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. |
| 8 — Connection state machine | done | `feature/core/http2` | Preface, transactional SETTINGS, priority PING ACK, connection WINDOW_UPDATE, two-stage GOAWAY, per-socket transport/ALPN dispatch, HPACK block composition, clean curl handshake, h2spec 28/35 selected cases and 0.008 B/op JMH gate complete. Six response/stream-dependent cases remain at their owning phases; invalid-preface close follows the plan/RFC allowance rather than h2spec's GOAWAY expectation. |
| 9 — HPACK encoder + h2 response path | done | `feature/core/http2` | Stateless static-table HPACK encoder; precompiled status/content-type/date fields; reusable response writer with header filtering, bounds, CONTINUATION splitting and fixed DATA happy path; HTTP/1/2 serializer parity test. EX-46 fixed the one-digit Date day-of-month bug. JMH: 174.309 ns/op, 0.001 B/op (noise floor), no GC. 603/603 tests green from a clean `-Pjmh` build. |
| 10 — Stream state machine + dispatch | done | `feature/core/http2` | Explicit stream transition table, bounded primitive stream table and pool, pseudo-header/message validation, protocol-neutral `Request` assembly, virtual-thread dispatch and exception path, cancellation-safe release, raw h2c + Java HTTP/2 integration. h2spec sections 5/8: 37/39; the two content-length/DATA accounting cases are owned by Phase 11. JMH pooled lifecycle: 458.499 ns/op, 0.003 B/op, no GC. 618/618 tests green from a clean `-Pjmh` build. |
| 11 — DATA, flow control, bodies | not started | — | — |
| 10 — Stream state machine + dispatch | done | `feature/core/http2` | Explicit stream transition table, bounded primitive stream table and pool, pseudo-header/message validation, protocol-neutral `Request` assembly, virtual-thread dispatch and exception path, cancellation-safe release, raw h2c + Java HTTP/2 integration. Phase 11 closed the two deferred content-length/DATA cases; h2spec sections 5/8 are now 39/39. JMH pooled lifecycle: 458.499 ns/op, 0.003 B/op, no GC. 618/618 tests green at phase closure. |
| 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. |
| 12 — Trailers, half-close, gRPC | not started | — | — |
| 13 — Security hardening & abuse resistance | not started | — | — |
| 14 — h2c prior knowledge + proxy support | not started | — | — |
@@ -2558,8 +2558,7 @@ rules, the dispatch model, and the resource-release contract.
existing `HttpServerTest` suite against an h2 client.
- [x] `FastPathRouterImpl` unchanged.
- [x] 0 B/op for the pooled protocol-side h2 GET lifecycle (0.003 B/op JMH noise floor).
- [~] `h2spec` sections 5 and 8: 37/39 green. Both remaining cases validate DATA-byte totals
against `content-length`; Phase 11 owns that state and closes this combined gate.
- [x] `h2spec` sections 5 and 8: 39/39 green after DATA-byte accounting landed.
---
@@ -2571,13 +2570,13 @@ backpressure.
### Files
Created:
- `h2/stream/Http2FlowController.java` — connection and stream windows, both directions.
- `h2/message/Http2RequestBody.java` — DATA frames → the `RequestBody` contract.
- `h2/message/DataBufferPool.java` — the fixed-size buffer free list.
- `http2/stream/Http2FlowController.java` — connection and stream windows, both directions.
- `http2/message/Http2RequestBody.java` — DATA frames → the `RequestBody` contract.
- `http2/message/DataBufferPool.java` — the fixed-size buffer free list.
Modified:
- `h2/Http2Connection.java` — DATA dispatch.
- `h2/message/Http2ResponseWriter.java` — multi-frame and streaming bodies.
- `http2/Http2Connection.java` — DATA dispatch.
- `http2/message/Http2ResponseWriter.java` — multi-frame and streaming bodies.
- `models/RequestBody.java` — accept an h2 backing (the Phase 6 refactor made this possible).
### Tasks
@@ -2635,16 +2634,16 @@ Modified:
- Streaming path: 0 B/op at steady state; all buffers come from `DataBufferPool`.
### Safety checks
- [ ] Connection-level **and** stream-level WINDOW_UPDATE both sent
- [ ] Window overflow (> 2^31-1) rejected
- [ ] Window underflow (peer exceeds its window) rejected with the correct scope
- [ ] Padding counted toward flow control
- [ ] Flow control accounted for RST streams until settled
- [ ] `content-length` verified against actual DATA
- [ ] Empty DATA frame flood bounded
- [ ] `DataBufferPool` bounded; exhaustion applies backpressure rather than allocating without
- [x] Connection-level **and** stream-level WINDOW_UPDATE both sent
- [x] Window overflow (> 2^31-1) rejected
- [x] Window underflow (peer exceeds its window) rejected with the correct scope
- [x] Padding counted toward flow control
- [x] Flow control accounted for RST streams until settled
- [x] `content-length` verified against actual DATA
- [x] Empty DATA frame flood bounded
- [x] `DataBufferPool` bounded; exhaustion applies backpressure rather than allocating without
limit
- [ ] Body size bounded by `Http2Limits.MAX_REQUEST_BODY_SIZE` when no handler consumes it
- [x] Body size bounded by `Http2Limits.MAX_REQUEST_BODY_SIZE` when no handler consumes it
### Tests
- `Http2FlowControlTest` — the classic scenarios: a 10 MB upload with a 64 KB window; a
@@ -2663,9 +2662,9 @@ Modified:
and the dispatch-on-END_STREAM optimization with its rationale.
### DoD
- [ ] 100 MB upload and 100 MB download both correct, both bounded memory.
- [ ] `h2spec` DATA and WINDOW_UPDATE sections green.
- [ ] Small-body path allocates exactly one `byte[]` (the user's body).
- [x] 100 MB upload and 100 MB download both correct, both bounded memory.
- [x] `h2spec` DATA and WINDOW_UPDATE sections green (13 passed, one tool-skipped, zero failed).
- [x] Small-body path allocates exactly one `byte[]` (1,040 B/op for a 1,024-byte body).
---
+4 -5
View File
@@ -40,10 +40,9 @@ while dispatch is pending.
## Verification
- Clean Maven build with JMH sources: 618 tests, no failures.
- h2spec sections 5 and 8: 37/39. The two remaining cases require request DATA byte accounting and
are completed with body flow control.
- Phase 11 clean Maven build with JMH sources: 633 tests, no failures.
- h2spec sections 5 and 8: 39/39 after request DATA byte accounting landed.
- Java `HttpClient` negotiates HTTP/2 over TLS and runs an existing parameterized route unchanged.
- curl prior-knowledge h2c receives a valid `200` response and body.
- JMH pooled lifecycle (HPACK decode, request assembly, response write and release):
458.499 ns/op, 0.003 B/op, no GC.
- Current JMH pooled lifecycle (HPACK decode, request assembly, response write and release):
483.571 ns/op, 0.003 B/op, no GC.