Skip to content

Commit 7a16960

Browse files
committed
Add integ test proving H2 bidirectional (full-duplex) streaming
1 parent a079570 commit 7a16960

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.http.client.it.h2;
7+
8+
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
9+
import static org.junit.jupiter.api.Assertions.assertEquals;
10+
11+
import java.io.IOException;
12+
import java.io.InputStream;
13+
import java.util.Arrays;
14+
import java.util.concurrent.CountDownLatch;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.Timeout;
17+
import software.amazon.smithy.java.http.api.HttpVersion;
18+
import software.amazon.smithy.java.http.client.HttpClient;
19+
import software.amazon.smithy.java.http.client.connection.HttpVersionPolicy;
20+
import software.amazon.smithy.java.http.client.it.TestUtils;
21+
import software.amazon.smithy.java.http.client.it.server.NettyTestServer;
22+
import software.amazon.smithy.java.http.client.it.server.h2.EchoHttp2ClientHandler;
23+
import software.amazon.smithy.java.io.datastream.DataStream;
24+
25+
/**
26+
* Proves true HTTP/2 bidirectional (full-duplex) streaming: the response is read while the request body is
27+
* still open and unfinished.
28+
*
29+
* <p>The request body ({@link BlockingInputStream}) hands over a leading payload and then <em>blocks
30+
* indefinitely</em> before signalling end-of-stream — it only unblocks once the test thread releases it,
31+
* which the test does only <em>after</em> {@code client.send(...)} has returned and the leading echo has
32+
* been read. The server ({@link EchoHttp2ClientHandler}) sends response HEADERS as soon as the request
33+
* HEADERS arrive and echoes each request DATA frame straight back.
34+
*
35+
* <p>This is a strict discriminator for duplex:
36+
* <ul>
37+
* <li><b>Full duplex (correct):</b> the client writes the request body on a background virtual thread
38+
* (see {@code DefaultHttpClient.sendForRoute}), so {@code send()} returns on the response HEADERS
39+
* while the body is still blocked. The test reads the leading echo, then releases the body to finish.</li>
40+
* <li><b>Serialized (broken):</b> if the client wrote the request body inline before reading the
41+
* response, {@code send()} would block inside the body write forever — the test thread that releases
42+
* the body never runs — and the test fails on the timeout.</li>
43+
* </ul>
44+
*
45+
* <p>Verified to be a real discriminator: forcing the client onto the inline-write path makes this test
46+
* hang to the timeout, while the duplex path passes.
47+
*/
48+
public class BidirectionalStreamingHttp2Test extends BaseHttpClientIntegTest {
49+
50+
// Larger than the 16 KB H2 frame buffer so the client flushes a DATA frame for it before the body
51+
// blocks (an OutputStream-backed body only auto-flushes once its frame buffer fills). Position-
52+
// dependent bytes catch a truncated or misordered echo.
53+
private static final byte[] LEADING_PAYLOAD = makeLeadingPayload(64 * 1024);
54+
55+
private static byte[] makeLeadingPayload(int size) {
56+
byte[] b = new byte[size];
57+
for (int i = 0; i < size; i++) {
58+
b[i] = (byte) (i * 31 + 7);
59+
}
60+
return b;
61+
}
62+
63+
@Override
64+
protected NettyTestServer.Builder configureServer(NettyTestServer.Builder builder) {
65+
return builder
66+
.httpVersion(HttpVersion.HTTP_2)
67+
.h2ConnectionMode(NettyTestServer.H2ConnectionMode.PRIOR_KNOWLEDGE)
68+
.http2HandlerFactory(ctx -> new EchoHttp2ClientHandler());
69+
}
70+
71+
@Override
72+
protected HttpClient.Builder configureClient(HttpClient.Builder builder) {
73+
return builder.httpVersionPolicy(HttpVersionPolicy.H2C_PRIOR_KNOWLEDGE);
74+
}
75+
76+
@Test
77+
@Timeout(30)
78+
void readsResponseWhileRequestBodyIsStillOpen() throws Exception {
79+
var releaseRequest = new CountDownLatch(1);
80+
var body = DataStream.ofInputStream(
81+
new BlockingInputStream(LEADING_PAYLOAD, releaseRequest),
82+
"application/octet-stream");
83+
var request = TestUtils.request(HttpVersion.HTTP_2, uri(), body);
84+
85+
try {
86+
// With duplex, send() returns on the response HEADERS even though the request body is still
87+
// blocked mid-stream. Without it, this call would never return and the test would hit @Timeout.
88+
var response = client.send(request);
89+
assertEquals(200, response.statusCode());
90+
91+
try (InputStream in = response.body().asInputStream()) {
92+
// Read one full 16 KB frame of the echo while the request body is still blocked. Receiving
93+
// any response body before releasing the request is the proof that the two directions
94+
// interleave. (Reading the whole leading payload could block on bytes the client hasn't
95+
// flushed yet, since an OutputStream-backed body only flushes on a full frame buffer or
96+
// close — one frame is enough.)
97+
int prefixLen = 16 * 1024;
98+
byte[] leadingEcho = readN(in, prefixLen);
99+
byte[] expectedPrefix = Arrays.copyOf(LEADING_PAYLOAD, prefixLen);
100+
assertArrayEquals(expectedPrefix, leadingEcho, "leading echo read while request still open");
101+
102+
// Now let the request body finish; the server echoes the rest plus END_STREAM. Drain the
103+
// rest and confirm the full leading payload round-tripped.
104+
releaseRequest.countDown();
105+
byte[] rest = in.readAllBytes();
106+
byte[] full = new byte[leadingEcho.length + rest.length];
107+
System.arraycopy(leadingEcho, 0, full, 0, leadingEcho.length);
108+
System.arraycopy(rest, 0, full, leadingEcho.length, rest.length);
109+
assertArrayEquals(LEADING_PAYLOAD, full, "full echoed request body");
110+
}
111+
} finally {
112+
// Ensure the background request-writer VT is never left blocked, even if an assertion above fails.
113+
releaseRequest.countDown();
114+
}
115+
}
116+
117+
private static byte[] readN(InputStream in, int n) throws IOException {
118+
byte[] buf = new byte[n];
119+
int read = 0;
120+
while (read < n) {
121+
int r = in.read(buf, read, n - read);
122+
if (r < 0) {
123+
throw new AssertionError("response stream ended early: wanted " + n + " bytes, got " + read);
124+
}
125+
read += r;
126+
}
127+
return buf;
128+
}
129+
130+
/**
131+
* Emits {@code leading} bytes, then blocks on {@code release} before returning EOF. Models a request
132+
* whose producer is still working: the body is open and unfinished until the test releases it.
133+
*/
134+
private static final class BlockingInputStream extends InputStream {
135+
private final byte[] leading;
136+
private final CountDownLatch release;
137+
private int pos;
138+
private boolean released;
139+
140+
BlockingInputStream(byte[] leading, CountDownLatch release) {
141+
this.leading = leading;
142+
this.release = release;
143+
}
144+
145+
@Override
146+
public int read() throws IOException {
147+
byte[] one = new byte[1];
148+
int n = read(one, 0, 1);
149+
return n < 0 ? -1 : (one[0] & 0xFF);
150+
}
151+
152+
@Override
153+
public int read(byte[] b, int off, int len) throws IOException {
154+
if (pos < leading.length) {
155+
int n = Math.min(len, leading.length - pos);
156+
System.arraycopy(leading, pos, b, off, n);
157+
pos += n;
158+
return n;
159+
}
160+
if (!released) {
161+
try {
162+
// Block indefinitely until the test releases the request, modelling an in-progress
163+
// upload. Crucially there is no self-timeout: a non-duplex client that writes the body
164+
// inline before reading the response would block here forever (the test thread that
165+
// releases this never gets to run), so the test fails via @Timeout rather than passing
166+
// slowly. That is what makes this a real duplex discriminator.
167+
release.await();
168+
} catch (InterruptedException e) {
169+
Thread.currentThread().interrupt();
170+
throw new IOException("interrupted while blocked on request release", e);
171+
}
172+
released = true;
173+
}
174+
return -1;
175+
}
176+
}
177+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package software.amazon.smithy.java.http.client.it.server.h2;
7+
8+
import io.netty.buffer.ByteBuf;
9+
import io.netty.buffer.Unpooled;
10+
import io.netty.channel.ChannelHandlerContext;
11+
import io.netty.handler.codec.http2.DefaultHttp2DataFrame;
12+
import io.netty.handler.codec.http2.DefaultHttp2Headers;
13+
import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame;
14+
import io.netty.handler.codec.http2.Http2DataFrame;
15+
import io.netty.handler.codec.http2.Http2HeadersFrame;
16+
17+
/**
18+
* HTTP/2 handler that echoes each request DATA frame straight back as a response DATA frame, before the
19+
* request stream has ended. It sends response HEADERS as soon as request HEADERS arrive, then mirrors every
20+
* inbound DATA frame and only sends END_STREAM on the response once the request's END_STREAM is seen.
21+
*
22+
* <p>This drives true bidirectional (full-duplex) streaming: the response body is produced incrementally
23+
* while the request body is still open, so a client that buffered the whole request before reading the
24+
* response could never make progress against it.
25+
*/
26+
public class EchoHttp2ClientHandler implements Http2ClientHandler {
27+
28+
@Override
29+
public void onHeadersFrame(ChannelHandlerContext ctx, Http2HeadersFrame frame) {
30+
var headers = new DefaultHttp2Headers();
31+
headers.status("200");
32+
headers.set("content-type", "application/octet-stream");
33+
// Response headers only — the body is streamed back as request DATA frames arrive.
34+
ctx.writeAndFlush(new DefaultHttp2HeadersFrame(headers, false));
35+
36+
// An empty-bodied request (END_STREAM on HEADERS) gets an immediate empty, end-of-stream response.
37+
if (frame.isEndStream()) {
38+
ctx.writeAndFlush(new DefaultHttp2DataFrame(true));
39+
}
40+
}
41+
42+
@Override
43+
public void onDataFrame(ChannelHandlerContext ctx, Http2DataFrame frame) {
44+
// Echo this chunk's payload straight back, retaining the request's END_STREAM flag so the response
45+
// ends exactly when the request does.
46+
ByteBuf echoed = Unpooled.copiedBuffer(frame.content());
47+
ctx.writeAndFlush(new DefaultHttp2DataFrame(echoed, frame.isEndStream()));
48+
}
49+
}

0 commit comments

Comments
 (0)