feat(core): add HTTP trailers and push streaming

This commit is contained in:
Zakaria El Orche
2026-08-13 19:23:26 +00:00
parent 8d5340a0b4
commit ee90ac44ff
34 changed files with 1468 additions and 87 deletions
+29 -1
View File
@@ -172,7 +172,7 @@ app.onException((ex, req, res) -> {
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. | | `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | | `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | | `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `http2Enabled` | `false` | Whether this server will ever negotiate HTTP/2. Off by default until the HTTP/2 connection state machine lands (see `flash/docs/http2/IMPLEMENTATION-PLAN.md`). | | `http2Enabled` | `false` | Whether the server negotiates HTTP/2 through ALPN or accepts h2c prior knowledge. The conservative default keeps protocol rollout explicit. |
## TLS ## TLS
@@ -316,6 +316,34 @@ app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
application and middleware code. application and middleware code.
### Trailers and push streaming
Request trailers become available after the body reaches EOF:
```java
byte[] payload = req.body().bytes();
String status = req.trailers().first("grpc-status");
```
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
virtual thread:
```java
return res.streaming(stream -> {
try {
stream.write(payload, 0, payload.length);
stream.trailer("result", "complete");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
separate extension.
## Architecture ## Architecture
``` ```
+19
View File
@@ -981,3 +981,22 @@ the allocation noise floor.
window and pool byte capacity together; never raise credit independently of bounded storage. window and pool byte capacity together; never raise credit independently of bounded storage.
--- ---
## DEC-29 — Keep HTTP/2 opt-in until the adversarial phase is complete
**Context.** Trailers and push streaming make the protocol feature-complete for ordinary and gRPC-
shaped traffic, but the dedicated rate-based and composite abuse controls are deliberately owned
by the following security phase.
**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false` during this phase.
Applications can enable the complete path explicitly; the default changes only after the hostile-
peer suite and its limits are green.
**Consequence.** Existing deployments do not silently expose a newly completed protocol before its
adversarial gate. This is rollout sequencing, not an architectural separation: both protocols use
the same public request/response, header, trailer and streaming APIs.
**Revisit when.** At Phase 13 closure; either flip the default with evidence or record why it must
remain opt-in.
---
+27 -10
View File
@@ -73,7 +73,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 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 | done | `feature/core/http2` | Explicit stream transition table, bounded primitive stream table and pool, pseudo-header/message validation, protocol-neutral `Request` assembly, virtual-thread dispatch and exception path, cancellation-safe release, raw h2c + Java HTTP/2 integration. Phase 11 closed the two deferred content-length/DATA cases; h2spec sections 5/8 are now 39/39. JMH pooled lifecycle: 458.499 ns/op, 0.003 B/op, no GC. 618/618 tests green at phase closure. | | 10 — Stream state machine + dispatch | done | `feature/core/http2` | Explicit stream transition table, bounded primitive stream table and pool, pseudo-header/message validation, protocol-neutral `Request` assembly, virtual-thread dispatch and exception path, cancellation-safe release, raw h2c + Java HTTP/2 integration. Phase 11 closed the two deferred content-length/DATA cases; h2spec sections 5/8 are now 39/39. JMH pooled lifecycle: 458.499 ns/op, 0.003 B/op, no GC. 618/618 tests green at phase closure. |
| 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. | | 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. |
| 12 — Trailers, half-close, gRPC | not started | — | — | | 12 — Trailers, half-close, gRPC | done | `feature/core/http2` | Protocol-neutral request/response trailers, bounded push streaming, four half-close orderings and authority-form CONNECT tunnels complete. Real grpcurl 1.9.3 unary/server-streaming/error interop passes. EX-48/49 fixed. HTTP/2 remains opt-in until the Phase 13 hostile-peer gate (DEC-29). 649/649 tests green from a clean `-Pjmh` build. |
| 13 — Security hardening & abuse resistance | not started | — | — | | 13 — Security hardening & abuse resistance | not started | — | — |
| 14 — h2c prior knowledge + proxy support | not started | — | — | | 14 — h2c prior knowledge + proxy support | not started | — | — |
| 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — | | 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — |
@@ -778,6 +778,22 @@ the final release. The regression test sends a complete request, immediately res
a second request and proves that only the second handler invocation and response occur. **Phase**: a second request and proves that only the second handler invocation and response occur. **Phase**:
10. 10.
### EX-48 — HTTP/1.1 request trailers were parsed and discarded
Found while exposing the protocol-neutral request trailer API. `ChunkedInputStream` consumed and
bounded the final trailer section but discarded every field, so no honest API could provide the
same semantics on HTTP/1.1 and HTTP/2. **Fix**: parse the bounded section into a connection-owned
`MutableHeaderMap`, expose it through `Request.trailers()` only after body EOF, reject malformed and
framing-sensitive fields, and add HTTP/1 parity/regression tests. **Phase**: 12.
### EX-49 — CONNECT routes were registered as origin-form paths
Found while exercising an HTTP/2 tunnel. The public `connect("authority", handler)` API passed
through the ordinary path sanitizer, which prepended `/`; both HTTP/1.1 authority-form request
targets and HTTP/2 `:authority` arrive without that prefix, so the existing CONNECT API could
never match its documented target. **Fix**: normalize CONNECT authority targets separately in the
shared router registration path and verify a live bidirectional HTTP/2 tunnel. **Phase**: 12.
--- ---
# PART III — The phases # PART III — The phases
@@ -2730,11 +2746,11 @@ error.
boundary in `DECISIONS.md` as `DEC-08`. boundary in `DECISIONS.md` as `DEC-08`.
### Safety checks ### Safety checks
- [ ] Trailers without `END_STREAM` rejected - [x] Trailers without `END_STREAM` rejected
- [ ] Pseudo-headers in trailers rejected - [x] Pseudo-headers in trailers rejected
- [ ] Trailer count and size bounded (they go through the same HPACK limits) - [x] Trailer count and size bounded (they go through the same HPACK limits)
- [ ] `ResponseStream.write` after `close` throws, does not corrupt the stream - [x] `ResponseStream.write` after `close` throws, does not corrupt the stream
- [ ] CONNECT tunnels are bounded by the same timeouts and flow control as normal streams - [x] CONNECT tunnels are bounded by the same timeouts and flow control as normal streams
### Tests ### Tests
- `Http2TrailersTest`, `Http1TrailersTest` (the h1 rendering), `TrailerParityTest`. - `Http2TrailersTest`, `Http1TrailersTest` (the h1 rendering), `TrailerParityTest`.
@@ -2748,11 +2764,12 @@ error.
- `README.md` — the `ResponseStream` API, with a gRPC-shaped example. - `README.md` — the `ResponseStream` API, with a gRPC-shaped example.
### DoD ### DoD
- [ ] `grpcurl` completes a unary and a server-streaming call against a Flash handler. - [x] `grpcurl` completes a unary and a server-streaming call against a Flash handler.
- [ ] Trailers work on both protocols through one API. - [x] Trailers work on both protocols through one API.
- [ ] `FlashConfiguration.http2Enabled` flips to default `true` (the feature is now complete - [x] `FlashConfiguration.http2Enabled` flips to default `true` (the feature is now complete
enough to be on by default) — or, if the team prefers a conservative rollout, stays enough to be on by default) — or, if the team prefers a conservative rollout, stays
`false` with the decision recorded. `false` with the decision recorded (`DEC-29`: retain opt-in until Phase 13's hostile-peer
suite is complete).
--- ---
@@ -0,0 +1,36 @@
# Trailers and streaming
Flash exposes the same request and response model on HTTP/1.1 and HTTP/2. Request trailers are
available through `Request.trailers()` after the body has reached EOF. Calling it earlier throws
`IllegalStateException`; this prevents handlers from observing an incomplete trailer section.
HTTP/1.1 reads trailers from the final chunk, while HTTP/2 decodes the trailing HEADERS block in
the connection's existing HPACK context.
Response trailers are added with `Response.trailer(name, value)` or a `PreEncodedHeader`. HTTP/1.1
uses chunked framing and writes the fields after the zero chunk. HTTP/2 writes a trailing HEADERS
block with `END_STREAM`; the final DATA frame deliberately does not carry `END_STREAM`.
`Response.streaming(producer)` is the push alternative to `stream(InputStream, length)` and
`chunked(InputStream)`. Its `ResponseStream` is a bounded blocking bridge. A producer runs on a
virtual thread and blocks when the protocol writer or the HTTP/2 flow-control windows cannot make
progress. This keeps backpressure explicit without callbacks or reactive types:
```java
return response.type("application/grpc").streaming(stream -> {
try {
for (byte[] message : messages) stream.write(message, 0, message.length);
stream.trailer("grpc-status", "0");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The transport supports the primitives required by gRPC, but the core does not provide protobuf
codecs, generated stubs, service descriptors, or a gRPC service API. Those belong in a future
`flash-ext-grpc` module. `GrpcInteropTest` verifies the boundary with the external `grpcurl` client
and a hand-written wire-format handler.
CONNECT requests follow RFC 9113 request pseudo-header rules: `:authority` is required and
`:scheme`/`:path` are forbidden. Their DATA remains subject to the ordinary request limits,
timeouts and two-level flow control.
@@ -2,6 +2,8 @@ package dev.relism.flash;
import dev.relism.flash.exceptions.MalformedRequestException; import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits; import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.models.BodyCompletion;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.transport.BufferedByteSource; import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException; import java.io.IOException;
@@ -20,15 +22,28 @@ import java.io.InputStream;
* {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/ * {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/
* {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request. * {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request.
*/ */
final class ChunkedInputStream extends InputStream { final class ChunkedInputStream extends InputStream implements BodyCompletion {
private final BufferedByteSource src; private final BufferedByteSource src;
private int chunkRemaining = 0; private int chunkRemaining = 0;
private boolean done = false; private boolean done = false;
private int chunksSeen = 0; private int chunksSeen = 0;
private final MutableHeaderMap trailers;
private final byte[] trailerLine = new byte[Http1Limits.MAX_HEADER_VALUE_LENGTH];
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen,
MutableHeaderMap trailers) {
this.src = src;
this.trailers = trailers;
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen);
}
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) { ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) {
this.src = src; this(src, preBuf, preBufOff, preBufLen, new MutableHeaderMap());
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen); }
@Override
public boolean fullyRead() {
return done;
} }
@Override @Override
@@ -129,7 +144,7 @@ final class ChunkedInputStream extends InputStream {
int trailerCount = 0; int trailerCount = 0;
while (true) { while (true) {
int b = src.read(); int b = src.read();
if (b == -1) return; // EOF mid-trailers — nothing left to bound. if (b == -1) throw new MalformedRequestException(400, "Truncated trailer section");
if (b == '\r') { if (b == '\r') {
if (src.read() != '\n') { if (src.read() != '\n') {
throw new MalformedRequestException(400, "Malformed trailer section terminator"); throw new MalformedRequestException(400, "Malformed trailer section terminator");
@@ -139,12 +154,68 @@ final class ChunkedInputStream extends InputStream {
if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) { if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) {
throw new MalformedRequestException(431, "Too many trailers"); throw new MalformedRequestException(431, "Too many trailers");
} }
int lineLen = 1; int lineLen = 0;
trailerLine[lineLen++] = (byte) b;
while ((b = src.read()) != -1 && b != '\n') { while ((b = src.read()) != -1 && b != '\n') {
if (++lineLen > Http1Limits.MAX_HEADER_VALUE_LENGTH) { if (lineLen == trailerLine.length) {
throw new MalformedRequestException(431, "Trailer line too long"); throw new MalformedRequestException(431, "Trailer line too long");
} }
trailerLine[lineLen++] = (byte) b;
} }
if (b != '\n' || lineLen == 0 || trailerLine[lineLen - 1] != '\r') {
throw new MalformedRequestException(400, "Malformed trailer line");
}
addTrailer(lineLen - 1);
} }
} }
private void addTrailer(int lineLength) throws MalformedRequestException {
int colon = -1;
for (int i = 0; i < lineLength; i++) {
if (trailerLine[i] == ':') { colon = i; break; }
}
if (colon <= 0) throw new MalformedRequestException(400, "Malformed trailer field");
for (int i = 0; i < colon; i++) {
int c = trailerLine[i] & 0xff;
if (!isToken(c)) {
throw new MalformedRequestException(400, "Invalid trailer field name");
}
}
int valueStart = colon + 1;
while (valueStart < lineLength
&& (trailerLine[valueStart] == ' ' || trailerLine[valueStart] == '\t')) valueStart++;
int valueEnd = lineLength;
while (valueEnd > valueStart
&& (trailerLine[valueEnd - 1] == ' ' || trailerLine[valueEnd - 1] == '\t')) valueEnd--;
if (forbidden(trailerLine, colon)) {
throw new MalformedRequestException(400, "Forbidden trailer field");
}
trailers.add(trailerLine, 0, colon, trailerLine, valueStart, valueEnd - valueStart);
}
private static boolean isToken(int c) {
return (c >= '0' && c <= '9')
|| (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\''
|| c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_'
|| c == '`' || c == '|' || c == '~';
}
private static boolean forbidden(byte[] name, int length) {
return asciiEquals(name, length, "content-length")
|| asciiEquals(name, length, "transfer-encoding")
|| asciiEquals(name, length, "host")
|| asciiEquals(name, length, "trailer");
}
private static boolean asciiEquals(byte[] bytes, int length, String expected) {
if (length != expected.length()) return false;
for (int i = 0; i < length; i++) {
int c = bytes[i] & 0xff;
if (c >= 'A' && c <= 'Z') c += 32;
if (c != expected.charAt(i)) return false;
}
return true;
}
} }
@@ -5,6 +5,7 @@ import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits; import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Http1HeaderMap; import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestBody; import dev.relism.flash.models.RequestBody;
import dev.relism.flash.models.RequestLine; import dev.relism.flash.models.RequestLine;
@@ -57,6 +58,7 @@ public class RequestParser {
private final InetSocketAddress remoteAddress; private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket; private final SSLSocket sslSocket;
private final Http1HeaderMap headerMap = new Http1HeaderMap(); private final Http1HeaderMap headerMap = new Http1HeaderMap();
private final MutableHeaderMap trailerMap = new MutableHeaderMap();
// request — same idiom as headerMap above. // request — same idiom as headerMap above.
private final RequestLine requestLine = new RequestLine(); private final RequestLine requestLine = new RequestLine();
private final Request request = new Request(); private final Request request = new Request();
@@ -115,6 +117,7 @@ public class RequestParser {
* @throws IOException on genuine I/O failure (socket reset, timeout). * @throws IOException on genuine I/O failure (socket reset, timeout).
*/ */
public Request parse(BufferedByteSource in) throws IOException { public Request parse(BufferedByteSource in) throws IOException {
trailerMap.reset();
// Snapshot leftover bytes from the previous request, then reset immediately. // Snapshot leftover bytes from the previous request, then reset immediately.
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state. // Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
int base = bufBase; int base = bufBase;
@@ -293,11 +296,15 @@ public class RequestParser {
// is handled by the same call: preBufLen is already forced to 0 for it above) or the // is handled by the same call: preBufLen is already forced to 0 for it above) or the
// chunked case, never reallocated. // chunked case, never reallocated.
if (isChunked) { if (isChunked) {
requestBody.reset(new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0); requestBody.reset(
new ChunkedInputStream(in, buffer, bodyStart, preBufLen, trailerMap),
-1L, null, 0, 0);
} else { } else {
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen); requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
} }
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket); Request parsed = Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
parsed.setTrailers(trailerMap);
return parsed;
} }
/** /**
@@ -82,7 +82,9 @@ public final class Http1ResponseWriter {
response.writeHeadersInto(head); response.writeHeadersInto(head);
if (response.isStreaming()) { if (response.hasTrailers()) {
writeTrailerBody(out, head, response, keepAlive, suppressBody, scratch);
} else if (response.isStreaming()) {
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch); writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
} else { } else {
byte[] body = response.getBody(); byte[] body = response.getBody();
@@ -109,6 +111,28 @@ public final class Http1ResponseWriter {
out.flush(); out.flush();
} }
private static void writeTrailerBody(OutputStream out, ByteWriter head, Response response,
boolean keepAlive, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
head.writeBytes(TRANSFER_CHUNKED);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
if (suppressBody) return;
if (response.isStreaming()) {
writeChunked(out, response.getStream(), response, scratch);
} else {
byte[] body = response.getBody();
if (body != null && body.length != 0) {
writeHex(out, body.length);
out.write(CRLF);
out.write(body);
out.write(CRLF);
}
writeFinalChunk(out, response);
}
}
private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive, private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive,
boolean noContentAllowed, boolean suppressBody, boolean noContentAllowed, boolean suppressBody,
ConnectionScratch scratch) throws IOException { ConnectionScratch scratch) throws IOException {
@@ -130,7 +154,7 @@ public final class Http1ResponseWriter {
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC // A HEAD response still declares the Transfer-Encoding GET would have used (RFC
// 9110 §9.3.2) but writes zero body bytes not even the final-chunk marker, since // 9110 §9.3.2) but writes zero body bytes not even the final-chunk marker, since
// there is no chunk framing at all for a message with no body. // there is no chunk framing at all for a message with no body.
if (!suppressBody) writeChunked(out, response.getStream(), scratch); if (!suppressBody) writeChunked(out, response.getStream(), response, scratch);
} }
} }
@@ -150,7 +174,8 @@ public final class Http1ResponseWriter {
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); } else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
} }
private static void writeChunked(OutputStream out, InputStream stream, ConnectionScratch scratch) throws IOException { private static void writeChunked(OutputStream out, InputStream stream, Response response,
ConnectionScratch scratch) throws IOException {
byte[] buf = scratch.relayBuffer; byte[] buf = scratch.relayBuffer;
int n; int n;
while ((n = stream.read(buf)) > 0) { while ((n = stream.read(buf)) > 0) {
@@ -159,7 +184,15 @@ public final class Http1ResponseWriter {
out.write(buf, 0, n); out.write(buf, 0, n);
out.write(CRLF); out.write(CRLF);
} }
out.write(FINAL_CHUNK); if (response.hasTrailers()) writeFinalChunk(out, response);
else out.write(FINAL_CHUNK);
}
private static void writeFinalChunk(OutputStream out, Response response) throws IOException {
out.write('0');
out.write(CRLF);
response.writeTrailers(out);
out.write(CRLF);
} }
private static void writeHex(OutputStream out, int value) throws IOException { private static void writeHex(OutputStream out, int value) throws IOException {
@@ -59,6 +59,7 @@ public final class Http2Connection implements ConnectionProtocol {
private int highestClientStreamId; private int highestClientStreamId;
private Http2Stream pendingHeaderStream; private Http2Stream pendingHeaderStream;
private boolean refusingHeaderStream; private boolean refusingHeaderStream;
private boolean pendingTrailers;
private Http2StreamDispatcher streamDispatcher; private Http2StreamDispatcher streamDispatcher;
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS]; private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
private int dispatchCount; private int dispatchCount;
@@ -218,8 +219,21 @@ public final class Http2Connection implements ConnectionProtocol {
private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException { private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException {
int streamId = frame.streamId(); int streamId = frame.streamId();
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR; if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
Http2Stream existing = streams.get(streamId);
if (existing != null) {
if (!FrameFlags.isEndStream(frame.flags())) {
throw new Http2StreamException(
streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM");
}
pendingHeaderStream = existing;
pendingTrailers = true;
existing.trailerBlock().reset();
if (headerBlocks.accept(frame, existing.trailerBlock())) completeHeaders(writer, streamId);
return;
}
if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR; if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
highestClientStreamId = streamId; highestClientStreamId = streamId;
pendingTrailers = false;
pendingHeaderStream = streams.acquire(streamId); pendingHeaderStream = streams.acquire(streamId);
refusingHeaderStream = pendingHeaderStream == null; refusingHeaderStream = pendingHeaderStream == null;
@@ -227,7 +241,12 @@ public final class Http2Connection implements ConnectionProtocol {
flowController.initializeStreamSendWindow( flowController.initializeStreamSendWindow(
pendingHeaderStream, peerSettings.initialWindowSize()); pendingHeaderStream, peerSettings.initialWindowSize());
} }
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock(); HeaderSink sink =
refusingHeaderStream
? DISCARD_HEADERS
: (pendingTrailers
? pendingHeaderStream.trailerBlock()
: pendingHeaderStream.headerBlock());
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId()); if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
} }
@@ -235,33 +254,53 @@ public final class Http2Connection implements ConnectionProtocol {
if (pendingHeaderStream == null && !refusingHeaderStream) { if (pendingHeaderStream == null && !refusingHeaderStream) {
throw Http2Exception.PROTOCOL_ERROR; throw Http2Exception.PROTOCOL_ERROR;
} }
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock(); HeaderSink sink =
refusingHeaderStream
? DISCARD_HEADERS
: (pendingTrailers
? pendingHeaderStream.trailerBlock()
: pendingHeaderStream.headerBlock());
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId()); if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
} }
private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException { private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException {
if (refusingHeaderStream) { try {
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM); if (refusingHeaderStream) {
} else { sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
Http2Stream stream = pendingHeaderStream; } else {
if (streamDispatcher != null) stream.validateHeaders(); Http2Stream stream = pendingHeaderStream;
boolean dispatch = boolean dispatch;
stream.prepareRequestBody(flowController, headerBlocks.endStream()); if (pendingTrailers) {
stream.transition( stream.validateTrailers();
headerBlocks.endStream() stream.finishRequestBody();
? Http2StreamState.Event.RECV_HEADERS_ES stream.transition(Http2StreamState.Event.RECV_HEADERS_ES);
: Http2StreamState.Event.RECV_HEADERS); dispatch = !stream.dispatched();
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId); } else {
if (streamDispatcher == null) { if (streamDispatcher != null) stream.validateHeaders();
streams.remove(streamId); dispatch = stream.prepareRequestBody(flowController, headerBlocks.endStream());
streams.release(stream); stream.transition(
if (!gracefulStarted) startGracefulShutdown(writer); headerBlocks.endStream()
} else if (dispatch) { ? Http2StreamState.Event.RECV_HEADERS_ES
enqueueDispatch(stream); : Http2StreamState.Event.RECV_HEADERS);
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
}
if (streamDispatcher == null && !pendingTrailers) {
streams.remove(streamId);
streams.release(stream);
if (!gracefulStarted) startGracefulShutdown(writer);
} else if (dispatch) {
enqueueDispatch(stream);
} else if (stream.responseStarted() && stream.responseWriter().finished()
&& !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) {
streams.remove(stream.id());
streams.release(stream);
}
} }
} finally {
pendingHeaderStream = null;
refusingHeaderStream = false;
pendingTrailers = false;
} }
pendingHeaderStream = null;
refusingHeaderStream = false;
} }
private void receivePriority(FrameHeader frame) { private void receivePriority(FrameHeader frame) {
@@ -310,6 +349,12 @@ public final class Http2Connection implements ConnectionProtocol {
if (FrameFlags.isEndStream(frame.flags())) { if (FrameFlags.isEndStream(frame.flags())) {
stream.finishRequestBody(); stream.finishRequestBody();
if (streamDispatcher != null && !stream.dispatched()) enqueueDispatch(stream); if (streamDispatcher != null && !stream.dispatched()) enqueueDispatch(stream);
else if (stream.responseStarted() && stream.responseWriter().finished()
&& !stream.responseInFlight()
&& stream.state() == Http2StreamState.CLOSED) {
streams.remove(stream.id());
streams.release(stream);
}
} }
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
if (!bodyAccepted) discardConnectionBytes(frame.length()); if (!bodyAccepted) discardConnectionBytes(frame.length());
@@ -115,7 +115,8 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
else if (result != null) response.setBody(result); else if (result != null) response.setBody(result);
} }
request.drain(); boolean pushStreaming = response.isPushStreaming();
if (!pushStreaming) request.drain();
Http2ResponseWriter responseWriter = stream.responseWriter(); Http2ResponseWriter responseWriter = stream.responseWriter();
if (stream.cancelled()) { if (stream.cancelled()) {
request.recycle(); request.recycle();
@@ -148,8 +149,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
} }
firstResponse = false; firstResponse = false;
} }
request.recycle(); if (!pushStreaming) request.recycle();
if (response == pooled) pooled.recycle();
stream.markResponseStarted(); stream.markResponseStarted();
applyBatchTransition(stream, responseWriter); applyBatchTransition(stream, responseWriter);
if (!stream.beginResponseBatch()) { if (!stream.beginResponseBatch()) {
@@ -170,8 +170,10 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
Http2ResponseWriter responseWriter = stream.responseWriter(); Http2ResponseWriter responseWriter = stream.responseWriter();
if (responseWriter.finished()) { if (responseWriter.finished()) {
stream.endResponseBatch(); stream.endResponseBatch();
streams.remove(stream.id()); if (stream.state() == Http2StreamState.CLOSED) {
streams.release(stream); streams.remove(stream.id());
streams.release(stream);
}
return; return;
} }
int reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize()); int reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize());
@@ -202,17 +204,25 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
private static void applyBatchTransition( private static void applyBatchTransition(
Http2Stream stream, Http2ResponseWriter responseWriter) { Http2Stream stream, Http2ResponseWriter responseWriter) {
if (responseWriter.headersInBatch()) { if (responseWriter.headersInBatch()) {
if (responseWriter.finished() && responseWriter.dataBytesInBatch() == 0) { if (responseWriter.finished() && responseWriter.dataBytesInBatch() == 0
&& !responseWriter.trailerHeadersInBatch()) {
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES); stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
return; return;
} }
stream.transition(Http2StreamState.Event.SEND_HEADERS); stream.transition(Http2StreamState.Event.SEND_HEADERS);
} }
if (responseWriter.dataBytesInBatch() != 0 || responseWriter.endStreamInBatch()) { if (responseWriter.dataBytesInBatch() != 0 || responseWriter.endStreamInBatch()) {
stream.transition( if (responseWriter.dataBytesInBatch() != 0) {
responseWriter.endStreamInBatch() stream.transition(
? Http2StreamState.Event.SEND_DATA_ES responseWriter.endStreamInBatch() && !responseWriter.trailerHeadersInBatch()
: Http2StreamState.Event.SEND_DATA); ? Http2StreamState.Event.SEND_DATA_ES
: Http2StreamState.Event.SEND_DATA);
}
if (responseWriter.trailerHeadersInBatch()) {
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
} else if (responseWriter.dataBytesInBatch() == 0 && responseWriter.endStreamInBatch()) {
stream.transition(Http2StreamState.Event.SEND_DATA_ES);
}
} }
} }
@@ -220,12 +230,19 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
public void responseBatchCompleted(Http2Stream stream) { public void responseBatchCompleted(Http2Stream stream) {
stream.endResponseBatch(); stream.endResponseBatch();
if (stream.id() == 0) return; if (stream.id() == 0) return;
if (stream.cancelled() || stream.responseWriter().finished()) { if (stream.cancelled()) {
streams.remove(stream.id()); streams.remove(stream.id());
streams.release(stream); streams.release(stream);
} else { return;
scheduleResume(stream);
} }
if (stream.responseWriter().finished()) {
if (stream.state() == Http2StreamState.CLOSED) {
streams.remove(stream.id());
streams.release(stream);
}
return;
}
scheduleResume(stream);
} }
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) { private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
@@ -35,6 +35,10 @@ public final class Http2HeaderMap implements HeaderView {
} }
} }
public void reset(HpackHeaderBlock block) {
reset(block, null);
}
@Override @Override
public String first(String name) { public String first(String name) {
ByteView value = find(name, scanValue); ByteView value = find(name, scanValue);
@@ -54,7 +58,8 @@ public final class Http2HeaderMap implements HeaderView {
new String( new String(
scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8)); scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8));
} }
if (result == null && isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) { if (result == null && pseudoHeaders != null
&& isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) {
return List.of(string(pseudoHeaders.authority())); return List.of(string(pseudoHeaders.authority()));
} }
return result == null ? List.of() : result; return result == null ? List.of() : result;
@@ -108,7 +113,8 @@ public final class Http2HeaderMap implements HeaderView {
return target; return target;
} }
} }
if (isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) { if (pseudoHeaders != null
&& isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) {
PooledSlice authority = pseudoHeaders.authority(); PooledSlice authority = pseudoHeaders.authority();
target.reset(authority.array(), authority.offset(), authority.length()); target.reset(authority.array(), authority.offset(), authority.length());
return target; return target;
@@ -6,11 +6,12 @@ import dev.relism.flash.http2.Http2StreamException;
import dev.relism.flash.http2.message.DataBufferPool.DataBuffer; import dev.relism.flash.http2.message.DataBufferPool.DataBuffer;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import dev.relism.flash.models.BodyCompletion;
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantLock;
/** Reusable request-body source fed by the connection demultiplexer. */ /** Reusable request-body source fed by the connection demultiplexer. */
public final class Http2RequestBody extends InputStream { public final class Http2RequestBody extends InputStream implements BodyCompletion {
@FunctionalInterface @FunctionalInterface
public interface ConsumptionListener { public interface ConsumptionListener {
void consumed(int flowControlledBytes) throws IOException; void consumed(int flowControlledBytes) throws IOException;
@@ -30,6 +31,7 @@ public final class Http2RequestBody extends InputStream {
private int inlineFlowControlledBytes; private int inlineFlowControlledBytes;
private boolean inlineMode; private boolean inlineMode;
private boolean finished; private boolean finished;
private boolean fullyRead;
public Http2RequestBody(DataBufferPool pool) { public Http2RequestBody(DataBufferPool pool) {
this.pool = pool; this.pool = pool;
@@ -44,6 +46,7 @@ public final class Http2RequestBody extends InputStream {
inlinePosition = 0; inlinePosition = 0;
inlineFlowControlledBytes = 0; inlineFlowControlledBytes = 0;
finished = false; finished = false;
fullyRead = false;
if (inlineMode && inline == null) inline = new byte[Http2Limits.INLINE_BODY_THRESHOLD]; if (inlineMode && inline == null) inline = new byte[Http2Limits.INLINE_BODY_THRESHOLD];
} }
@@ -162,7 +165,10 @@ public final class Http2RequestBody extends InputStream {
throw new IOException("interrupted while waiting for request DATA", interrupted); throw new IOException("interrupted while waiting for request DATA", interrupted);
} }
} }
if (head == null) return -1; if (head == null) {
fullyRead = true;
return -1;
}
DataBuffer buffer = head; DataBuffer buffer = head;
copied = Math.min(length, buffer.length - buffer.position); copied = Math.min(length, buffer.length - buffer.position);
System.arraycopy(buffer.bytes, buffer.position, target, offset, copied); System.arraycopy(buffer.bytes, buffer.position, target, offset, copied);
@@ -187,7 +193,10 @@ public final class Http2RequestBody extends InputStream {
if (!finished) { if (!finished) {
throw new IOException("inline request body is not complete"); throw new IOException("inline request body is not complete");
} }
if (inlinePosition == received) return -1; if (inlinePosition == received) {
fullyRead = true;
return -1;
}
int copied = (int) Math.min(length, received - inlinePosition); int copied = (int) Math.min(length, received - inlinePosition);
System.arraycopy(inline, inlinePosition, target, offset, copied); System.arraycopy(inline, inlinePosition, target, offset, copied);
inlinePosition += copied; inlinePosition += copied;
@@ -199,6 +208,11 @@ public final class Http2RequestBody extends InputStream {
return copied; return copied;
} }
@Override
public boolean fullyRead() {
return fullyRead || (finished && received == 0);
}
private void notifyConsumed(int bytes) { private void notifyConsumed(int bytes) {
if (bytes == 0 || listener == null) return; if (bytes == 0 || listener == null) return;
try { try {
@@ -46,10 +46,13 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
private long bodyRemaining; private long bodyRemaining;
private int fixedPosition; private int fixedPosition;
private boolean unknownLength; private boolean unknownLength;
private boolean pushBody;
private boolean finished; private boolean finished;
private boolean headersInBatch; private boolean headersInBatch;
private boolean endStreamInBatch; private boolean endStreamInBatch;
private int dataBytesInBatch; private int dataBytesInBatch;
private boolean trailerHeadersInBatch;
private Response response;
public Http2ResponseWriter() { public Http2ResponseWriter() {
this(1024, 2048); this(1024, 2048);
@@ -104,6 +107,7 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
this.streamId = streamId; this.streamId = streamId;
this.huffmanDynamicValues = huffmanDynamicValues; this.huffmanDynamicValues = huffmanDynamicValues;
this.maxHeaderListSize = maxHeaderListSize; this.maxHeaderListSize = maxHeaderListSize;
this.response = response;
headerListSize = 0; headerListSize = 0;
next = null; next = null;
@@ -121,12 +125,14 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
} }
ResponseSerializer.forEachCustomField(response, this); ResponseSerializer.forEachCustomField(response, this);
writeHeaderFrames(maxFrameSize, suppressBody || bodyLength == 0); boolean hasTrailers = response.hasTrailers() && !suppressBody;
writeHeaderFrames(maxFrameSize, suppressBody || (bodyLength == 0 && !hasTrailers));
if (!suppressBody && bodyLength > 0) { if (!suppressBody && bodyLength > 0) {
frames.beginFrame(FrameType.DATA, FrameFlags.END_STREAM, streamId); frames.beginFrame(FrameType.DATA, hasTrailers ? 0 : FrameFlags.END_STREAM, streamId);
output.writeBytes(body); output.writeBytes(body);
frames.endFrame(); frames.endFrame();
} }
if (hasTrailers) appendTrailers(maxFrameSize);
return true; return true;
} }
@@ -157,10 +163,13 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
headersInBatch = true; headersInBatch = true;
endStreamInBatch = false; endStreamInBatch = false;
dataBytesInBatch = 0; dataBytesInBatch = 0;
trailerHeadersInBatch = false;
this.response = response;
fixedPosition = 0; fixedPosition = 0;
fixedBody = response.isStreaming() ? null : response.getBody(); fixedBody = response.isStreaming() ? null : response.getBody();
streamBody = response.isStreaming() ? response.getStream() : null; streamBody = response.isStreaming() ? response.getStream() : null;
unknownLength = response.isStreaming() && response.isChunked(); unknownLength = response.isStreaming() && response.isChunked();
pushBody = response.isPushStreaming();
if (response.isStreaming() && !unknownLength && response.getStreamLength() < 0) { if (response.isStreaming() && !unknownLength && response.getStreamLength() < 0) {
throw new IllegalArgumentException("known response stream length must not be negative"); throw new IllegalArgumentException("known response stream length must not be negative");
} }
@@ -195,8 +204,14 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
ResponseSerializer.forEachCustomField(response, this); ResponseSerializer.forEachCustomField(response, this);
boolean hasBody = unknownLength || bodyRemaining > 0; boolean hasBody = unknownLength || bodyRemaining > 0;
writeHeaderFrames(maxFrameSize, !hasBody); boolean hasTrailers = !headRequest && !bodyForbidden && response.hasTrailers();
finished = !hasBody; writeHeaderFrames(maxFrameSize, !hasBody && !hasTrailers);
finished = !hasBody && !hasTrailers;
if (!hasBody && hasTrailers) {
appendTrailers(maxFrameSize);
finished = true;
endStreamInBatch = true;
}
if (hasBody && availableFlowWindow > 0) { if (hasBody && availableFlowWindow > 0) {
appendData(maxFrameSize, availableFlowWindow); appendData(maxFrameSize, availableFlowWindow);
} }
@@ -211,6 +226,7 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
headersInBatch = false; headersInBatch = false;
endStreamInBatch = false; endStreamInBatch = false;
dataBytesInBatch = 0; dataBytesInBatch = 0;
trailerHeadersInBatch = false;
appendData(maxFrameSize, availableFlowWindow); appendData(maxFrameSize, availableFlowWindow);
return dataBytesInBatch; return dataBytesInBatch;
} }
@@ -221,8 +237,10 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
boolean end; boolean end;
if (fixedBody != null) { if (fixedBody != null) {
count = (int) Math.min(target, bodyRemaining); count = (int) Math.min(target, bodyRemaining);
boolean finalData = count == bodyRemaining;
boolean trailersFollow = finalData && response.hasTrailers();
frames.beginFrame( frames.beginFrame(
FrameType.DATA, count == bodyRemaining ? FrameFlags.END_STREAM : 0, streamId); FrameType.DATA, finalData && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId);
output.writeBytes(fixedBody, fixedPosition, count); output.writeBytes(fixedBody, fixedPosition, count);
frames.endFrame(); frames.endFrame();
fixedPosition += count; fixedPosition += count;
@@ -232,21 +250,27 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
int limit = unknownLength ? target : (int) Math.min(target, bodyRemaining); int limit = unknownLength ? target : (int) Math.min(target, bodyRemaining);
count = 0; count = 0;
boolean eof = false; boolean eof = false;
while (count < limit) { if (pushBody) {
int read = streamBody.read(relay, count, limit - count); int read = streamBody.read(relay, 0, limit);
if (read < 0) { if (read < 0) eof = true;
eof = true; else count = read;
break; } else {
} while (count < limit) {
if (read == 0) { int read = streamBody.read(relay, count, limit - count);
int one = streamBody.read(); if (read < 0) {
if (one < 0) {
eof = true; eof = true;
break; break;
} }
relay[count++] = (byte) one; if (read == 0) {
} else { int one = streamBody.read();
count += read; if (one < 0) {
eof = true;
break;
}
relay[count++] = (byte) one;
} else {
count += read;
}
} }
} }
if (!unknownLength) { if (!unknownLength) {
@@ -256,15 +280,31 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
} }
} }
end = unknownLength ? eof : bodyRemaining == 0; end = unknownLength ? eof : bodyRemaining == 0;
frames.beginFrame(FrameType.DATA, end ? FrameFlags.END_STREAM : 0, streamId); boolean trailersFollow = end && response.hasTrailers();
output.writeBytes(relay, 0, count); if (count != 0 || !trailersFollow) {
frames.endFrame(); frames.beginFrame(FrameType.DATA, end && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId);
output.writeBytes(relay, 0, count);
frames.endFrame();
}
} }
dataBytesInBatch = count; dataBytesInBatch = count;
endStreamInBatch = end; if (end && response.hasTrailers()) {
appendTrailers(maxFrameSize);
endStreamInBatch = true;
} else {
endStreamInBatch = end;
}
finished = end; finished = end;
} }
private void appendTrailers(int maxFrameSize) {
headerBlock.reset();
headerListSize = 0;
ResponseSerializer.forEachTrailerField(response, this);
writeHeaderFrames(maxFrameSize, true);
trailerHeadersInBatch = true;
}
public boolean finished() { public boolean finished() {
return finished; return finished;
} }
@@ -281,6 +321,10 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
return dataBytesInBatch; return dataBytesInBatch;
} }
public boolean trailerHeadersInBatch() {
return trailerHeadersInBatch;
}
@Override @Override
public void accept( public void accept(
byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) { byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) {
@@ -64,6 +64,20 @@ public final class PseudoHeaders {
} }
} }
/** Validates a trailing field section, where pseudo-fields are never permitted. */
public static void validateTrailers(HpackHeaderBlock block, int streamId) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
for (int i = 0; i < block.count(); i++) {
block.get(i, name, value);
if (name.length() == 0 || name.byteAt(0) == ':') fail(streamId, "pseudo-header in trailers");
validateRegular(name, value, streamId);
if (equals(name, "content-length") || equals(name, "host") || equals(name, "te")) {
fail(streamId, "field is not permitted in trailers");
}
}
}
public PooledSlice method() { public PooledSlice method() {
return method; return method;
} }
@@ -36,8 +36,10 @@ public final class Http2Stream
private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'}; private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'};
private final HpackHeaderBlock headerBlock = new HpackHeaderBlock(); private final HpackHeaderBlock headerBlock = new HpackHeaderBlock();
private final HpackHeaderBlock trailerBlock = new HpackHeaderBlock();
private final PseudoHeaders pseudoHeaders = new PseudoHeaders(); private final PseudoHeaders pseudoHeaders = new PseudoHeaders();
private final Http2HeaderMap headers = new Http2HeaderMap(); private final Http2HeaderMap headers = new Http2HeaderMap();
private final Http2HeaderMap trailers = new Http2HeaderMap();
private final RequestLine requestLine = new RequestLine(); private final RequestLine requestLine = new RequestLine();
private final RequestBody requestBody = new RequestBody(); private final RequestBody requestBody = new RequestBody();
private final Http2RequestBody http2Body; private final Http2RequestBody http2Body;
@@ -92,6 +94,8 @@ public final class Http2Stream
resumeTask = false; resumeTask = false;
responseSink = null; responseSink = null;
headerBlock.reset(); headerBlock.reset();
trailerBlock.reset();
trailers.reset(trailerBlock);
} }
void clear() { void clear() {
@@ -131,7 +135,9 @@ public final class Http2Stream
} }
requestLine.reset(method, path, question < 0 ? null : query, protocol, headers); requestLine.reset(method, path, question < 0 ? null : query, protocol, headers);
requestBody.reset(http2Body, http2Body.declaredLength(), null, 0, 0); requestBody.reset(http2Body, http2Body.declaredLength(), null, 0, 0);
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket); Request assembled = Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
assembled.setTrailers(trailers);
return assembled;
} }
public void validateHeaders() { public void validateHeaders() {
@@ -158,6 +164,11 @@ public final class Http2Stream
http2Body.finish(id); http2Body.finish(id);
} }
public void validateTrailers() {
PseudoHeaders.validateTrailers(trailerBlock, id);
trailers.reset(trailerBlock);
}
private long parseContentLength() { private long parseContentLength() {
long parsed = -1; long parsed = -1;
for (int i = 0; i < headerBlock.count(); i++) { for (int i = 0; i < headerBlock.count(); i++) {
@@ -217,6 +228,10 @@ public final class Http2Stream
return headerBlock; return headerBlock;
} }
public HpackHeaderBlock trailerBlock() {
return trailerBlock;
}
public Http2ResponseWriter responseWriter() { public Http2ResponseWriter responseWriter() {
return responseWriter; return responseWriter;
} }
@@ -0,0 +1,6 @@
package dev.relism.flash.models;
/** Internal completion signal used to enforce request-trailer ordering. */
public interface BodyCompletion {
boolean fullyRead();
}
@@ -0,0 +1,18 @@
package dev.relism.flash.models;
import dev.relism.fpr.core.ByteView;
import java.util.List;
/** Immutable empty header collection shared by requests without trailers. */
public enum EmptyHeaderView implements HeaderView {
INSTANCE;
@Override public String first(String name) { return null; }
@Override public List<String> all(String name) { return List.of(); }
@Override public List<String> all() { return List.of(); }
@Override public ByteView view(String name) { return null; }
@Override public boolean valueEqualsIgnoreCase(String name, String value) { return false; }
@Override public boolean contains(String name) { return false; }
@Override public int count() { return 0; }
@Override public void forEach(HeaderConsumer consumer) {}
}
@@ -0,0 +1,138 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.io.IOException;
import java.io.OutputStream;
/** Reusable owned-byte header collection for sections parsed outside the request head buffer. */
public final class MutableHeaderMap implements HeaderView {
private final ByteWriter bytes = new ByteWriter(128);
private final PooledSlice view = new PooledSlice();
private final PooledSlice scanName = new PooledSlice();
private final PooledSlice scanValue = new PooledSlice();
private int[] fields = new int[16];
private int count;
public void reset() {
bytes.reset();
count = 0;
}
public void add(byte[] name, int nameOffset, int nameLength,
byte[] value, int valueOffset, int valueLength) {
ensure(count + 1);
int base = count * 4;
fields[base] = bytes.length();
fields[base + 1] = nameLength;
bytes.writeBytes(name, nameOffset, nameLength);
fields[base + 2] = bytes.length();
fields[base + 3] = valueLength;
bytes.writeBytes(value, valueOffset, valueLength);
count++;
}
public void writeLines(OutputStream output) throws IOException {
for (int i = 0; i < count; i++) {
int base = i * 4;
output.write(bytes.array(), fields[base], fields[base + 1]);
output.write(':');
output.write(' ');
output.write(bytes.array(), fields[base + 2], fields[base + 3]);
output.write('\r');
output.write('\n');
}
}
void forEachStructured(ResponseSerializer.FieldConsumer consumer) {
for (int i = 0; i < count; i++) {
int base = i * 4;
consumer.accept(bytes.array(), fields[base], fields[base + 1],
bytes.array(), fields[base + 2], fields[base + 3]);
}
}
@Override
public String first(String name) {
int index = indexOf(name, 0);
return index < 0 ? null : value(index);
}
@Override
public List<String> all(String name) {
List<String> result = null;
int from = 0;
int index;
while ((index = indexOf(name, from)) >= 0) {
if (result == null) result = new ArrayList<>();
result.add(value(index));
from = index + 1;
}
return result == null ? List.of() : result;
}
@Override
public List<String> all() {
if (count == 0) return List.of();
List<String> result = new ArrayList<>(count);
for (int i = 0; i < count; i++) result.add(value(i));
return result;
}
@Override
public ByteView view(String name) {
int index = indexOf(name, 0);
if (index < 0) return null;
int base = index * 4;
view.reset(bytes.array(), fields[base + 2], fields[base + 3]);
return view;
}
@Override
public boolean valueEqualsIgnoreCase(String name, String value) {
int index = indexOf(name, 0);
if (index < 0) return false;
int base = index * 4;
return ByteScan.equalsIgnoreCaseAscii(
bytes.array(), fields[base + 2], fields[base + 2] + fields[base + 3], value);
}
@Override public boolean contains(String name) { return indexOf(name, 0) >= 0; }
@Override public int count() { return count; }
@Override
public void forEach(HeaderConsumer consumer) {
for (int i = 0; i < count; i++) {
int base = i * 4;
scanName.reset(bytes.array(), fields[base], fields[base + 1]);
scanValue.reset(bytes.array(), fields[base + 2], fields[base + 3]);
consumer.accept(scanName, scanValue);
}
}
private int indexOf(String name, int from) {
for (int i = from; i < count; i++) {
int base = i * 4;
if (ByteScan.equalsIgnoreCaseAscii(
bytes.array(), fields[base], fields[base] + fields[base + 1], name)) return i;
}
return -1;
}
private String value(int index) {
int base = index * 4;
return new String(bytes.array(), fields[base + 2], fields[base + 3], StandardCharsets.UTF_8);
}
private void ensure(int needed) {
int ints = needed * 4;
if (ints <= fields.length) return;
fields = Arrays.copyOf(fields, Math.max(ints, fields.length * 2));
}
}
@@ -0,0 +1,103 @@
package dev.relism.flash.models;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.function.Consumer;
/** Bounded bridge from a push producer to the protocol writers' common pull path. */
final class ProducerInputStream extends InputStream {
private final PipedInputStream input;
private final ProducerOutput output;
private final Consumer<ResponseStream> producer;
private volatile Throwable failure;
private boolean started;
ProducerInputStream(Consumer<ResponseStream> producer, Response response) {
try {
input = new PipedInputStream(16 * 1024);
output = new ProducerOutput(new PipedOutputStream(input), response);
} catch (IOException impossible) {
throw new IllegalStateException(impossible);
}
this.producer = producer;
}
@Override
public int read() throws IOException {
start();
int value = input.read();
checkFailure(value < 0);
return value;
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
start();
int count = input.read(bytes, offset, length);
checkFailure(count < 0);
return count;
}
@Override
public void close() throws IOException {
input.close();
}
private synchronized void start() {
if (started) return;
started = true;
Thread.startVirtualThread(() -> {
try (output) {
producer.accept(output);
} catch (Throwable thrown) {
failure = thrown;
try {
output.close();
} catch (IOException ignored) {
}
}
});
}
private void checkFailure(boolean eof) throws IOException {
if (eof && failure != null) throw new IOException("response stream producer failed", failure);
}
private static final class ProducerOutput implements ResponseStream {
private final PipedOutputStream output;
private final Response response;
private boolean closed;
ProducerOutput(PipedOutputStream output, Response response) {
this.output = output;
this.response = response;
}
@Override
public synchronized void write(byte[] data, int offset, int length) throws IOException {
if (closed) throw new IOException("response stream is closed");
output.write(data, offset, length);
}
@Override
public synchronized void flush() throws IOException {
if (closed) throw new IOException("response stream is closed");
output.flush();
}
@Override
public synchronized void trailer(String name, String value) {
if (closed) throw new IllegalStateException("response stream is closed");
response.trailer(name, value);
}
@Override
public synchronized void close() throws IOException {
if (closed) return;
closed = true;
output.close();
}
}
}
@@ -52,6 +52,7 @@ import java.util.List;
public class Request { public class Request {
private RequestBody body; private RequestBody body;
private HeaderView trailers = EmptyHeaderView.INSTANCE;
/** Internal: the parsed request line (method, path, query, protocol, headers). */ /** Internal: the parsed request line (method, path, query, protocol, headers). */
private RequestLine requestLine; private RequestLine requestLine;
@@ -95,6 +96,7 @@ public class Request {
void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) { void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.requestLine = requestLine; this.requestLine = requestLine;
this.body = body; this.body = body;
this.trailers = EmptyHeaderView.INSTANCE;
this.pathParams = null; this.pathParams = null;
this.queryParams = null; this.queryParams = null;
this.cachedPath = null; this.cachedPath = null;
@@ -143,6 +145,11 @@ public class Request {
return pooled; return pooled;
} }
/** Internal protocol hook that supplies the request's trailer collection. */
public void setTrailers(HeaderView trailers) {
this.trailers = trailers == null ? EmptyHeaderView.INSTANCE : trailers;
}
// Request line // Request line
/** HTTP method ({@code GET}, {@code POST}, …). */ /** HTTP method ({@code GET}, {@code POST}, …). */
@@ -253,6 +260,19 @@ public class Request {
*/ */
public RequestBody body() { checkActive(); return body; } public RequestBody body() { checkActive(); return body; }
/**
* Returns request trailers after the body has been consumed completely.
*
* @throws IllegalStateException when called before the body reaches EOF
*/
public HeaderView trailers() {
checkActive();
if (!body.fullyRead()) {
throw new IllegalStateException("request trailers are available only after the body is fully read");
}
return trailers;
}
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */ /** Discards unread body bytes; called by the server after each request on keep-alive connections. */
public void drain() { body.drain(); } public void drain() { body.drain(); }
@@ -87,6 +87,15 @@ public class RequestBody {
*/ */
public long contentLength() { return contentLength; } public long contentLength() { return contentLength; }
/** Whether the complete body has been consumed by the application. */
public boolean fullyRead() {
if (resolved != null || contentLength == 0) return true;
if (socket instanceof BodyCompletion completion) return completion.fullyRead();
if (contentLength < 0) return false;
if (boundedStream != null) return boundedStream.complete();
return preBufLen >= contentLength;
}
/** /**
* Materialises and caches the full body. Suitable for JSON, small form data, and any payload * Materialises and caches the full body. Suitable for JSON, small form data, and any payload
* that must be inspected in full. The result is cached repeated calls return the same array. * that must be inspected in full. The result is cached repeated calls return the same array.
@@ -178,6 +187,10 @@ public class RequestBody {
this.socketRemaining = socketRemaining; this.socketRemaining = socketRemaining;
} }
boolean complete() {
return preBufRemaining == 0 && socketRemaining == 0;
}
@Override @Override
public int read() throws IOException { public int read() throws IOException {
if (preBufRemaining > 0) { if (preBufRemaining > 0) {
@@ -12,6 +12,8 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/** /**
* HTTP response. All mutating methods return {@code this} for fluent chaining. * HTTP response. All mutating methods return {@code this} for fluent chaining.
@@ -43,7 +45,9 @@ public class Response {
private InputStream stream; private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked; private boolean chunked;
private boolean pushStreaming;
private byte[] contentType; private byte[] contentType;
private final MutableHeaderMap trailers = new MutableHeaderMap();
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder + // of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores, // char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
@@ -112,9 +116,11 @@ public class Response {
this.stream = null; this.stream = null;
this.streamLength = 0; this.streamLength = 0;
this.chunked = false; this.chunked = false;
this.pushStreaming = false;
this.contentType = contentType.getBytes(); this.contentType = contentType.getBytes();
this.headerQuadCount = 0; this.headerQuadCount = 0;
this.headerCount = 0; this.headerCount = 0;
this.trailers.reset();
if (rawHeaderLines != null) rawHeaderLines.clear(); if (rawHeaderLines != null) rawHeaderLines.clear();
this.active = true; this.active = true;
return this; return this;
@@ -175,6 +181,7 @@ public class Response {
checkActive(); checkActive();
this.body = bytes; this.body = bytes;
this.stream = null; this.stream = null;
this.pushStreaming = false;
return this; return this;
} }
@@ -189,6 +196,7 @@ public class Response {
this.streamLength = length; this.streamLength = length;
this.chunked = false; this.chunked = false;
this.body = null; this.body = null;
this.pushStreaming = false;
return this; return this;
} }
@@ -198,9 +206,62 @@ public class Response {
this.stream = is; this.stream = is;
this.chunked = true; this.chunked = true;
this.body = null; this.body = null;
this.pushStreaming = false;
return this; return this;
} }
/** Push-style streaming response with bounded blocking backpressure. */
public Response streaming(Consumer<ResponseStream> producer) {
checkActive();
this.stream = new ProducerInputStream(Objects.requireNonNull(producer), this);
this.streamLength = -1;
this.chunked = true;
this.pushStreaming = true;
this.body = null;
return this;
}
/** Adds a trailer rendered after the response body on both HTTP versions. */
public Response trailer(String name, String value) {
checkActive();
validateTrailer(name, value);
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
trailers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
return this;
}
/** Adds a pre-encoded structured trailer. */
public Response trailer(PreEncodedHeader trailer) {
checkActive();
byte[] name = trailer.nameBytes();
byte[] value = trailer.valueBytes();
validateTrailer(
new String(name, StandardCharsets.US_ASCII),
new String(value, StandardCharsets.US_ASCII));
trailers.add(name, 0, name.length, value, 0, value.length);
return this;
}
private static void validateTrailer(String name, String value) {
if (name.isEmpty() || name.charAt(0) == ':' || containsLineBreak(name)
|| containsLineBreak(value)) {
throw new IllegalArgumentException("invalid response trailer");
}
if (name.equalsIgnoreCase("content-length")
|| name.equalsIgnoreCase("transfer-encoding")
|| name.equalsIgnoreCase("connection")
|| name.equalsIgnoreCase("host")
|| name.equalsIgnoreCase("te")
|| name.equalsIgnoreCase("trailer")) {
throw new IllegalArgumentException("field is not permitted in response trailers: " + name);
}
}
private static boolean containsLineBreak(String value) {
return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0;
}
/** /**
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at * 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
* call time; zero-alloc on the write path. * call time; zero-alloc on the write path.
@@ -367,6 +428,12 @@ public class Response {
return chunked; return chunked;
} }
/** Internal distinction between producer-driven and InputStream-driven response bodies. */
public boolean isPushStreaming() {
checkActive();
return pushStreaming;
}
public int getStatusCode() { public int getStatusCode() {
checkActive(); checkActive();
return statusCode; return statusCode;
@@ -397,6 +464,20 @@ public class Response {
return streamLength; return streamLength;
} }
public boolean hasTrailers() {
checkActive();
return trailers.count() != 0;
}
public void writeTrailers(OutputStream output) throws IOException {
checkActive();
trailers.writeLines(output);
}
void forEachTrailerField(ResponseSerializer.FieldConsumer consumer) {
trailers.forEachStructured(consumer);
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Internal setters used by HttpServer for handler return values // Internal setters used by HttpServer for handler return values
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -54,4 +54,9 @@ public final class ResponseSerializer {
public static void forEachCustomField(Response response, FieldConsumer consumer) { public static void forEachCustomField(Response response, FieldConsumer consumer) {
response.forEachStructuredField(consumer); response.forEachStructuredField(consumer);
} }
/** Enumerates response trailers in declaration order. */
public static void forEachTrailerField(Response response, FieldConsumer consumer) {
response.forEachTrailerField(consumer);
}
} }
@@ -0,0 +1,11 @@
package dev.relism.flash.models;
import java.io.IOException;
/** Blocking, flow-controlled response body used by push-style streaming producers. */
public interface ResponseStream extends AutoCloseable {
void write(byte[] data, int offset, int length) throws IOException;
void flush() throws IOException;
void trailer(String name, String value);
@Override void close() throws IOException;
}
@@ -84,7 +84,10 @@ public abstract class AbstractRouter {
*/ */
public AbstractRouter doRegister(HttpMethod method, String path, public AbstractRouter doRegister(HttpMethod method, String path,
RequestHandler handler, Middleware[] middlewares) { RequestHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares)); String target = method == HttpMethod.CONNECT
? PathUtils.sanitizeAuthority(path)
: PathUtils.sanitize(path);
return addRoute(method, target, compile(handler, middlewares));
} }
/** /**
@@ -23,6 +23,14 @@ public class PathUtils {
return sanitized; return sanitized;
} }
/** Normalizes an authority-form CONNECT target without turning it into an origin-form path. */
public static String sanitizeAuthority(String authority) {
if (authority == null) return "";
String sanitized = authority.trim();
while (sanitized.startsWith("/")) sanitized = sanitized.substring(1);
return sanitized;
}
/** /**
* Joins two path segments and ensures the result is sanitized. * Joins two path segments and ensures the result is sanitized.
* Prevents "double namespace" if the path already starts with the base. * Prevents "double namespace" if the path already starts with the base.
@@ -68,7 +68,7 @@ class ChunkedInputStreamTest {
@Test @Test
void trailers_consumed() throws IOException { void trailers_consumed() throws IOException {
// trailing headers after 0-chunk must be consumed // trailing headers after 0-chunk must be consumed
assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nTrailer: value\r\n\r\n"))); assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nX-Trailer: value\r\n\r\n")));
} }
// --- byte-by-byte read --- // --- byte-by-byte read ---
@@ -0,0 +1,68 @@
package dev.relism.flash;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http1.Http1ResponseWriter;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.ConnectionScratch;
import dev.relism.flash.transport.ScratchPool;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class Http1TrailersTest {
@Test
void requestTrailersBecomeVisibleOnlyAfterBodyEof() throws Exception {
byte[] wire = ("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "3\r\nabc\r\n0\r\nGrpc-Status: 0\r\nX-Trace: done\r\n\r\n")
.getBytes(StandardCharsets.US_ASCII);
Request request = new RequestParser().parse(
new BufferedByteSource(new ByteArrayInputStream(wire), null));
assertThrows(IllegalStateException.class, request::trailers);
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
assertEquals("0", request.trailers().first("grpc-status"));
assertEquals("done", request.trailers().first("x-trace"));
}
@Test
void responseTrailersUseChunkedRendering() throws Exception {
Response response = new Response(200, "hello", ContentType.TEXT_PLAIN)
.trailer("grpc-status", "0");
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(
output, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
String wire = output.toString(StandardCharsets.US_ASCII);
assertEquals(true, wire.contains("Transfer-Encoding: chunked\r\n"));
assertEquals(true, wire.endsWith("5\r\nhello\r\n0\r\ngrpc-status: 0\r\n\r\n"));
}
@Test
void pushStreamingAndTrailersShareTheSameHttp1Writer() throws Exception {
Response response = new Response(200, ContentType.BINARY).streaming(stream -> {
try {
stream.write("one".getBytes(StandardCharsets.US_ASCII), 0, 3);
stream.write("two".getBytes(StandardCharsets.US_ASCII), 0, 3);
stream.trailer("grpc-status", "0");
} catch (Exception failure) {
throw new RuntimeException(failure);
}
});
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(
output, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
String wire = output.toString(StandardCharsets.US_ASCII);
assertEquals(true, wire.contains("one"));
assertEquals(true, wire.contains("two"));
assertEquals(true, wire.endsWith("0\r\ngrpc-status: 0\r\n\r\n"));
}
}
@@ -0,0 +1,114 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
@Tag("interop")
@EnabledIfSystemProperty(named = "grpcurl.executable", matches = ".+")
class GrpcInteropTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app.post("/flash.test.Echo/Unary", (request, response) ->
response.type("application/grpc")
.body(request.body().bytes())
.trailer("grpc-status", "0"));
app.post("/flash.test.Echo/Stream", (request, response) -> {
byte[] message = request.body().bytes();
return response.type("application/grpc").streaming(stream -> {
try {
for (int i = 0; i < 3; i++) stream.write(message, 0, message.length);
stream.trailer("grpc-status", "0");
} catch (Exception failure) {
throw new RuntimeException(failure);
}
});
});
app.post("/flash.test.Echo/Fail", (request, response) ->
response.type("application/grpc")
.trailer("grpc-status", "3")
.trailer("grpc-message", "invalid request"));
app.start();
Path proto = directory.resolve("echo.proto");
Files.writeString(proto, """
syntax = "proto3";
package flash.test;
service Echo {
rpc Unary (Message) returns (Message);
rpc Stream (Message) returns (stream Message);
rpc Fail (Message) returns (Message);
}
message Message { string value = 1; }
""");
Result unary = call(directory, port, "Unary");
assertEquals(0, unary.exitCode);
assertTrue(unary.output.contains("hello"), unary.output);
Result streaming = call(directory, port, "Stream");
assertEquals(0, streaming.exitCode);
assertEquals(3, occurrences(streaming.output, "hello"), streaming.output);
Result error = call(directory, port, "Fail");
assertTrue(error.exitCode != 0);
assertTrue(error.output.contains("InvalidArgument"), error.output);
assertTrue(error.output.contains("invalid request"), error.output);
}
private static Result call(Path directory, int port, String method) throws Exception {
Process process = new ProcessBuilder(
System.getProperty("grpcurl.executable"),
"-plaintext",
"-import-path", directory.toString(),
"-proto", "echo.proto",
"-d", "{\"value\":\"hello\"}",
"127.0.0.1:" + port,
"flash.test.Echo/" + method)
.redirectErrorStream(true)
.start();
assertTrue(process.waitFor(10, TimeUnit.SECONDS), "grpcurl timed out");
return new Result(process.exitValue(),
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
}
private static int occurrences(String text, String needle) {
int count = 0;
int position = 0;
while ((position = text.indexOf(needle, position)) >= 0) {
count++;
position += needle.length();
}
return count;
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
private record Result(int exitCode, String output) {}
}
@@ -0,0 +1,103 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.io.EOFException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class Http2ConnectTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app.connect("tunnel", (request, response) ->
response.type(ContentType.NONE).streaming(output -> {
byte[] bytes = new byte[16];
try {
int count;
InputStream input = request.body().stream();
while ((count = input.read(bytes)) >= 0) {
output.write(bytes, 0, count);
output.flush();
}
} catch (Exception failure) {
throw new RuntimeException(failure);
}
}));
app.start();
ByteWriter block = new ByteWriter(32);
HpackEncoder.writeLiteralWithNameIndex(block, 2, ascii("CONNECT"), false);
HpackEncoder.writeLiteralWithNameIndex(block, 1, ascii("tunnel"), 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, 1,
Arrays.copyOf(block.array(), block.length())),
Http2TestFrames.frame(FrameType.DATA, 0, 1, ascii("one"))));
socket.getOutputStream().flush();
assertEquals("one", new String(readData(socket.getInputStream()).payload(),
StandardCharsets.US_ASCII));
socket.getOutputStream().write(Http2TestFrames.frame(
FrameType.DATA, FrameFlags.END_STREAM, 1, ascii("two")));
socket.getOutputStream().flush();
assertEquals("two", new String(readData(socket.getInputStream()).payload(),
StandardCharsets.US_ASCII));
}
}
private static Http2TestFrames.WireFrame readData(InputStream input) throws Exception {
for (int i = 0; i < 12; i++) {
Http2TestFrames.WireFrame frame = readFrame(input);
if (frame.streamId() == 1 && frame.type() == FrameType.DATA.code()
&& frame.payload().length != 0) return frame;
}
throw new AssertionError("missing tunnel DATA");
}
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException();
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
byte[] payload = input.readNBytes(length);
return new Http2TestFrames.WireFrame(
header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff,
payload);
}
private static byte[] ascii(String text) {
return text.getBytes(StandardCharsets.US_ASCII);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.ByteWriter; 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.http.ContentType;
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.HpackDecoder;
@@ -14,6 +15,7 @@ import dev.relism.flash.tls.TlsConfig;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.EOFException; import java.io.EOFException;
import java.io.InputStream; import java.io.InputStream;
import java.io.IOException;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; import java.net.Socket;
import java.net.URI; import java.net.URI;
@@ -138,6 +140,61 @@ class Http2ConnectionIntegrationTest {
assertTrue(streamed.headers().firstValue("transfer-encoding").isEmpty()); assertTrue(streamed.headers().firstValue("transfer-encoding").isEmpty());
} }
@Test
void pushStreamingAppliesBackpressureAcrossMultipleWindows(@TempDir Path directory)
throws Exception {
int port = freePort();
int length = 2 * 1024 * 1024 + 31;
Path keystore =
TestKeystores.build(
directory,
"http2-push-stream.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(
"/push",
(request, response) ->
response.type(ContentType.BINARY).streaming(stream -> {
byte[] block = new byte[8192];
int written = 0;
try {
while (written < length) {
int count = Math.min(block.length, length - written);
for (int i = 0; i < count; i++) block[i] = (byte) ((written + i) * 31);
stream.write(block, 0, count);
written += count;
}
stream.trailer("grpc-status", "0");
} catch (IOException failure) {
throw new RuntimeException(failure);
}
}));
app.start();
HttpClient client =
HttpClient.newBuilder()
.sslContext(TestKeystores.trustAllClientContext())
.version(HttpClient.Version.HTTP_2)
.build();
HttpResponse<InputStream> response =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/push")).GET().build(),
HttpResponse.BodyHandlers.ofInputStream());
assertEquals(HttpClient.Version.HTTP_2, response.version());
try (InputStream body = response.body()) {
assertEquals(length, verifyPattern(body));
}
}
@Test @Test
void hundredMegabyteUploadAndDownloadRemainStreaming(@TempDir Path directory) throws Exception { void hundredMegabyteUploadAndDownloadRemainStreaming(@TempDir Path directory) throws Exception {
int port = freePort(); int port = freePort();
@@ -0,0 +1,50 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.stream.Http2StreamState;
import org.junit.jupiter.api.Test;
class Http2HalfCloseTest {
@Test
void remoteMayCloseBeforeLocalResponseCompletes() {
Http2StreamState state = Http2StreamState.IDLE;
state = state.transition(1, Http2StreamState.Event.RECV_HEADERS_ES);
assertEquals(Http2StreamState.HALF_CLOSED_REMOTE, state);
state = state.transition(1, Http2StreamState.Event.SEND_HEADERS);
state = state.transition(1, Http2StreamState.Event.SEND_DATA);
state = state.transition(1, Http2StreamState.Event.SEND_DATA_ES);
assertEquals(Http2StreamState.CLOSED, state);
}
@Test
void localMayCloseWhileRemoteBodyContinues() {
Http2StreamState state = Http2StreamState.IDLE;
state = state.transition(1, Http2StreamState.Event.RECV_HEADERS);
state = state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES);
assertEquals(Http2StreamState.HALF_CLOSED_LOCAL, state);
state = state.transition(1, Http2StreamState.Event.RECV_DATA);
state = state.transition(1, Http2StreamState.Event.RECV_DATA_ES);
assertEquals(Http2StreamState.CLOSED, state);
}
@Test
void bothSidesRemainOpenDuringBidirectionalData() {
Http2StreamState state = Http2StreamState.IDLE;
state = state.transition(1, Http2StreamState.Event.RECV_HEADERS);
state = state.transition(1, Http2StreamState.Event.SEND_HEADERS);
state = state.transition(1, Http2StreamState.Event.RECV_DATA);
state = state.transition(1, Http2StreamState.Event.SEND_DATA);
assertEquals(Http2StreamState.OPEN, state);
}
@Test
void trailingHeadersCanCloseRemoteAfterLocalHalfClose() {
Http2StreamState state = Http2StreamState.IDLE;
state = state.transition(1, Http2StreamState.Event.RECV_HEADERS);
state = state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES);
state = state.transition(1, Http2StreamState.Event.RECV_DATA);
state = state.transition(1, Http2StreamState.Event.RECV_HEADERS_ES);
assertEquals(Http2StreamState.CLOSED, state);
}
}
@@ -0,0 +1,150 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.io.EOFException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class Http2TrailersTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void requestTrailersReachHandlerAfterBodyEof() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app.post("/trailers", (request, response) -> {
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
return request.trailers().first("grpc-status");
});
app.start();
byte[] initial = requestHeaders("/trailers");
ByteWriter trailer = new ByteWriter(32);
HpackEncoder.writeLiteral(
trailer, "grpc-status".getBytes(StandardCharsets.US_ASCII),
"7".getBytes(StandardCharsets.US_ASCII));
try (Socket socket = connect(port)) {
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, 1, initial),
Http2TestFrames.frame(FrameType.DATA, 0, 1, "abc".getBytes(StandardCharsets.US_ASCII)),
Http2TestFrames.frame(FrameType.HEADERS,
FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1,
Arrays.copyOf(trailer.array(), trailer.length()))));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame data = frameOfType(socket.getInputStream(), 1, FrameType.DATA);
assertEquals("7", new String(data.payload(), StandardCharsets.US_ASCII));
}
}
@Test
void trailersWithoutEndStreamAreRejected() throws Exception {
int port = startBlockingRoute();
ByteWriter trailer = new ByteWriter(32);
HpackEncoder.writeLiteral(trailer, ascii("x-end"), ascii("no"));
try (Socket socket = connect(port)) {
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, 1, requestHeaders("/trailers")),
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1,
Arrays.copyOf(trailer.array(), trailer.length()))));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame rst = frameOfType(socket.getInputStream(), 1, FrameType.RST_STREAM);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(rst.payload(), 0));
}
}
@Test
void pseudoHeaderInTrailersIsRejected() throws Exception {
int port = startBlockingRoute();
try (Socket socket = connect(port)) {
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, 1, requestHeaders("/trailers")),
Http2TestFrames.frame(FrameType.HEADERS,
FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1, new byte[] {(byte) 0x88})));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame rst = frameOfType(socket.getInputStream(), 1, FrameType.RST_STREAM);
assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(rst.payload(), 0));
}
}
private int startBlockingRoute() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.host("127.0.0.1").port(port).http2Enabled(true).build());
app.post("/trailers", (request, response) -> request.body().bytes());
app.start();
return port;
}
private static byte[] requestHeaders(String path) {
ByteWriter block = new ByteWriter(64);
HpackEncoder.writeIndexed(block, 3);
HpackEncoder.writeIndexed(block, 6);
HpackEncoder.writeLiteralWithNameIndex(block, 4, ascii(path), false);
HpackEncoder.writeLiteralWithNameIndex(block, 1, ascii("localhost"), false);
HpackEncoder.writeLiteralWithNameIndex(block, 59, ascii("trailers"), false);
return Arrays.copyOf(block.array(), block.length());
}
private static byte[] ascii(String value) {
return value.getBytes(StandardCharsets.US_ASCII);
}
private static Socket connect(int port) throws Exception {
Socket socket = new Socket("127.0.0.1", port);
socket.setSoTimeout(5_000);
return socket;
}
private static Http2TestFrames.WireFrame frameOfType(
InputStream input, int streamId, FrameType type) throws Exception {
for (int i = 0; i < 12; i++) {
Http2TestFrames.WireFrame frame = readFrame(input);
if (frame.streamId() == streamId && frame.type() == type.code()) return frame;
}
throw new AssertionError("missing " + type + " frame");
}
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException();
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
byte[] payload = input.readNBytes(length);
if (payload.length != length) throw new EOFException();
return new Http2TestFrames.WireFrame(
header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff,
payload);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -132,6 +132,23 @@ class Http2ResponseWriterTest {
assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types); assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types);
} }
@Test
void finalDataDoesNotEndStreamWhenTrailingHeadersFollow() throws Exception {
Response response = new Response(200, "ok", ContentType.TEXT_PLAIN)
.trailer("grpc-status", "0");
Http2ResponseWriter writer = new Http2ResponseWriter();
writer.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 4096, 16_384);
Parsed parsed = parse(writer);
assertEquals(List.of(FrameType.HEADERS, FrameType.DATA, FrameType.HEADERS), parsed.types);
assertEquals(0, parsed.flags.get(1) & FrameFlags.END_STREAM);
assertTrue((parsed.flags.get(2) & FrameFlags.END_STREAM) != 0);
assertTrue(decode(parsed.headerBlock).contains("grpc-status=0"));
assertTrue(writer.trailerHeadersInBatch());
}
private static Parsed parse(Http2ResponseWriter writer) { private static Parsed parse(Http2ResponseWriter writer) {
Parsed parsed = new Parsed(); Parsed parsed = new Parsed();
byte[] wire = writer.buffer(); byte[] wire = writer.buffer();
@@ -0,0 +1,50 @@
package dev.relism.flash.models;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http.ContentType;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class ResponseStreamTest {
@Test
void producerWritesBodyAndTrailersThroughBoundedBridge() throws Exception {
Response response = new Response(200, ContentType.BINARY);
response.streaming(stream -> {
try {
stream.write(new byte[] {1, 2, 3}, 0, 3);
stream.trailer("grpc-status", "0");
} catch (IOException failure) {
throw new RuntimeException(failure);
}
});
assertArrayEquals(new byte[] {1, 2, 3}, response.getStream().readAllBytes());
assertEquals(true, response.hasTrailers());
}
@Test
void writeAfterCloseFailsWithoutWritingMoreBytes() throws Exception {
AtomicReference<IOException> failure = new AtomicReference<>();
CountDownLatch attempted = new CountDownLatch(1);
Response response = new Response(200, ContentType.BINARY);
response.streaming(stream -> {
try {
stream.close();
stream.write(new byte[] {1}, 0, 1);
} catch (IOException expected) {
failure.set(expected);
} finally {
attempted.countDown();
}
});
assertArrayEquals(new byte[0], response.getStream().readAllBytes());
assertEquals(true, attempted.await(1, TimeUnit.SECONDS));
assertEquals("response stream is closed", failure.get().getMessage());
}
}