feat(core): add HTTP/2 stream dispatch

This commit is contained in:
Zakaria El Orche
2026-08-13 18:33:04 +00:00
parent 9391f80f76
commit c96d51f7ea
24 changed files with 1621 additions and 70 deletions
+24
View File
@@ -927,3 +927,27 @@ path remains allocation-free and avoids duplicating the response model.
existing model internally without introducing a second public header abstraction.
---
## DEC-27 — Drain already-buffered frames before dispatching completed streams
**Context.** A client can write a burst of complete requests before the server schedules their
handlers. Dispatching after every individual HEADERS frame lets a very fast handler close and
release streams while the same inbound burst is still being decoded, making the advertised
concurrency limit dependent on virtual-thread scheduling. Waiting a fixed interval would make the
limit deterministic but would add latency to every ordinary request.
**Decision.** Completed bodyless streams enter a fixed queue bounded by
`MAX_CONCURRENT_STREAMS`. The demultiplexer continues only while its own frame reader already has
bytes buffered; as soon as consuming the next frame would require network input, it drains the
queue to the shared virtual-thread executor. The configured concurrent-stream limit is 64 and the
primitive stream table has exactly the same bound.
**Consequence.** One socket read's request burst is admitted and bounded as a unit, excess streams
receive `REFUSED_STREAM`, and a single request is dispatched immediately without a timer. The demux
still never executes application code or waits for a worker. h2spec's concurrency case passes and
the lifecycle benchmark remains at the allocation noise floor.
**Revisit when.** If production traces show a materially different batching pattern, tune the
advertised limit or reader size from measurements; do not add a sleep-based dispatch delay.
---
+32 -20
View File
@@ -71,7 +71,7 @@ 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 | 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. 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 | — | — |
| 12 — Trailers, half-close, gRPC | not started | — | — |
| 13 — Security hardening & abuse resistance | not started | — | — |
@@ -766,6 +766,18 @@ two-digit calendar day and could not exercise the boundary. **Fix**: use an expl
`EEE, dd MMM yyyy HH:mm:ss 'GMT'` formatter for both protocol renderings and add a deterministic
regression test for the third day of a month. **Phase**: 9.
### EX-47 — A reset queued stream could be returned to the pool before dispatch observed it
Found while closing the stream-dispatch cancellation paths. `receiveRstStream` transitioned a
queued stream to `CLOSED` before deciding whether its release had to be deferred. The subsequent
state check could therefore no longer see `HALF_CLOSED_REMOTE`, returned the object to the pool,
and left the same object referenced by the dispatch queue. A following request could acquire and
mutate it before the queue drained. **Fix**: capture the deferred-release condition before the
transition, mark queued/dispatched streams cancelled, and let the sole queue/worker owner perform
the final release. The regression test sends a complete request, immediately resets it, then sends
a second request and proves that only the second handler invocation and response occur. **Phase**:
10.
---
# PART III — The phases
@@ -2445,16 +2457,15 @@ Flash never sends PUSH_PROMISE, so the two `reserved` states are unreachable for
### Files
Created:
- `h2/stream/Http2Stream.java` — per-stream state. Also the intrusive MPSC node (Phase 3) and
the owner of the per-stream arena (Phase 7).
- `h2/stream/Http2StreamState.java` — the state machine as an explicit transition table, not a
pile of `if`s. Every transition cites its RFC clause.
- `h2/stream/Http2StreamTable.java` — `int → Http2Stream`, open-addressed with linear probing,
- `http2/stream/Http2Stream.java` — per-stream state and owner of the request/response resources.
- `http2/stream/Http2StreamState.java` — the state machine as an explicit transition table, not a
pile of `if`s.
- `http2/stream/Http2StreamTable.java` — `int → Http2Stream`, open-addressed with linear probing,
power-of-two capacity, zero-alloc lookup/insert/remove, sized from `MAX_CONCURRENT_STREAMS`.
- `h2/message/Http2HeaderMap.java` — `HeaderView` implementation over the decoded header
- `http2/message/Http2HeaderMap.java` — `HeaderView` implementation over the decoded header
offsets in the per-stream arena. Same indexed lookup as Phase 4's `Http1HeaderMap`.
- `h2/message/PseudoHeaders.java` — validation and extraction.
- `h2/Http2StreamDispatcher.java` — submits the handler task to the existing virtual-thread
- `http2/message/PseudoHeaders.java` — validation and extraction.
- `http2/Http2StreamDispatcher.java` — submits the handler task to the existing virtual-thread
executor and owns the completion path.
### Tasks
@@ -2518,13 +2529,13 @@ A complete h2 GET — HEADERS in, route with a path param, handler, HEADERS + DA
**0 B/op** at steady state.
### Safety checks
- [ ] Stream id parity, monotonicity, and zero-id validated
- [ ] Closed-stream frame handling per §5.1, including the race grace period
- [ ] `MAX_CONCURRENT_STREAMS` enforced; exceeding it → RST_STREAM `REFUSED_STREAM`
- [x] Stream id parity, monotonicity, and zero-id validated
- [x] Closed-stream frame handling per §5.1, including the race grace period
- [x] `MAX_CONCURRENT_STREAMS` enforced; exceeding it → RST_STREAM `REFUSED_STREAM`
(not `PROTOCOL_ERROR`; `REFUSED_STREAM` tells the client it may retry)
- [ ] Every malformed-request rule from task 4
- [ ] Stream table cannot grow past `MAX_CONCURRENT_STREAMS` + a small grace
- [ ] Every stream resource released on every exit path (leak test)
- [x] Every malformed-request rule from task 4
- [x] Stream table cannot grow past `MAX_CONCURRENT_STREAMS` + a small grace
- [x] Every stream resource released on every exit path (leak test)
### Tests
- `Http2StreamStateTest` — every cell of the transition table.
@@ -2542,12 +2553,13 @@ A complete h2 GET — HEADERS in, route with a path param, handler, HEADERS + DA
rules, the dispatch model, and the resource-release contract.
### DoD
- [ ] `curl --http2 https://localhost:port/ping` returns `pong`.
- [ ] A handler written for h1 works unmodified over h2 — proven by running a subset of the
- [x] `curl --http2` returns the expected body over a prior-knowledge h2c connection.
- [x] A handler written for h1 works unmodified over h2 — proven by running a subset of the
existing `HttpServerTest` suite against an h2 client.
- [ ] `FastPathRouterImpl` unchanged.
- [ ] 0 B/op for the h2 GET path.
- [ ] `h2spec` sections 5 and 8 green.
- [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.
---
+49
View File
@@ -0,0 +1,49 @@
# HTTP/2 streams and request dispatch
Each connection owns a fixed-capacity `Http2StreamTable`. Client stream identifiers are validated
as odd, non-zero and strictly increasing before a stream object is acquired. The table uses
primitive open addressing and a bounded object free list; it never grows beyond the advertised 64
concurrent streams. An excess request receives `REFUSED_STREAM`, allowing the peer to retry it.
## State model
`Http2StreamState` represents `IDLE`, `OPEN`, `HALF_CLOSED_REMOTE`, `HALF_CLOSED_LOCAL` and
`CLOSED`. A class-initialized table maps every receive/send event to either its next state or the
correct stream error. Frames racing with a recently closed stream follow RFC 9113 §5.1 rather than
being rejected uniformly.
## Header and request model
Decoded HPACK fields are copied into storage owned by the stream. Before dispatch,
`PseudoHeaders` enforces ordering, uniqueness, required request pseudo-fields, lowercase regular
names, connection-specific-field rejection, the `te: trailers` exception and host/authority
consistency. Pseudo-fields are not exposed as regular headers; `:authority` is also visible as
`host` so existing middleware sees the same authority through HTTP/1.1 and HTTP/2.
The stream assembles the existing protocol-neutral `Request`, `RequestLine`, `RequestBody` and
`HeaderView` models. Path/query splitting, routing, middleware, not-found handling and exception
handling therefore use the same code as HTTP/1.1. `FastPathRouterImpl` is unchanged.
## Dispatch and ownership
The connection thread decodes and validates frames only. Completed bodyless streams are queued in
a fixed array while more frame bytes are already buffered, then submitted to the server's shared
virtual-thread executor before the demultiplexer waits for the network again. This preserves burst
admission semantics without adding a dispatch timer or blocking the connection thread.
The stream owns its pooled request, response, body, decoded-header arena and response writer.
Normal response completion releases it through the serialized writer callback. RST_STREAM marks a
queued or running stream cancelled and defers release to that sole owner; setup, routing and handler
failures send an appropriate stream reset and release in the failure path. A 100,000-cycle test
proves stable pool counts, and an immediate request/reset/request regression test covers reuse
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.
- 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.