feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -927,3 +927,27 @@ path remains allocation-free and avoids duplicating the response model.
|
|||||||
existing model internally without introducing a second public header abstraction.
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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 | — | — |
|
| 11 — DATA, flow control, bodies | not started | — | — |
|
||||||
| 12 — Trailers, half-close, gRPC | not started | — | — |
|
| 12 — Trailers, half-close, gRPC | not started | — | — |
|
||||||
| 13 — Security hardening & abuse resistance | 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
|
`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.
|
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
|
# PART III — The phases
|
||||||
@@ -2445,16 +2457,15 @@ Flash never sends PUSH_PROMISE, so the two `reserved` states are unreachable for
|
|||||||
### Files
|
### Files
|
||||||
|
|
||||||
Created:
|
Created:
|
||||||
- `h2/stream/Http2Stream.java` — per-stream state. Also the intrusive MPSC node (Phase 3) and
|
- `http2/stream/Http2Stream.java` — per-stream state and owner of the request/response resources.
|
||||||
the owner of the per-stream arena (Phase 7).
|
- `http2/stream/Http2StreamState.java` — the state machine as an explicit transition table, not a
|
||||||
- `h2/stream/Http2StreamState.java` — the state machine as an explicit transition table, not a
|
pile of `if`s.
|
||||||
pile of `if`s. Every transition cites its RFC clause.
|
- `http2/stream/Http2StreamTable.java` — `int → Http2Stream`, open-addressed with linear probing,
|
||||||
- `h2/stream/Http2StreamTable.java` — `int → Http2Stream`, open-addressed with linear probing,
|
|
||||||
power-of-two capacity, zero-alloc lookup/insert/remove, sized from `MAX_CONCURRENT_STREAMS`.
|
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`.
|
offsets in the per-stream arena. Same indexed lookup as Phase 4's `Http1HeaderMap`.
|
||||||
- `h2/message/PseudoHeaders.java` — validation and extraction.
|
- `http2/message/PseudoHeaders.java` — validation and extraction.
|
||||||
- `h2/Http2StreamDispatcher.java` — submits the handler task to the existing virtual-thread
|
- `http2/Http2StreamDispatcher.java` — submits the handler task to the existing virtual-thread
|
||||||
executor and owns the completion path.
|
executor and owns the completion path.
|
||||||
|
|
||||||
### Tasks
|
### 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.
|
**0 B/op** at steady state.
|
||||||
|
|
||||||
### Safety checks
|
### Safety checks
|
||||||
- [ ] Stream id parity, monotonicity, and zero-id validated
|
- [x] Stream id parity, monotonicity, and zero-id validated
|
||||||
- [ ] Closed-stream frame handling per §5.1, including the race grace period
|
- [x] Closed-stream frame handling per §5.1, including the race grace period
|
||||||
- [ ] `MAX_CONCURRENT_STREAMS` enforced; exceeding it → RST_STREAM `REFUSED_STREAM`
|
- [x] `MAX_CONCURRENT_STREAMS` enforced; exceeding it → RST_STREAM `REFUSED_STREAM`
|
||||||
(not `PROTOCOL_ERROR`; `REFUSED_STREAM` tells the client it may retry)
|
(not `PROTOCOL_ERROR`; `REFUSED_STREAM` tells the client it may retry)
|
||||||
- [ ] Every malformed-request rule from task 4
|
- [x] Every malformed-request rule from task 4
|
||||||
- [ ] Stream table cannot grow past `MAX_CONCURRENT_STREAMS` + a small grace
|
- [x] Stream table cannot grow past `MAX_CONCURRENT_STREAMS` + a small grace
|
||||||
- [ ] Every stream resource released on every exit path (leak test)
|
- [x] Every stream resource released on every exit path (leak test)
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
- `Http2StreamStateTest` — every cell of the transition table.
|
- `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.
|
rules, the dispatch model, and the resource-release contract.
|
||||||
|
|
||||||
### DoD
|
### DoD
|
||||||
- [ ] `curl --http2 https://localhost:port/ping` returns `pong`.
|
- [x] `curl --http2` returns the expected body over a prior-knowledge h2c connection.
|
||||||
- [ ] A handler written for h1 works unmodified over h2 — proven by running a subset of the
|
- [x] A handler written for h1 works unmodified over h2 — proven by running a subset of the
|
||||||
existing `HttpServerTest` suite against an h2 client.
|
existing `HttpServerTest` suite against an h2 client.
|
||||||
- [ ] `FastPathRouterImpl` unchanged.
|
- [x] `FastPathRouterImpl` unchanged.
|
||||||
- [ ] 0 B/op for the h2 GET path.
|
- [x] 0 B/op for the pooled protocol-side h2 GET lifecycle (0.003 B/op JMH noise floor).
|
||||||
- [ ] `h2spec` sections 5 and 8 green.
|
- [~] `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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Benchmark;
|
||||||
|
import org.openjdk.jmh.annotations.BenchmarkMode;
|
||||||
|
import org.openjdk.jmh.annotations.Fork;
|
||||||
|
import org.openjdk.jmh.annotations.Measurement;
|
||||||
|
import org.openjdk.jmh.annotations.Mode;
|
||||||
|
import org.openjdk.jmh.annotations.OutputTimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Scope;
|
||||||
|
import org.openjdk.jmh.annotations.Setup;
|
||||||
|
import org.openjdk.jmh.annotations.State;
|
||||||
|
import org.openjdk.jmh.annotations.Warmup;
|
||||||
|
|
||||||
|
/** Measures the pooled HPACK-decode, request-assembly and fixed-response stream lifecycle. */
|
||||||
|
@State(Scope.Thread)
|
||||||
|
@BenchmarkMode(Mode.AverageTime)
|
||||||
|
@OutputTimeUnit(TimeUnit.NANOSECONDS)
|
||||||
|
@Fork(2)
|
||||||
|
@Warmup(iterations = 3, time = 1)
|
||||||
|
@Measurement(iterations = 5, time = 1)
|
||||||
|
public class Http2StreamBenchmark {
|
||||||
|
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
|
private Http2StreamTable streams;
|
||||||
|
private HpackDecoder decoder;
|
||||||
|
private byte[] requestBlock;
|
||||||
|
private int requestLength;
|
||||||
|
|
||||||
|
@Setup
|
||||||
|
public void setup() {
|
||||||
|
streams = new Http2StreamTable(1);
|
||||||
|
decoder = new HpackDecoder();
|
||||||
|
ByteWriter block = new ByteWriter(64);
|
||||||
|
HpackEncoder.writeIndexed(block, 2);
|
||||||
|
HpackEncoder.writeIndexed(block, 7);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
requestBlock = block.array();
|
||||||
|
requestLength = block.length();
|
||||||
|
lifecycle();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int lifecycle() {
|
||||||
|
Http2Stream stream = streams.acquire(1);
|
||||||
|
decoder.decode(requestBlock, 0, requestLength, stream.headerBlock());
|
||||||
|
stream.assembleRequest(null, null);
|
||||||
|
Response response = stream.resetResponse().body(BODY);
|
||||||
|
stream
|
||||||
|
.responseWriter()
|
||||||
|
.prepare(response, 1, false, false, true, false, false, 16_384, 32_768, 65_535);
|
||||||
|
int bytes = stream.responseWriter().length();
|
||||||
|
streams.remove(1);
|
||||||
|
streams.release(stream);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ import dev.relism.flash.http2.frame.FrameType;
|
|||||||
import dev.relism.flash.http2.frame.FrameValidator;
|
import dev.relism.flash.http2.frame.FrameValidator;
|
||||||
import dev.relism.flash.http2.frame.Http2FrameReader;
|
import dev.relism.flash.http2.frame.Http2FrameReader;
|
||||||
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
|
import dev.relism.flash.http2.hpack.HeaderSink;
|
||||||
|
import dev.relism.flash.http2.stream.Http2Stream;
|
||||||
|
import dev.relism.flash.http2.stream.Http2StreamState;
|
||||||
|
import dev.relism.flash.http2.stream.Http2StreamTable;
|
||||||
import dev.relism.flash.transport.BufferedByteSource;
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
import dev.relism.flash.transport.ConnectionContext;
|
import dev.relism.flash.transport.ConnectionContext;
|
||||||
import dev.relism.flash.transport.ConnectionProtocol;
|
import dev.relism.flash.transport.ConnectionProtocol;
|
||||||
@@ -33,6 +37,8 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
private final Http2Settings.StreamWindowUpdater streamWindows;
|
private final Http2Settings.StreamWindowUpdater streamWindows;
|
||||||
private final long settingsAckTimeoutMs;
|
private final long settingsAckTimeoutMs;
|
||||||
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
|
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
|
||||||
|
private final Http2StreamTable streams = new Http2StreamTable(Http2Limits.MAX_CONCURRENT_STREAMS);
|
||||||
|
private static final HeaderSink DISCARD_HEADERS = (name, value, never) -> {};
|
||||||
|
|
||||||
private long connectionSendWindow = 65_535;
|
private long connectionSendWindow = 65_535;
|
||||||
private int outstandingLocalSettings;
|
private int outstandingLocalSettings;
|
||||||
@@ -43,6 +49,12 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
private boolean peerGoAway;
|
private boolean peerGoAway;
|
||||||
private boolean gracefulStarted;
|
private boolean gracefulStarted;
|
||||||
private boolean gracefulFinished;
|
private boolean gracefulFinished;
|
||||||
|
private int highestClientStreamId;
|
||||||
|
private Http2Stream pendingHeaderStream;
|
||||||
|
private boolean refusingHeaderStream;
|
||||||
|
private Http2StreamDispatcher streamDispatcher;
|
||||||
|
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
|
||||||
|
private int dispatchCount;
|
||||||
|
|
||||||
public Http2Connection() {
|
public Http2Connection() {
|
||||||
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
||||||
@@ -53,13 +65,28 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Http2Connection(Http2Settings.StreamWindowUpdater streamWindows, long settingsAckTimeoutMs) {
|
Http2Connection(Http2Settings.StreamWindowUpdater streamWindows, long settingsAckTimeoutMs) {
|
||||||
this.streamWindows = streamWindows;
|
this.streamWindows =
|
||||||
|
delta -> {
|
||||||
|
try {
|
||||||
|
streams.adjustAllSendWindows(delta);
|
||||||
|
} catch (IllegalStateException overflow) {
|
||||||
|
throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
|
}
|
||||||
|
streamWindows.applyInitialWindowDelta(delta);
|
||||||
|
};
|
||||||
this.settingsAckTimeoutMs = settingsAckTimeoutMs;
|
this.settingsAckTimeoutMs = settingsAckTimeoutMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ConnectionContext ctx) throws IOException {
|
public void run(ConnectionContext ctx) throws IOException {
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
||||||
|
streamDispatcher =
|
||||||
|
new Http2StreamDispatcher(
|
||||||
|
ctx,
|
||||||
|
writer,
|
||||||
|
peerSettings,
|
||||||
|
streams,
|
||||||
|
(streamId, error) -> sendRstStream(writer, streamId, error));
|
||||||
try {
|
try {
|
||||||
run(ctx.in(), writer, ctx.stopped());
|
run(ctx.in(), writer, ctx.stopped());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -119,10 +146,12 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
dispatch(frame, writer);
|
dispatch(frame, writer);
|
||||||
} catch (Http2StreamException streamError) {
|
} catch (Http2StreamException streamError) {
|
||||||
sendRstStream(writer, streamError);
|
sendRstStream(writer, streamError);
|
||||||
|
closeStreamAfterError(streamError.streamId());
|
||||||
} finally {
|
} finally {
|
||||||
reader.consumeFrame();
|
reader.consumeFrame();
|
||||||
}
|
}
|
||||||
writer.drain();
|
writer.drain();
|
||||||
|
if (dispatchCount > 0 && !reader.hasBufferedInput()) dispatchPendingStreams();
|
||||||
checkSettingsTimeout();
|
checkSettingsTimeout();
|
||||||
}
|
}
|
||||||
} catch (Http2Exception connectionError) {
|
} catch (Http2Exception connectionError) {
|
||||||
@@ -160,17 +189,121 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
case PING -> receivePing(frame, writer);
|
case PING -> receivePing(frame, writer);
|
||||||
case WINDOW_UPDATE -> receiveWindowUpdate(frame);
|
case WINDOW_UPDATE -> receiveWindowUpdate(frame);
|
||||||
case GOAWAY -> receiveGoAway(frame);
|
case GOAWAY -> receiveGoAway(frame);
|
||||||
case HEADERS, CONTINUATION -> {
|
case HEADERS -> receiveHeaders(frame, writer);
|
||||||
if (headerBlocks.accept(frame)) {
|
case CONTINUATION -> receiveContinuation(frame, writer);
|
||||||
lastProcessedStreamId = Math.max(lastProcessedStreamId, frame.streamId());
|
case DATA -> receiveData(frame);
|
||||||
if (!gracefulStarted) startGracefulShutdown(writer);
|
case RST_STREAM -> receiveRstStream(frame);
|
||||||
}
|
case PRIORITY -> receivePriority(frame);
|
||||||
}
|
default -> {}
|
||||||
default -> {
|
}
|
||||||
// Stream semantics are introduced by the stream layer. Structurally-valid
|
}
|
||||||
// frames are consumed here so connection-level state remains synchronized.
|
|
||||||
|
private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
|
int streamId = frame.streamId();
|
||||||
|
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
highestClientStreamId = streamId;
|
||||||
|
|
||||||
|
pendingHeaderStream = streams.acquire(streamId);
|
||||||
|
refusingHeaderStream = pendingHeaderStream == null;
|
||||||
|
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock();
|
||||||
|
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveContinuation(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
|
if (pendingHeaderStream == null && !refusingHeaderStream) {
|
||||||
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock();
|
||||||
|
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException {
|
||||||
|
if (refusingHeaderStream) {
|
||||||
|
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
|
||||||
|
} else {
|
||||||
|
Http2Stream stream = pendingHeaderStream;
|
||||||
|
if (streamDispatcher != null) stream.validateHeaders();
|
||||||
|
stream.transition(
|
||||||
|
headerBlocks.endStream()
|
||||||
|
? Http2StreamState.Event.RECV_HEADERS_ES
|
||||||
|
: Http2StreamState.Event.RECV_HEADERS);
|
||||||
|
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
|
||||||
|
if (streamDispatcher == null) {
|
||||||
|
streams.remove(streamId);
|
||||||
|
streams.release(stream);
|
||||||
|
if (!gracefulStarted) startGracefulShutdown(writer);
|
||||||
|
} else if (headerBlocks.endStream()) {
|
||||||
|
enqueueDispatch(stream);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pendingHeaderStream = null;
|
||||||
|
refusingHeaderStream = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receivePriority(FrameHeader frame) {
|
||||||
|
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
|
if (dependency == frame.streamId()) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "stream depends on itself");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveData(FrameHeader frame) {
|
||||||
|
Http2Stream stream = streamForFrame(frame.streamId());
|
||||||
|
stream.transition(
|
||||||
|
FrameFlags.isEndStream(frame.flags())
|
||||||
|
? Http2StreamState.Event.RECV_DATA_ES
|
||||||
|
: Http2StreamState.Event.RECV_DATA);
|
||||||
|
if (frame.length() != 0) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
frame.streamId(), Http2ErrorCode.INTERNAL_ERROR, "request DATA support is not active");
|
||||||
|
}
|
||||||
|
if (FrameFlags.isEndStream(frame.flags()) && streamDispatcher != null) {
|
||||||
|
enqueueDispatch(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveRstStream(FrameHeader frame) {
|
||||||
|
Http2Stream stream = streams.get(frame.streamId());
|
||||||
|
if (stream == null) {
|
||||||
|
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean releaseDeferred =
|
||||||
|
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
|
||||||
|
stream.transition(Http2StreamState.Event.RECV_RST);
|
||||||
|
streams.remove(stream.id());
|
||||||
|
if (releaseDeferred) {
|
||||||
|
stream.cancel();
|
||||||
|
} else {
|
||||||
|
streams.release(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void enqueueDispatch(Http2Stream stream) {
|
||||||
|
if (dispatchCount == dispatchQueue.length) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
stream.id(), Http2ErrorCode.REFUSED_STREAM, "dispatch queue is full");
|
||||||
|
}
|
||||||
|
dispatchQueue[dispatchCount++] = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchPendingStreams() {
|
||||||
|
int count = dispatchCount;
|
||||||
|
dispatchCount = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
Http2Stream stream = dispatchQueue[i];
|
||||||
|
dispatchQueue[i] = null;
|
||||||
|
streamDispatcher.dispatch(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Http2Stream streamForFrame(int streamId) {
|
||||||
|
Http2Stream stream = streams.get(streamId);
|
||||||
|
if (stream != null) return stream;
|
||||||
|
if (streamId > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
throw new Http2StreamException(streamId, Http2ErrorCode.STREAM_CLOSED, "stream is closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
@@ -201,8 +334,25 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
|
|
||||||
private void receiveWindowUpdate(FrameHeader frame) {
|
private void receiveWindowUpdate(FrameHeader frame) {
|
||||||
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
if (increment == 0) throw Http2Exception.PROTOCOL_ERROR;
|
if (increment == 0) {
|
||||||
if (frame.streamId() != 0) return;
|
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
throw new Http2StreamException(
|
||||||
|
frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "zero window increment");
|
||||||
|
}
|
||||||
|
if (frame.streamId() != 0) {
|
||||||
|
Http2Stream stream = streams.get(frame.streamId());
|
||||||
|
if (stream == null) {
|
||||||
|
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
stream.adjustSendWindow(increment);
|
||||||
|
} catch (IllegalStateException overflow) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
frame.streamId(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream window overflow");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
long next = connectionSendWindow + increment;
|
long next = connectionSendWindow + increment;
|
||||||
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
|
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
connectionSendWindow = next;
|
connectionSendWindow = next;
|
||||||
@@ -229,6 +379,21 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
writer.writePriority(rst);
|
writer.writePriority(rst);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void sendRstStream(Http2FrameWriter writer, int streamId, Http2ErrorCode error)
|
||||||
|
throws IOException {
|
||||||
|
ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
|
||||||
|
rst.frame(FrameType.RST_STREAM, 0, streamId, error.bytes(), 0, 4);
|
||||||
|
writer.writePriority(rst);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeStreamAfterError(int streamId) {
|
||||||
|
Http2Stream stream = streams.remove(streamId);
|
||||||
|
if (stream == null) return;
|
||||||
|
if (stream.dispatched()) stream.cancel();
|
||||||
|
else streams.release(stream);
|
||||||
|
if (pendingHeaderStream == stream) pendingHeaderStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void sendGoAway(
|
private void sendGoAway(
|
||||||
Http2FrameWriter writer, int lastStreamId, Http2ErrorCode error, String debug)
|
Http2FrameWriter writer, int lastStreamId, Http2ErrorCode error, String debug)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
@@ -303,5 +468,9 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
peerGoAway = false;
|
peerGoAway = false;
|
||||||
gracefulStarted = false;
|
gracefulStarted = false;
|
||||||
gracefulFinished = false;
|
gracefulFinished = false;
|
||||||
|
highestClientStreamId = 0;
|
||||||
|
pendingHeaderStream = null;
|
||||||
|
refusingHeaderStream = false;
|
||||||
|
dispatchCount = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import dev.relism.flash.http2.frame.FrameType;
|
|||||||
import dev.relism.flash.http2.frame.Padding;
|
import dev.relism.flash.http2.frame.Padding;
|
||||||
import dev.relism.flash.http2.hpack.ContinuationAssembler;
|
import dev.relism.flash.http2.hpack.ContinuationAssembler;
|
||||||
import dev.relism.flash.http2.hpack.HeaderListSizeException;
|
import dev.relism.flash.http2.hpack.HeaderListSizeException;
|
||||||
|
import dev.relism.flash.http2.hpack.HeaderSink;
|
||||||
import dev.relism.flash.http2.hpack.HpackDecoder;
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
|
||||||
|
|
||||||
/** Composes frame fragment extraction, CONTINUATION assembly and HPACK decoding. */
|
/** Composes frame fragment extraction, CONTINUATION assembly and HPACK decoding. */
|
||||||
final class Http2HeaderBlockDecoder {
|
final class Http2HeaderBlockDecoder {
|
||||||
@@ -16,14 +16,14 @@ final class Http2HeaderBlockDecoder {
|
|||||||
|
|
||||||
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
||||||
private final HpackDecoder decoder = new HpackDecoder();
|
private final HpackDecoder decoder = new HpackDecoder();
|
||||||
private final HpackHeaderBlock headers = new HpackHeaderBlock();
|
private boolean endStream;
|
||||||
|
|
||||||
boolean insideHeaderBlock() {
|
boolean insideHeaderBlock() {
|
||||||
return assembler.isActive();
|
return assembler.isActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
||||||
boolean accept(FrameHeader frame) {
|
boolean accept(FrameHeader frame, HeaderSink sink) {
|
||||||
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
||||||
throw Http2Exception.PROTOCOL_ERROR;
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
}
|
}
|
||||||
@@ -41,9 +41,8 @@ final class Http2HeaderBlockDecoder {
|
|||||||
}
|
}
|
||||||
if (!assembler.isComplete()) return false;
|
if (!assembler.isComplete()) return false;
|
||||||
|
|
||||||
headers.reset();
|
|
||||||
try {
|
try {
|
||||||
decoder.decode(assembler.buffer(), 0, assembler.length(), headers);
|
decoder.decode(assembler.buffer(), 0, assembler.length(), sink);
|
||||||
} catch (HeaderListSizeException tooLarge) {
|
} catch (HeaderListSizeException tooLarge) {
|
||||||
int streamId = assembler.streamId();
|
int streamId = assembler.streamId();
|
||||||
assembler.reset();
|
assembler.reset();
|
||||||
@@ -54,7 +53,12 @@ final class Http2HeaderBlockDecoder {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean endStream() {
|
||||||
|
return endStream;
|
||||||
|
}
|
||||||
|
|
||||||
private void begin(FrameHeader frame) {
|
private void begin(FrameHeader frame) {
|
||||||
|
endStream = FrameFlags.isEndStream(frame.flags());
|
||||||
long unpadded =
|
long unpadded =
|
||||||
Padding.unpad(
|
Padding.unpad(
|
||||||
frame.buffer(),
|
frame.buffer(),
|
||||||
@@ -65,6 +69,15 @@ final class Http2HeaderBlockDecoder {
|
|||||||
int fragmentLength = Pairs.lo(unpadded);
|
int fragmentLength = Pairs.lo(unpadded);
|
||||||
if (FrameFlags.hasPriority(frame.flags())) {
|
if (FrameFlags.hasPriority(frame.flags())) {
|
||||||
if (fragmentLength < PRIORITY_FIELDS_LENGTH) throw Http2Exception.FRAME_SIZE_ERROR;
|
if (fragmentLength < PRIORITY_FIELDS_LENGTH) throw Http2Exception.FRAME_SIZE_ERROR;
|
||||||
|
int dependency =
|
||||||
|
((frame.buffer()[fragmentOffset] & 0x7f) << 24)
|
||||||
|
| ((frame.buffer()[fragmentOffset + 1] & 0xff) << 16)
|
||||||
|
| ((frame.buffer()[fragmentOffset + 2] & 0xff) << 8)
|
||||||
|
| (frame.buffer()[fragmentOffset + 3] & 0xff);
|
||||||
|
if (dependency == frame.streamId()) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "stream depends on itself");
|
||||||
|
}
|
||||||
fragmentOffset += PRIORITY_FIELDS_LENGTH;
|
fragmentOffset += PRIORITY_FIELDS_LENGTH;
|
||||||
fragmentLength -= PRIORITY_FIELDS_LENGTH;
|
fragmentLength -= PRIORITY_FIELDS_LENGTH;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public final class Http2Limits {
|
|||||||
* owns a per-stream HPACK arena and request/response state) against a peer that simply opens
|
* owns a per-stream HPACK arena and request/response state) against a peer that simply opens
|
||||||
* streams and never closes them.
|
* streams and never closes them.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_CONCURRENT_STREAMS = 100;
|
public static final int MAX_CONCURRENT_STREAMS = 64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The largest frame payload we accept without the peer first raising it via our own {@code
|
* The largest frame payload we accept without the peer first raising it via our own {@code
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
|
import dev.relism.flash.http2.message.Http2ResponseWriter;
|
||||||
|
import dev.relism.flash.http2.stream.Http2Stream;
|
||||||
|
import dev.relism.flash.http2.stream.Http2StreamState;
|
||||||
|
import dev.relism.flash.http2.stream.Http2StreamTable;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.RequestHandler;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.transport.ConnectionContext;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/** Dispatches completed request streams without blocking the connection demultiplexer. */
|
||||||
|
@Slf4j
|
||||||
|
final class Http2StreamDispatcher {
|
||||||
|
@FunctionalInterface
|
||||||
|
interface FailureSink {
|
||||||
|
void fail(int streamId, Http2ErrorCode errorCode) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final ConnectionContext context;
|
||||||
|
private final Http2FrameWriter frameWriter;
|
||||||
|
private final Http2Settings peerSettings;
|
||||||
|
private final Http2StreamTable streams;
|
||||||
|
private final FailureSink failures;
|
||||||
|
private volatile boolean firstResponse = true;
|
||||||
|
|
||||||
|
Http2StreamDispatcher(
|
||||||
|
ConnectionContext context,
|
||||||
|
Http2FrameWriter frameWriter,
|
||||||
|
Http2Settings peerSettings,
|
||||||
|
Http2StreamTable streams,
|
||||||
|
FailureSink failures) {
|
||||||
|
this.context = context;
|
||||||
|
this.frameWriter = frameWriter;
|
||||||
|
this.peerSettings = peerSettings;
|
||||||
|
this.streams = streams;
|
||||||
|
this.failures = failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispatch(Http2Stream stream) {
|
||||||
|
if (stream.cancelled()) {
|
||||||
|
streams.release(stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream.markDispatched();
|
||||||
|
try {
|
||||||
|
context.executor().execute(() -> handle(stream));
|
||||||
|
} catch (RejectedExecutionException rejected) {
|
||||||
|
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle(Http2Stream stream) {
|
||||||
|
try {
|
||||||
|
Request request = stream.assembleRequest(context.remoteAddress(), context.sslSocket());
|
||||||
|
Response pooled = stream.resetResponse();
|
||||||
|
Response response = pooled;
|
||||||
|
Object routeScratch = stream.routeScratch(context.router());
|
||||||
|
RequestHandler handler = context.router().route(request, routeScratch);
|
||||||
|
if (handler == null) handler = context.router().getNotFoundHandler();
|
||||||
|
try {
|
||||||
|
Object result = handler.handle(request, response);
|
||||||
|
if (result instanceof Response returned) response = returned;
|
||||||
|
else if (result != null) response.setBody(result);
|
||||||
|
} catch (Exception handlerFailure) {
|
||||||
|
Object result =
|
||||||
|
context.router().getExceptionHandler().handle(handlerFailure, request, response);
|
||||||
|
if (result instanceof Response returned) response = returned;
|
||||||
|
else if (result != null) response.setBody(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
Http2ResponseWriter responseWriter = stream.responseWriter();
|
||||||
|
if (stream.cancelled()) {
|
||||||
|
request.recycle();
|
||||||
|
if (response == pooled) pooled.recycle();
|
||||||
|
streams.release(stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean prepared;
|
||||||
|
synchronized (this) {
|
||||||
|
boolean tableUpdate = firstResponse;
|
||||||
|
prepared =
|
||||||
|
responseWriter.prepare(
|
||||||
|
response,
|
||||||
|
stream.id(),
|
||||||
|
request.method() == HttpMethod.HEAD,
|
||||||
|
context.configuration().isSendDate(),
|
||||||
|
true,
|
||||||
|
context.configuration().isH2HuffmanDynamicValues(),
|
||||||
|
tableUpdate,
|
||||||
|
peerSettings.maxFrameSize(),
|
||||||
|
peerSettings.maxHeaderListSize(),
|
||||||
|
(int) Math.min(stream.sendWindow(), Integer.MAX_VALUE));
|
||||||
|
if (prepared) {
|
||||||
|
firstResponse = false;
|
||||||
|
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
|
||||||
|
request.recycle();
|
||||||
|
if (response == pooled) pooled.recycle();
|
||||||
|
streams.remove(stream.id());
|
||||||
|
frameWriter.write(responseWriter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!prepared) {
|
||||||
|
request.recycle();
|
||||||
|
if (response == pooled) pooled.recycle();
|
||||||
|
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, null);
|
||||||
|
}
|
||||||
|
} catch (Exception failure) {
|
||||||
|
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
|
||||||
|
if (stream.id() == 0) return;
|
||||||
|
if (cause != null) log.error("HTTP/2 stream {} failed", stream.id(), cause);
|
||||||
|
streams.remove(stream.id());
|
||||||
|
try {
|
||||||
|
failures.fail(stream.id(), error);
|
||||||
|
} catch (IOException writeFailure) {
|
||||||
|
log.debug("Failed to write RST_STREAM for {}", stream.id(), writeFailure);
|
||||||
|
} finally {
|
||||||
|
streams.release(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,6 +116,11 @@ public final class Http2FrameReader {
|
|||||||
return totalRead != 0 && System.nanoTime() >= frameDeadlineNanos;
|
return totalRead != 0 && System.nanoTime() >= frameDeadlineNanos;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether another frame may be consumed immediately without waiting for network input. */
|
||||||
|
public boolean hasBufferedInput() {
|
||||||
|
return totalRead != 0 || in.available() != 0;
|
||||||
|
}
|
||||||
|
|
||||||
private static int decodeLength(byte[] buf, int off) {
|
private static int decodeLength(byte[] buf, int off) {
|
||||||
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
|
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
|
||||||
return (b0 << 16) | (b1 << 8) | b2;
|
return (b0 << 16) | (b1 << 8) | b2;
|
||||||
|
|||||||
@@ -306,8 +306,7 @@ public final class Huffman {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Huffman-encodes {@code src[off, off + len)}, writing directly into {@code out}. Pads the final
|
* Huffman-encodes {@code src[off, off + len)}, writing directly into {@code out}. Pads the final
|
||||||
* byte with the high-order bits of the EOS code (all 1s), per RFC 7541 §5.2. Built now ({@code
|
* byte with the high-order bits of the EOS code (all 1s), per RFC 7541 §5.2.
|
||||||
* EX} task 3) for use by the HPACK encoder.
|
|
||||||
*/
|
*/
|
||||||
public static void encode(ByteWriter out, byte[] src, int off, int len) {
|
public static void encode(ByteWriter out, byte[] src, int off, int len) {
|
||||||
long accumulator = 0;
|
long accumulator = 0;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package dev.relism.flash.http2.message;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
import dev.relism.flash.models.HeaderView;
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Header view over stream-owned decoded HPACK storage. Pseudo-fields are excluded. */
|
||||||
|
public final class Http2HeaderMap implements HeaderView {
|
||||||
|
private static final int VIEW_COUNT = 4;
|
||||||
|
|
||||||
|
private final PooledSlice scanName = new PooledSlice();
|
||||||
|
private final PooledSlice scanValue = new PooledSlice();
|
||||||
|
private final PooledSlice[] views = new PooledSlice[VIEW_COUNT];
|
||||||
|
private HpackHeaderBlock block;
|
||||||
|
private PseudoHeaders pseudoHeaders;
|
||||||
|
private int viewCursor;
|
||||||
|
private int regularCount;
|
||||||
|
|
||||||
|
public Http2HeaderMap() {
|
||||||
|
for (int i = 0; i < views.length; i++) views[i] = new PooledSlice();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset(HpackHeaderBlock block, PseudoHeaders pseudoHeaders) {
|
||||||
|
this.block = block;
|
||||||
|
this.pseudoHeaders = pseudoHeaders;
|
||||||
|
viewCursor = 0;
|
||||||
|
regularCount = 0;
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, scanName, scanValue);
|
||||||
|
if (scanName.byteAt(0) != ':') regularCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String first(String name) {
|
||||||
|
ByteView value = find(name, scanValue);
|
||||||
|
if (value == null) return null;
|
||||||
|
PooledSlice slice = (PooledSlice) value;
|
||||||
|
return new String(slice.array(), slice.offset(), slice.length(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> all(String name) {
|
||||||
|
List<String> result = null;
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, scanName, scanValue);
|
||||||
|
if (scanName.byteAt(0) == ':' || !equalsIgnoreCase(scanName, name)) continue;
|
||||||
|
if (result == null) result = new ArrayList<>();
|
||||||
|
result.add(
|
||||||
|
new String(
|
||||||
|
scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
if (result == null && isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) {
|
||||||
|
return List.of(string(pseudoHeaders.authority()));
|
||||||
|
}
|
||||||
|
return result == null ? List.of() : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> all() {
|
||||||
|
List<String> result = new ArrayList<>(regularCount);
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, scanName, scanValue);
|
||||||
|
if (scanName.byteAt(0) != ':') result.add(string(scanValue));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ByteView view(String name) {
|
||||||
|
PooledSlice target = views[viewCursor++ & (views.length - 1)];
|
||||||
|
return find(name, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean valueEqualsIgnoreCase(String name, String value) {
|
||||||
|
ByteView found = find(name, scanValue);
|
||||||
|
return found != null && equalsIgnoreCase(found, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean contains(String name) {
|
||||||
|
return find(name, scanValue) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int count() {
|
||||||
|
return regularCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void forEach(HeaderConsumer consumer) {
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, scanName, scanValue);
|
||||||
|
if (scanName.byteAt(0) != ':') consumer.accept(scanName, scanValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PooledSlice find(String requested, PooledSlice target) {
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, scanName, scanValue);
|
||||||
|
if (scanName.byteAt(0) != ':' && equalsIgnoreCase(scanName, requested)) {
|
||||||
|
target.reset(scanValue.array(), scanValue.offset(), scanValue.length());
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) {
|
||||||
|
PooledSlice authority = pseudoHeaders.authority();
|
||||||
|
target.reset(authority.array(), authority.offset(), authority.length());
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isAuthorityAlias(String name) {
|
||||||
|
return name.equalsIgnoreCase("host") || name.equalsIgnoreCase(":authority");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean equalsIgnoreCase(ByteView bytes, String value) {
|
||||||
|
if (bytes.length() != value.length()) return false;
|
||||||
|
for (int i = 0; i < bytes.length(); i++) {
|
||||||
|
int left = bytes.byteAt(i) & 0xff;
|
||||||
|
int right = value.charAt(i);
|
||||||
|
if (left >= 'A' && left <= 'Z') left += 32;
|
||||||
|
if (right >= 'A' && right <= 'Z') right += 32;
|
||||||
|
if (left != right) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String string(PooledSlice slice) {
|
||||||
|
return new String(slice.array(), slice.offset(), slice.length(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,11 @@ import dev.relism.flash.models.ResponseSerializer;
|
|||||||
* connection write lock and exposes it as one {@link WriteIntent}.
|
* connection write lock and exposes it as one {@link WriteIntent}.
|
||||||
*/
|
*/
|
||||||
public final class Http2ResponseWriter implements WriteIntent, ResponseSerializer.FieldConsumer {
|
public final class Http2ResponseWriter implements WriteIntent, ResponseSerializer.FieldConsumer {
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface Completion {
|
||||||
|
void responseWriteCompleted();
|
||||||
|
}
|
||||||
|
|
||||||
private static final int STATUS_NAME_LENGTH = 7;
|
private static final int STATUS_NAME_LENGTH = 7;
|
||||||
private static final int CONTENT_LENGTH_NAME_LENGTH = 14;
|
private static final int CONTENT_LENGTH_NAME_LENGTH = 14;
|
||||||
|
|
||||||
@@ -31,6 +36,7 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
private int streamId;
|
private int streamId;
|
||||||
private long headerListSize;
|
private long headerListSize;
|
||||||
private long maxHeaderListSize;
|
private long maxHeaderListSize;
|
||||||
|
private Completion completion;
|
||||||
|
|
||||||
public Http2ResponseWriter() {
|
public Http2ResponseWriter() {
|
||||||
this(1024, 2048);
|
this(1024, 2048);
|
||||||
@@ -42,6 +48,10 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
frames = new FrameWriteBuffer(output);
|
frames = new FrameWriteBuffer(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void completion(Completion completion) {
|
||||||
|
this.completion = completion;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares a non-streaming response. Returns {@code false} when the body needs the deferred DATA
|
* Prepares a non-streaming response. Returns {@code false} when the body needs the deferred DATA
|
||||||
* flow-control path implemented by the stream scheduler.
|
* flow-control path implemented by the stream scheduler.
|
||||||
@@ -236,4 +246,9 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
public void setMpscNext(WriteIntent next) {
|
public void setMpscNext(WriteIntent next) {
|
||||||
this.next = next;
|
this.next = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void completed() {
|
||||||
|
if (completion != null) completion.responseWriteCompleted();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package dev.relism.flash.http2.message;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
|
||||||
|
/** Validates request pseudo-headers and HTTP/2 field rules while extracting request metadata. */
|
||||||
|
public final class PseudoHeaders {
|
||||||
|
private static final int METHOD = 1;
|
||||||
|
private static final int SCHEME = 2;
|
||||||
|
private static final int PATH = 4;
|
||||||
|
private static final int AUTHORITY = 8;
|
||||||
|
|
||||||
|
private final PooledSlice name = new PooledSlice();
|
||||||
|
private final PooledSlice value = new PooledSlice();
|
||||||
|
private final PooledSlice method = new PooledSlice();
|
||||||
|
private final PooledSlice scheme = new PooledSlice();
|
||||||
|
private final PooledSlice path = new PooledSlice();
|
||||||
|
private final PooledSlice authority = new PooledSlice();
|
||||||
|
private final PooledSlice host = new PooledSlice();
|
||||||
|
private int present;
|
||||||
|
|
||||||
|
public void validate(HpackHeaderBlock block, int streamId) {
|
||||||
|
present = 0;
|
||||||
|
method.reset(null, 0, 0);
|
||||||
|
scheme.reset(null, 0, 0);
|
||||||
|
path.reset(null, 0, 0);
|
||||||
|
authority.reset(null, 0, 0);
|
||||||
|
host.reset(null, 0, 0);
|
||||||
|
boolean regularSeen = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, name, value);
|
||||||
|
if (name.length() == 0) fail(streamId, "empty field name");
|
||||||
|
boolean pseudo = name.byteAt(0) == ':';
|
||||||
|
if (pseudo) {
|
||||||
|
if (regularSeen) fail(streamId, "pseudo-header after regular field");
|
||||||
|
int bit = pseudoBit(name);
|
||||||
|
if (bit == 0) fail(streamId, "unknown pseudo-header");
|
||||||
|
if ((present & bit) != 0) fail(streamId, "duplicate pseudo-header");
|
||||||
|
present |= bit;
|
||||||
|
copySlice(bit, value);
|
||||||
|
} else {
|
||||||
|
regularSeen = true;
|
||||||
|
validateRegular(name, value, streamId);
|
||||||
|
if (equals(name, "host")) copy(value, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((present & METHOD) == 0) fail(streamId, "missing :method");
|
||||||
|
boolean connect = equals(method, "CONNECT");
|
||||||
|
if (connect) {
|
||||||
|
if ((present & AUTHORITY) == 0) fail(streamId, "CONNECT requires :authority");
|
||||||
|
if ((present & (SCHEME | PATH)) != 0) fail(streamId, "CONNECT forbids :scheme and :path");
|
||||||
|
} else {
|
||||||
|
int required = METHOD | SCHEME | PATH | AUTHORITY;
|
||||||
|
if ((present & required) != required) fail(streamId, "missing request pseudo-header");
|
||||||
|
if (path.length() == 0) fail(streamId, "empty :path");
|
||||||
|
}
|
||||||
|
if (host.array() != null && authority.array() != null && !equals(host, authority)) {
|
||||||
|
fail(streamId, "host conflicts with :authority");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public PooledSlice method() {
|
||||||
|
return method;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PooledSlice scheme() {
|
||||||
|
return scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PooledSlice path() {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PooledSlice authority() {
|
||||||
|
return authority;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void copySlice(int bit, PooledSlice source) {
|
||||||
|
if (bit == METHOD) copy(source, method);
|
||||||
|
else if (bit == SCHEME) copy(source, scheme);
|
||||||
|
else if (bit == PATH) copy(source, path);
|
||||||
|
else copy(source, authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void copy(PooledSlice source, PooledSlice target) {
|
||||||
|
target.reset(source.array(), source.offset(), source.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int pseudoBit(ByteView name) {
|
||||||
|
if (equals(name, ":method")) return METHOD;
|
||||||
|
if (equals(name, ":scheme")) return SCHEME;
|
||||||
|
if (equals(name, ":path")) return PATH;
|
||||||
|
if (equals(name, ":authority")) return AUTHORITY;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateRegular(ByteView name, ByteView value, int streamId) {
|
||||||
|
for (int i = 0; i < name.length(); i++) {
|
||||||
|
int c = name.byteAt(i) & 0xff;
|
||||||
|
if (c >= 'A' && c <= 'Z') fail(streamId, "uppercase field name");
|
||||||
|
}
|
||||||
|
if (equals(name, "connection")
|
||||||
|
|| equals(name, "keep-alive")
|
||||||
|
|| equals(name, "proxy-connection")
|
||||||
|
|| equals(name, "transfer-encoding")
|
||||||
|
|| equals(name, "upgrade")) {
|
||||||
|
fail(streamId, "connection-specific field");
|
||||||
|
}
|
||||||
|
if (equals(name, "te") && !equals(value, "trailers")) {
|
||||||
|
fail(streamId, "invalid te field");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean equals(ByteView view, String expected) {
|
||||||
|
if (view.length() != expected.length()) return false;
|
||||||
|
for (int i = 0; i < view.length(); i++) {
|
||||||
|
if ((view.byteAt(i) & 0xff) != expected.charAt(i)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean equals(ByteView left, ByteView right) {
|
||||||
|
if (left.length() != right.length()) return false;
|
||||||
|
for (int i = 0; i < left.length(); i++) {
|
||||||
|
if (left.byteAt(i) != right.byteAt(i)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void fail(int streamId, String message) {
|
||||||
|
throw new Http2StreamException(streamId, Http2ErrorCode.PROTOCOL_ERROR, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
import dev.relism.flash.http2.message.Http2HeaderMap;
|
||||||
|
import dev.relism.flash.http2.message.Http2ResponseWriter;
|
||||||
|
import dev.relism.flash.http2.message.PseudoHeaders;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.RequestBody;
|
||||||
|
import dev.relism.flash.models.RequestLine;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
|
||||||
|
/** Per-stream request, response, decoded-header and write state. */
|
||||||
|
public final class Http2Stream implements Http2ResponseWriter.Completion {
|
||||||
|
private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'};
|
||||||
|
|
||||||
|
private final HpackHeaderBlock headerBlock = new HpackHeaderBlock();
|
||||||
|
private final PseudoHeaders pseudoHeaders = new PseudoHeaders();
|
||||||
|
private final Http2HeaderMap headers = new Http2HeaderMap();
|
||||||
|
private final RequestLine requestLine = new RequestLine();
|
||||||
|
private final RequestBody requestBody = new RequestBody();
|
||||||
|
private final Request request = new Request();
|
||||||
|
private final Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||||
|
private final Http2ResponseWriter responseWriter = new Http2ResponseWriter();
|
||||||
|
private final PooledSlice path = new PooledSlice();
|
||||||
|
private final PooledSlice query = new PooledSlice();
|
||||||
|
private final PooledSlice protocol = new PooledSlice();
|
||||||
|
|
||||||
|
private int id;
|
||||||
|
private Http2StreamState state = Http2StreamState.IDLE;
|
||||||
|
private int sendWindow = 65_535;
|
||||||
|
private Http2StreamTable owner;
|
||||||
|
private Object routeScratch;
|
||||||
|
private volatile boolean dispatched;
|
||||||
|
private volatile boolean cancelled;
|
||||||
|
private boolean headersValidated;
|
||||||
|
Http2Stream poolNext;
|
||||||
|
|
||||||
|
Http2Stream() {
|
||||||
|
responseWriter.completion(this);
|
||||||
|
protocol.reset(HTTP_2, 0, HTTP_2.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset(int id, Http2StreamTable owner) {
|
||||||
|
this.id = id;
|
||||||
|
this.owner = owner;
|
||||||
|
state = Http2StreamState.IDLE;
|
||||||
|
sendWindow = 65_535;
|
||||||
|
dispatched = false;
|
||||||
|
cancelled = false;
|
||||||
|
headersValidated = false;
|
||||||
|
headerBlock.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
request.recycle();
|
||||||
|
response.recycle();
|
||||||
|
id = 0;
|
||||||
|
owner = null;
|
||||||
|
state = Http2StreamState.CLOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Request assembleRequest(InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||||
|
validateHeaders();
|
||||||
|
headers.reset(headerBlock, pseudoHeaders);
|
||||||
|
PooledSlice rawPath = pseudoHeaders.path();
|
||||||
|
if (rawPath.array() == null) rawPath = pseudoHeaders.authority();
|
||||||
|
int question = -1;
|
||||||
|
for (int i = 0; i < rawPath.length(); i++) {
|
||||||
|
if (rawPath.byteAt(i) == '?') {
|
||||||
|
question = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (question < 0) {
|
||||||
|
path.reset(rawPath.array(), rawPath.offset(), rawPath.length());
|
||||||
|
query.reset(null, 0, 0);
|
||||||
|
} else {
|
||||||
|
path.reset(rawPath.array(), rawPath.offset(), question);
|
||||||
|
query.reset(
|
||||||
|
rawPath.array(), rawPath.offset() + question + 1, rawPath.length() - question - 1);
|
||||||
|
}
|
||||||
|
PooledSlice methodBytes = pseudoHeaders.method();
|
||||||
|
HttpMethod method =
|
||||||
|
HttpMethod.fromBytes(methodBytes.array(), methodBytes.offset(), methodBytes.length());
|
||||||
|
if (method == null) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
id, Http2ErrorCode.PROTOCOL_ERROR, "unsupported request method");
|
||||||
|
}
|
||||||
|
requestLine.reset(method, path, question < 0 ? null : query, protocol, headers);
|
||||||
|
requestBody.reset(null, 0, null, 0, 0);
|
||||||
|
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateHeaders() {
|
||||||
|
if (headersValidated) return;
|
||||||
|
pseudoHeaders.validate(headerBlock, id);
|
||||||
|
headersValidated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Response resetResponse() {
|
||||||
|
return response.reset(200, ContentType.TEXT_PLAIN);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void transition(Http2StreamState.Event event) {
|
||||||
|
state = state.transition(id, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int id() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2StreamState state() {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HpackHeaderBlock headerBlock() {
|
||||||
|
return headerBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2ResponseWriter responseWriter() {
|
||||||
|
return responseWriter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object routeScratch(AbstractRouter router) {
|
||||||
|
if (routeScratch == null) routeScratch = router.newScratch();
|
||||||
|
return routeScratch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markDispatched() {
|
||||||
|
dispatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean dispatched() {
|
||||||
|
return dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean cancelled() {
|
||||||
|
return cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int sendWindow() {
|
||||||
|
return sendWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void adjustSendWindow(int delta) {
|
||||||
|
long adjusted = (long) sendWindow + delta;
|
||||||
|
if (adjusted > Integer.MAX_VALUE) throw new IllegalStateException("stream window overflow");
|
||||||
|
sendWindow = (int) adjusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void responseWriteCompleted() {
|
||||||
|
Http2StreamTable table = owner;
|
||||||
|
if (table != null) table.release(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
|
||||||
|
/** Explicit RFC 9113 stream-state transition table. */
|
||||||
|
public enum Http2StreamState {
|
||||||
|
IDLE,
|
||||||
|
OPEN,
|
||||||
|
HALF_CLOSED_REMOTE,
|
||||||
|
HALF_CLOSED_LOCAL,
|
||||||
|
CLOSED;
|
||||||
|
|
||||||
|
public enum Event {
|
||||||
|
RECV_HEADERS,
|
||||||
|
RECV_HEADERS_ES,
|
||||||
|
RECV_DATA,
|
||||||
|
RECV_DATA_ES,
|
||||||
|
RECV_RST,
|
||||||
|
SEND_HEADERS,
|
||||||
|
SEND_HEADERS_ES,
|
||||||
|
SEND_DATA,
|
||||||
|
SEND_DATA_ES,
|
||||||
|
SEND_RST
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final byte ERROR = -1;
|
||||||
|
private static final byte[][] TRANSITIONS = buildTransitions();
|
||||||
|
|
||||||
|
public Http2StreamState transition(int streamId, Event event) {
|
||||||
|
int next = TRANSITIONS[ordinal()][event.ordinal()];
|
||||||
|
if (next == ERROR) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId, errorFor(event), "invalid stream transition " + this + " + " + event);
|
||||||
|
}
|
||||||
|
return values()[next];
|
||||||
|
}
|
||||||
|
|
||||||
|
private Http2ErrorCode errorFor(Event event) {
|
||||||
|
if (this == CLOSED) return Http2ErrorCode.STREAM_CLOSED;
|
||||||
|
if (this == HALF_CLOSED_REMOTE
|
||||||
|
&& (event == Event.RECV_DATA
|
||||||
|
|| event == Event.RECV_DATA_ES
|
||||||
|
|| event == Event.RECV_HEADERS
|
||||||
|
|| event == Event.RECV_HEADERS_ES)) {
|
||||||
|
return Http2ErrorCode.STREAM_CLOSED;
|
||||||
|
}
|
||||||
|
return Http2ErrorCode.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isValid(Http2StreamState state, Event event) {
|
||||||
|
return TRANSITIONS[state.ordinal()][event.ordinal()] != ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[][] buildTransitions() {
|
||||||
|
byte[][] table = new byte[values().length][Event.values().length];
|
||||||
|
for (byte[] row : table) java.util.Arrays.fill(row, ERROR);
|
||||||
|
|
||||||
|
set(table, IDLE, Event.RECV_HEADERS, OPEN);
|
||||||
|
set(table, IDLE, Event.RECV_HEADERS_ES, HALF_CLOSED_REMOTE);
|
||||||
|
|
||||||
|
set(table, OPEN, Event.RECV_HEADERS, OPEN);
|
||||||
|
set(table, OPEN, Event.RECV_HEADERS_ES, HALF_CLOSED_REMOTE);
|
||||||
|
set(table, OPEN, Event.RECV_DATA, OPEN);
|
||||||
|
set(table, OPEN, Event.RECV_DATA_ES, HALF_CLOSED_REMOTE);
|
||||||
|
set(table, OPEN, Event.RECV_RST, CLOSED);
|
||||||
|
set(table, OPEN, Event.SEND_HEADERS, OPEN);
|
||||||
|
set(table, OPEN, Event.SEND_HEADERS_ES, HALF_CLOSED_LOCAL);
|
||||||
|
set(table, OPEN, Event.SEND_DATA, OPEN);
|
||||||
|
set(table, OPEN, Event.SEND_DATA_ES, HALF_CLOSED_LOCAL);
|
||||||
|
set(table, OPEN, Event.SEND_RST, CLOSED);
|
||||||
|
|
||||||
|
set(table, HALF_CLOSED_REMOTE, Event.RECV_RST, CLOSED);
|
||||||
|
set(table, HALF_CLOSED_REMOTE, Event.SEND_HEADERS, HALF_CLOSED_REMOTE);
|
||||||
|
set(table, HALF_CLOSED_REMOTE, Event.SEND_HEADERS_ES, CLOSED);
|
||||||
|
set(table, HALF_CLOSED_REMOTE, Event.SEND_DATA, HALF_CLOSED_REMOTE);
|
||||||
|
set(table, HALF_CLOSED_REMOTE, Event.SEND_DATA_ES, CLOSED);
|
||||||
|
set(table, HALF_CLOSED_REMOTE, Event.SEND_RST, CLOSED);
|
||||||
|
|
||||||
|
set(table, HALF_CLOSED_LOCAL, Event.RECV_HEADERS, HALF_CLOSED_LOCAL);
|
||||||
|
set(table, HALF_CLOSED_LOCAL, Event.RECV_HEADERS_ES, CLOSED);
|
||||||
|
set(table, HALF_CLOSED_LOCAL, Event.RECV_DATA, HALF_CLOSED_LOCAL);
|
||||||
|
set(table, HALF_CLOSED_LOCAL, Event.RECV_DATA_ES, CLOSED);
|
||||||
|
set(table, HALF_CLOSED_LOCAL, Event.RECV_RST, CLOSED);
|
||||||
|
set(table, HALF_CLOSED_LOCAL, Event.SEND_RST, CLOSED);
|
||||||
|
|
||||||
|
set(table, CLOSED, Event.RECV_RST, CLOSED);
|
||||||
|
set(table, CLOSED, Event.SEND_RST, CLOSED);
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void set(byte[][] table, Http2StreamState from, Event event, Http2StreamState to) {
|
||||||
|
table[from.ordinal()][event.ordinal()] = (byte) to.ordinal();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/** Fixed-capacity primitive stream-id table using linear-probed open addressing. */
|
||||||
|
public final class Http2StreamTable {
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface StreamConsumer {
|
||||||
|
void accept(Http2Stream stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final int[] keys;
|
||||||
|
private final Http2Stream[] values;
|
||||||
|
private final int mask;
|
||||||
|
private final int maxEntries;
|
||||||
|
private int size;
|
||||||
|
private Http2Stream free;
|
||||||
|
private int created;
|
||||||
|
|
||||||
|
public Http2StreamTable(int maxEntries) {
|
||||||
|
if (maxEntries < 1) throw new IllegalArgumentException("maxEntries must be positive");
|
||||||
|
int capacity = 1;
|
||||||
|
while (capacity < maxEntries * 2) capacity <<= 1;
|
||||||
|
keys = new int[capacity];
|
||||||
|
values = new Http2Stream[capacity];
|
||||||
|
mask = capacity - 1;
|
||||||
|
this.maxEntries = maxEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Http2Stream get(int streamId) {
|
||||||
|
int slot = find(streamId);
|
||||||
|
return keys[slot] == streamId ? values[slot] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void put(Http2Stream stream) {
|
||||||
|
if (size == maxEntries) throw new IllegalStateException("stream table capacity exceeded");
|
||||||
|
int streamId = stream.id();
|
||||||
|
int slot = find(streamId);
|
||||||
|
if (keys[slot] == streamId) throw new IllegalStateException("duplicate stream " + streamId);
|
||||||
|
keys[slot] = streamId;
|
||||||
|
values[slot] = stream;
|
||||||
|
size++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Http2Stream acquire(int streamId) {
|
||||||
|
if (size == maxEntries) return null;
|
||||||
|
Http2Stream stream = free;
|
||||||
|
if (stream != null) {
|
||||||
|
free = stream.poolNext;
|
||||||
|
stream.poolNext = null;
|
||||||
|
} else {
|
||||||
|
if (created == maxEntries) return null;
|
||||||
|
stream = new Http2Stream();
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
stream.reset(streamId, this);
|
||||||
|
put(stream);
|
||||||
|
return stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void release(Http2Stream stream) {
|
||||||
|
stream.clear();
|
||||||
|
stream.poolNext = free;
|
||||||
|
free = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized Http2Stream remove(int streamId) {
|
||||||
|
int slot = find(streamId);
|
||||||
|
if (keys[slot] != streamId) return null;
|
||||||
|
Http2Stream removed = values[slot];
|
||||||
|
keys[slot] = 0;
|
||||||
|
values[slot] = null;
|
||||||
|
size--;
|
||||||
|
|
||||||
|
int scan = (slot + 1) & mask;
|
||||||
|
while (keys[scan] != 0) {
|
||||||
|
int key = keys[scan];
|
||||||
|
Http2Stream value = values[scan];
|
||||||
|
keys[scan] = 0;
|
||||||
|
values[scan] = null;
|
||||||
|
size--;
|
||||||
|
put(value);
|
||||||
|
scan = (scan + 1) & mask;
|
||||||
|
}
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void forEach(StreamConsumer consumer) {
|
||||||
|
for (int i = 0; i < keys.length; i++) {
|
||||||
|
if (keys[i] != 0) consumer.accept(values[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void adjustAllSendWindows(int delta) {
|
||||||
|
for (int i = 0; i < keys.length; i++) {
|
||||||
|
if (keys[i] == 0) continue;
|
||||||
|
long adjusted = (long) values[i].sendWindow() + delta;
|
||||||
|
if (adjusted > Integer.MAX_VALUE) {
|
||||||
|
throw new IllegalStateException("stream window overflow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (int i = 0; i < keys.length; i++) {
|
||||||
|
if (keys[i] != 0) values[i].adjustSendWindow(delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int size() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int capacity() {
|
||||||
|
return maxEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int createdCount() {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int freeCount() {
|
||||||
|
int count = 0;
|
||||||
|
for (Http2Stream stream = free; stream != null; stream = stream.poolNext) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void clear() {
|
||||||
|
Arrays.fill(keys, 0);
|
||||||
|
Arrays.fill(values, null);
|
||||||
|
size = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int find(int streamId) {
|
||||||
|
if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive");
|
||||||
|
int slot = mix(streamId) & mask;
|
||||||
|
while (keys[slot] != 0 && keys[slot] != streamId) slot = (slot + 1) & mask;
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int mix(int value) {
|
||||||
|
value ^= value >>> 16;
|
||||||
|
value *= 0x7feb352d;
|
||||||
|
value ^= value >>> 15;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,46 +3,43 @@ package dev.relism.flash.transport;
|
|||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
|
||||||
import javax.net.ssl.SSLSocket;
|
|
||||||
|
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everything a {@link ConnectionProtocol} implementation needs to serve one connection, bundled
|
* Everything a {@link ConnectionProtocol} implementation needs to serve one connection, bundled
|
||||||
* into a single object instead of a long parameter list.
|
* into a single object instead of a long parameter list.
|
||||||
*
|
*
|
||||||
* @param socket the accepted socket — owns its lifecycle (closing it is the caller's,
|
* @param socket the accepted socket — owns its lifecycle (closing it is the caller's, i.e. {@link
|
||||||
* i.e. {@link ConnectionRunner}'s, responsibility, not the protocol's)
|
* ConnectionRunner}'s, responsibility, not the protocol's)
|
||||||
* @param sslSocket {@code socket} narrowed to {@link SSLSocket}, or {@code null} for a
|
* @param sslSocket {@code socket} narrowed to {@link SSLSocket}, or {@code null} for a plaintext
|
||||||
* plaintext connection
|
* connection
|
||||||
* @param in the single buffered, deadline-aware source for this connection's
|
* @param in the single buffered, deadline-aware source for this connection's inbound bytes
|
||||||
* inbound bytes
|
* @param out the buffered output stream — for header/body writes that benefit from userspace
|
||||||
* @param out the buffered output stream — for header/body writes that benefit from
|
* coalescing before a single syscall
|
||||||
* userspace coalescing before a single syscall
|
* @param rawOut the unbuffered output stream — for WebSocket, whose writes are already bulk (see
|
||||||
* @param rawOut the unbuffered output stream — for WebSocket, whose writes are already
|
* {@code WebSocketSession})
|
||||||
* bulk (see {@code WebSocketSession})
|
|
||||||
* @param remoteAddress the client's address, or {@code null} if unavailable
|
* @param remoteAddress the client's address, or {@code null} if unavailable
|
||||||
* @param router the HTTP router
|
* @param router the HTTP router
|
||||||
* @param wsRouter the WebSocket router
|
* @param wsRouter the WebSocket router
|
||||||
* @param configuration the server configuration (timeouts, limits, feature flags)
|
* @param configuration the server configuration (timeouts, limits, feature flags)
|
||||||
* @param stopped {@code true} once the server has begun shutting down — a protocol
|
* @param stopped {@code true} once the server has begun shutting down — a protocol implementation's
|
||||||
* implementation's request loop must check this and exit promptly
|
* request loop must check this and exit promptly
|
||||||
*/
|
*/
|
||||||
public record ConnectionContext(
|
public record ConnectionContext(
|
||||||
Socket socket,
|
Socket socket,
|
||||||
SSLSocket sslSocket,
|
SSLSocket sslSocket,
|
||||||
BufferedByteSource in,
|
BufferedByteSource in,
|
||||||
OutputStream out,
|
OutputStream out,
|
||||||
OutputStream rawOut,
|
OutputStream rawOut,
|
||||||
InetSocketAddress remoteAddress,
|
InetSocketAddress remoteAddress,
|
||||||
ConnectionScratch scratch,
|
ConnectionScratch scratch,
|
||||||
AbstractRouter router,
|
AbstractRouter router,
|
||||||
AbstractWsRouter wsRouter,
|
AbstractWsRouter wsRouter,
|
||||||
FlashConfiguration configuration,
|
FlashConfiguration configuration,
|
||||||
BooleanSupplier stopped
|
ExecutorService executor,
|
||||||
) {
|
BooleanSupplier stopped) {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ public final class ConnectionRunner {
|
|||||||
router,
|
router,
|
||||||
wsRouter,
|
wsRouter,
|
||||||
configuration,
|
configuration,
|
||||||
|
executorService,
|
||||||
stopped);
|
stopped);
|
||||||
if (negotiated == NegotiatedProtocol.HTTP_2) http2ProtocolFactory.get().run(ctx);
|
if (negotiated == NegotiatedProtocol.HTTP_2) http2ProtocolFactory.get().run(ctx);
|
||||||
else http1Protocol.run(ctx);
|
else http1Protocol.run(ctx);
|
||||||
|
|||||||
@@ -2,22 +2,32 @@ package dev.relism.flash.http2;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
import dev.relism.flash.extension.FlashApp;
|
import dev.relism.flash.extension.FlashApp;
|
||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.http2.frame.FrameFlags;
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
import dev.relism.flash.http2.frame.FrameType;
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
import dev.relism.flash.tls.TestKeystores;
|
import dev.relism.flash.tls.TestKeystores;
|
||||||
import dev.relism.flash.tls.TlsConfig;
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
import java.io.EOFException;
|
import java.io.EOFException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
import javax.net.ssl.SSLParameters;
|
import javax.net.ssl.SSLParameters;
|
||||||
import javax.net.ssl.SSLSocket;
|
import javax.net.ssl.SSLSocket;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
@@ -32,6 +42,171 @@ class Http2ConnectionIntegrationTest {
|
|||||||
if (app != null) app.stop().join();
|
if (app != null) app.stop().join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void javaHttpClientUsesHttp2AgainstAnExistingParameterizedRoute(@TempDir Path directory)
|
||||||
|
throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
Path keystore =
|
||||||
|
TestKeystores.build(
|
||||||
|
directory,
|
||||||
|
"http2-route.p12",
|
||||||
|
"changeit",
|
||||||
|
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.port(port)
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||||
|
.http2Enabled(true)
|
||||||
|
.build());
|
||||||
|
app.get(
|
||||||
|
"/users/{id}", (request, response) -> request.param("id") + ":" + request.header("host"));
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
HttpClient client =
|
||||||
|
HttpClient.newBuilder()
|
||||||
|
.sslContext(TestKeystores.trustAllClientContext())
|
||||||
|
.version(HttpClient.Version.HTTP_2)
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response =
|
||||||
|
client.send(
|
||||||
|
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/users/42"))
|
||||||
|
.GET()
|
||||||
|
.build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
assertEquals(HttpClient.Version.HTTP_2, response.version());
|
||||||
|
assertEquals(200, response.statusCode());
|
||||||
|
assertEquals("42:localhost:" + port, response.body());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||||
|
app.get("/api/ping", (request, response) -> "pong");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
ByteWriter block = new ByteWriter(64);
|
||||||
|
HpackEncoder.writeIndexed(block, 2);
|
||||||
|
HpackEncoder.writeIndexed(block, 6);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 4, "/api/ping".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||||
|
Http2TestFrames.frame(
|
||||||
|
FrameType.HEADERS,
|
||||||
|
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||||
|
1,
|
||||||
|
Arrays.copyOf(block.array(), block.length()))));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
|
||||||
|
ByteWriter responseBlock = new ByteWriter(128);
|
||||||
|
byte[] body = null;
|
||||||
|
for (int i = 0; i < 10 && body == null; i++) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||||
|
if (frame.streamId() != 1) continue;
|
||||||
|
if (frame.type() == FrameType.HEADERS.code()
|
||||||
|
|| frame.type() == FrameType.CONTINUATION.code()) {
|
||||||
|
responseBlock.writeBytes(frame.payload());
|
||||||
|
} else if (frame.type() == FrameType.DATA.code()) {
|
||||||
|
body = frame.payload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> fields = new ArrayList<>();
|
||||||
|
new HpackDecoder()
|
||||||
|
.decode(
|
||||||
|
responseBlock.array(),
|
||||||
|
0,
|
||||||
|
responseBlock.length(),
|
||||||
|
(name, value, never) -> fields.add(ascii(name) + "=" + ascii(value)));
|
||||||
|
assertTrue(fields.contains(":status=200"));
|
||||||
|
assertTrue(fields.contains("content-length=4"));
|
||||||
|
assertEquals("pong", new String(body, StandardCharsets.US_ASCII));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetQueuedStreamCannotReleaseOrReuseItBeforeDispatchObservesCancellation()
|
||||||
|
throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
AtomicInteger calls = new AtomicInteger();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||||
|
app.get(
|
||||||
|
"/queued",
|
||||||
|
(request, response) -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return "ok";
|
||||||
|
});
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
ByteWriter block = new ByteWriter(64);
|
||||||
|
HpackEncoder.writeIndexed(block, 2);
|
||||||
|
HpackEncoder.writeIndexed(block, 6);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 4, "/queued".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
byte[] headers = Arrays.copyOf(block.array(), block.length());
|
||||||
|
byte[] cancel = {0, 0, 0, 8};
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||||
|
Http2TestFrames.frame(
|
||||||
|
FrameType.HEADERS,
|
||||||
|
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||||
|
1,
|
||||||
|
headers),
|
||||||
|
Http2TestFrames.frame(FrameType.RST_STREAM, 0, 1, cancel),
|
||||||
|
Http2TestFrames.frame(
|
||||||
|
FrameType.HEADERS,
|
||||||
|
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||||
|
3,
|
||||||
|
headers)));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame response = null;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||||
|
assertFalse(
|
||||||
|
frame.streamId() == 1
|
||||||
|
&& (frame.type() == FrameType.HEADERS.code()
|
||||||
|
|| frame.type() == FrameType.DATA.code()),
|
||||||
|
"a reset request must not produce a response");
|
||||||
|
if (frame.streamId() == 3 && frame.type() == FrameType.DATA.code()) {
|
||||||
|
response = frame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNotNull(response);
|
||||||
|
assertEquals("ok", new String(response.payload(), StandardCharsets.US_ASCII));
|
||||||
|
assertEquals(1, calls.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
|
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
|
||||||
int port = freePort();
|
int port = freePort();
|
||||||
@@ -244,4 +419,10 @@ class Http2ConnectionIntegrationTest {
|
|||||||
return socket.getLocalPort();
|
return socket.getLocalPort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String ascii(dev.relism.fpr.core.ByteView view) {
|
||||||
|
byte[] bytes = new byte[view.length()];
|
||||||
|
for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i);
|
||||||
|
return new String(bytes, StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.relism.flash.http2.message;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class PseudoHeaderValidationTest {
|
||||||
|
@Test
|
||||||
|
void validRequest() {
|
||||||
|
assertDoesNotThrow(
|
||||||
|
() ->
|
||||||
|
validate(
|
||||||
|
":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsPseudoAfterRegular() {
|
||||||
|
rejects("x", "1", ":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUnknownAndDuplicatePseudoHeaders() {
|
||||||
|
rejects(":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x", ":other", "x");
|
||||||
|
rejects(
|
||||||
|
":method", "GET", ":method", "POST", ":scheme", "https", ":path", "/", ":authority", "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsMissingAndEmptyPseudoHeaders() {
|
||||||
|
rejects(":method", "GET", ":scheme", "https", ":path", "/");
|
||||||
|
rejects(":method", "GET", ":scheme", "https", ":path", "", ":authority", "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesConnectShape() {
|
||||||
|
assertDoesNotThrow(() -> validate(":method", "CONNECT", ":authority", "example.com:443"));
|
||||||
|
rejects(":method", "CONNECT", ":scheme", "https", ":authority", "example.com:443");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsUppercaseForbiddenAndInvalidTeFields() {
|
||||||
|
rejects(validWith("X-Test", "1"));
|
||||||
|
rejects(validWith("connection", "close"));
|
||||||
|
rejects(validWith("keep-alive", "timeout=5"));
|
||||||
|
rejects(validWith("proxy-connection", "close"));
|
||||||
|
rejects(validWith("transfer-encoding", "chunked"));
|
||||||
|
rejects(validWith("upgrade", "websocket"));
|
||||||
|
rejects(validWith("te", "gzip"));
|
||||||
|
assertDoesNotThrow(() -> validate(validWith("te", "trailers")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsHostAuthorityConflict() {
|
||||||
|
rejects(validWith("host", "other.example"));
|
||||||
|
assertDoesNotThrow(() -> validate(validWith("host", "example.com")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] validWith(String name, String value) {
|
||||||
|
return new String[] {
|
||||||
|
":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com", name, value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void rejects(String... fields) {
|
||||||
|
assertThrows(Http2StreamException.class, () -> validate(fields));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validate(String... fields) {
|
||||||
|
HpackHeaderBlock block = new HpackHeaderBlock();
|
||||||
|
PooledSlice name = new PooledSlice();
|
||||||
|
PooledSlice value = new PooledSlice();
|
||||||
|
for (int i = 0; i < fields.length; i += 2) {
|
||||||
|
byte[] nameBytes = fields[i].getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] valueBytes = fields[i + 1].getBytes(StandardCharsets.US_ASCII);
|
||||||
|
name.reset(nameBytes, 0, nameBytes.length);
|
||||||
|
value.reset(valueBytes, 0, valueBytes.length);
|
||||||
|
block.accept(name, value, false);
|
||||||
|
}
|
||||||
|
new PseudoHeaders().validate(block, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2RequestAssemblyTest {
|
||||||
|
@Test
|
||||||
|
void assemblesProtocolNeutralRequestWithQueryAndAuthorityAlias() {
|
||||||
|
Http2StreamTable table = new Http2StreamTable(1);
|
||||||
|
Http2Stream stream = table.acquire(1);
|
||||||
|
field(stream, ":method", "GET");
|
||||||
|
field(stream, ":scheme", "https");
|
||||||
|
field(stream, ":path", "/users/42?verbose=true");
|
||||||
|
field(stream, ":authority", "example.com");
|
||||||
|
field(stream, "x-trace", "abc");
|
||||||
|
|
||||||
|
Request request = stream.assembleRequest(null, null);
|
||||||
|
|
||||||
|
assertEquals(HttpMethod.GET, request.method());
|
||||||
|
assertEquals("/users/42", request.path());
|
||||||
|
assertEquals("true", request.query("verbose"));
|
||||||
|
assertEquals("example.com", request.header("host"));
|
||||||
|
assertEquals("example.com", request.header(":authority"));
|
||||||
|
assertEquals("abc", request.header("X-Trace"));
|
||||||
|
assertNull(request.remoteAddress());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void field(Http2Stream stream, String name, String value) {
|
||||||
|
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
PooledSlice nameView = new PooledSlice();
|
||||||
|
PooledSlice valueView = new PooledSlice();
|
||||||
|
nameView.reset(nameBytes, 0, nameBytes.length);
|
||||||
|
valueView.reset(valueBytes, 0, valueBytes.length);
|
||||||
|
stream.headerBlock().accept(nameView, valueView, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2StreamLeakTest {
|
||||||
|
@Test
|
||||||
|
void oneHundredThousandAcquireReleaseCyclesReuseOneStream() {
|
||||||
|
Http2StreamTable table = new Http2StreamTable(100);
|
||||||
|
for (int i = 0; i < 100_000; i++) {
|
||||||
|
Http2Stream stream = table.acquire((i << 1) | 1);
|
||||||
|
table.remove(stream.id());
|
||||||
|
table.release(stream);
|
||||||
|
}
|
||||||
|
assertEquals(1, table.createdCount());
|
||||||
|
assertEquals(1, table.freeCount());
|
||||||
|
assertEquals(0, table.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2StreamStateTest {
|
||||||
|
@Test
|
||||||
|
void everyTransitionCellIsExecutableOrTypedError() {
|
||||||
|
for (Http2StreamState state : Http2StreamState.values()) {
|
||||||
|
for (Http2StreamState.Event event : Http2StreamState.Event.values()) {
|
||||||
|
if (Http2StreamState.isValid(state, event)) {
|
||||||
|
Http2StreamState next = state.transition(1, event);
|
||||||
|
assertEquals(true, next != null);
|
||||||
|
} else {
|
||||||
|
assertThrows(Http2StreamException.class, () -> state.transition(1, event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bodylessRequestAndResponseCloseStream() {
|
||||||
|
Http2StreamState state =
|
||||||
|
Http2StreamState.IDLE.transition(1, Http2StreamState.Event.RECV_HEADERS_ES);
|
||||||
|
assertEquals(Http2StreamState.HALF_CLOSED_REMOTE, state);
|
||||||
|
assertEquals(
|
||||||
|
Http2StreamState.CLOSED, state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2StreamTableTest {
|
||||||
|
@Test
|
||||||
|
void insertLookupRemoveAtCapacityAndAcrossProbeClusters() {
|
||||||
|
Http2StreamTable table = new Http2StreamTable(8);
|
||||||
|
Http2Stream[] streams = new Http2Stream[8];
|
||||||
|
for (int i = 0; i < streams.length; i++) {
|
||||||
|
streams[i] = table.acquire(i * 2 + 1);
|
||||||
|
assertSame(streams[i], table.get(i * 2 + 1));
|
||||||
|
}
|
||||||
|
assertNull(table.acquire(99));
|
||||||
|
for (int i = 0; i < streams.length; i += 2) {
|
||||||
|
assertSame(streams[i], table.remove(streams[i].id()));
|
||||||
|
table.release(streams[i]);
|
||||||
|
}
|
||||||
|
for (int i = 1; i < streams.length; i += 2) {
|
||||||
|
assertSame(streams[i], table.get(streams[i].id()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user