feat(core): add HTTP/2 flow-controlled bodies
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user