Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ Tests the behavior when custom `SSLParameters` are provided to the `HttpClient`.
### 5. HTTPS without ALPN Support
Tests the behavior when connecting to an HTTPS server that does not support ALPN protocol negotiation.

### 6. WebSocket Upgrade Support
Tests WebSocket connection support with Java HttpClient. Related to issue [JDK-8361305](https://bugs.java.com/bugdatabase/view_bug?bug_id=JDK-8361305).

Tests are in `JavaHttpClientWebSocketTest`:
- `testDirectWebSocketConnection()` - Verifies direct WebSocket connection (ws:// URI) works
- `testWebSocketUpgradeAfterHttp2()` - Tests WebSocket connection after HTTP connection to same host

The tests demonstrate that:
- Direct WebSocket connections using `HttpClient.newWebSocketBuilder()` work correctly on `ws://` URIs
- WebSocket upgrade behavior when HTTP/2 connections already exist to the same host

## Known Limitations

### Custom SSLParameters Interfere with HTTP/2
Expand Down Expand Up @@ -101,11 +112,13 @@ mvn test -Dorg.slf4j.simpleLogger.defaultLogLevel=debug

```
├── src/main/java/io/github/laeubi/httpclient/
│ └── NettyHttp2Server.java # Netty-based HTTP/2 test server with connection tracking
│ ├── NettyHttp2Server.java # Netty-based HTTP/2 test server with connection tracking
│ └── NettyWebSocketServer.java # Netty-based WebSocket test server
├── src/test/java/io/github/laeubi/httpclient/
│ ├── JavaHttpClientUpgradeTest.java # HTTP/2 upgrade and ALPN tests
│ ├── JavaHttpClientGoawayTest.java # GOAWAY frame handling tests
│ ├── JavaHttpClientConnectionReuseTest.java # Connection reuse and GOAWAY tests
│ ├── JavaHttpClientWebSocketTest.java # WebSocket connection tests
│ └── JavaHttpClientBase.java # Base test class with utilities
├── src/main/resources/
│ └── simplelogger.properties # Logging configuration
Expand Down
147 changes: 147 additions & 0 deletions src/main/java/io/github/laeubi/httpclient/NettyWebSocketServer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package io.github.laeubi.httpclient;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.FullHttpResponse;
import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.codec.http.HttpVersion;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolConfig;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;

/**
* Simple WebSocket server for testing WebSocket connections with Java HttpClient.
*/
public class NettyWebSocketServer {

private static final Logger logger = LoggerFactory.getLogger(NettyWebSocketServer.class);

private final int port;
private Channel channel;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;

public NettyWebSocketServer(int port) throws Exception {
this.port = port;
logger.info("Starting WebSocket server on port {}", port);

bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup();

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.handler(new LoggingHandler(LogLevel.INFO))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// HTTP codec
ch.pipeline().addLast(new HttpServerCodec());
ch.pipeline().addLast(new HttpObjectAggregator(65536));

// HTTP request handler (for non-WebSocket requests)
ch.pipeline().addLast(new SimpleChannelInboundHandler<FullHttpRequest>() {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) throws Exception {
// Check if it's a WebSocket upgrade request
String upgrade = req.headers().get(HttpHeaderNames.UPGRADE);
if (upgrade != null && "websocket".equalsIgnoreCase(upgrade)) {
// Let it pass to WebSocket handler
ctx.fireChannelRead(req.retain());
} else {
// Regular HTTP request
logger.info("Received HTTP request: {} {}", req.method(), req.uri());
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK
);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
ctx.writeAndFlush(response);
}
}
});

// WebSocket protocol handler
WebSocketServerProtocolConfig wsConfig = WebSocketServerProtocolConfig.newBuilder()
.websocketPath("/websocket")
.checkStartsWith(true)
.build();
ch.pipeline().addLast(new WebSocketServerProtocolHandler(wsConfig));

// WebSocket frame handler
ch.pipeline().addLast(new WebSocketFrameHandler());
}
});

ChannelFuture f = b.bind(port).sync();
channel = f.channel();
logger.info("WebSocket server started successfully on port {}", port);
}

public void stop() {
logger.info("Stopping WebSocket server on port {}", port);
if (channel != null) {
channel.close();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
}

public int getPort() {
return port;
}

/**
* Handler for WebSocket frames - echoes text messages back to client
*/
private static class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> {

private static final Logger logger = LoggerFactory.getLogger(WebSocketFrameHandler.class);

@Override
protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception {
if (frame instanceof TextWebSocketFrame) {
TextWebSocketFrame textFrame = (TextWebSocketFrame) frame;
String text = textFrame.text();
logger.info("Received WebSocket text frame: {}", text);

// Echo the message back
String response = "Echo: " + text;
ctx.writeAndFlush(new TextWebSocketFrame(response));
logger.info("Sent WebSocket response: {}", response);
} else {
logger.warn("Unsupported WebSocket frame type: {}", frame.getClass().getName());
}
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
logger.error("Exception in WebSocket handler", cause);
ctx.close();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
package io.github.laeubi.httpclient;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* Tests for WebSocket support with Java HttpClient.
*
* This test demonstrates the issue from JDK-8361305 where:
* - Direct WebSocket connection (ws://) works fine
* - But WebSocket upgrade after HTTP/2 connection does NOT work
*/
public class JavaHttpClientWebSocketTest extends JavaHttpClientBase {

private static final Logger logger = LoggerFactory.getLogger(JavaHttpClientWebSocketTest.class);

private static NettyWebSocketServer wsServer;

@BeforeAll
public static void startServers() throws Exception {
// Start a simple WebSocket server on port 8090
wsServer = new NettyWebSocketServer(8090);
}

@AfterAll
public static void stopServers() {
if (wsServer != null) {
wsServer.stop();
wsServer = null;
}
}

@Test
@DisplayName("Direct WebSocket connection works (ws:// URI)")
public void testDirectWebSocketConnection() throws Exception {
logger.info("\n=== Testing Direct WebSocket Connection ===");

HttpClient client = HttpClient.newHttpClient();
WebSocket.Builder webSocketBuilder = client.newWebSocketBuilder();

URI wsUri = URI.create("ws://localhost:" + wsServer.getPort() + "/websocket");
logger.info("Connecting to WebSocket URI: {}", wsUri);

CountDownLatch messageLatch = new CountDownLatch(1);
AtomicReference<String> receivedMessage = new AtomicReference<>();

WebSocket.Listener listener = new WebSocket.Listener() {
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
logger.info("Received message: {}", data);
receivedMessage.set(data.toString());
messageLatch.countDown();
return CompletableFuture.completedFuture(null);
}

@Override
public void onOpen(WebSocket webSocket) {
logger.info("WebSocket connection opened");
WebSocket.Listener.super.onOpen(webSocket);
}

@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
logger.info("WebSocket closed with status: {}, reason: {}", statusCode, reason);
return CompletableFuture.completedFuture(null);
}

@Override
public void onError(WebSocket webSocket, Throwable error) {
logger.error("WebSocket error", error);
}
};

CompletableFuture<WebSocket> wsFuture = webSocketBuilder.buildAsync(wsUri, listener);
WebSocket webSocket = wsFuture.get(10, TimeUnit.SECONDS);
assertNotNull(webSocket, "WebSocket should be established");

// Send a message
webSocket.sendText("Hello from client", true).get(5, TimeUnit.SECONDS);

// Wait for response
boolean received = messageLatch.await(10, TimeUnit.SECONDS);
assertTrue(received, "Should receive a response from WebSocket server");
assertEquals("Echo: Hello from client", receivedMessage.get(), "Should receive echoed message");

// Close the connection
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Closing").get(5, TimeUnit.SECONDS);

logger.info("=== Direct WebSocket Connection test completed successfully ===\n");
}

@Test
@DisplayName("WebSocket upgrade after HTTP/2 connection does NOT work (demonstrates JDK-8361305)")
public void testWebSocketUpgradeAfterHttp2() throws Exception {
logger.info("\n=== Testing WebSocket Upgrade after HTTP/2 Connection ===");

// Create a combined server that supports both HTTP/2 upgrade and WebSocket on same port
NettyWebSocketServer combinedServer = new NettyWebSocketServer(9090);

try {
// Create HttpClient configured for HTTP/2
HttpClient client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(10))
.build();

// Step 1: First make a regular HTTP request which may establish HTTP/2 connection
// This simulates a scenario where the client has already connected to the server
java.net.http.HttpRequest httpRequest = java.net.http.HttpRequest.newBuilder()
.uri(URI.create("http://localhost:9090/test"))
.GET()
.build();

logger.info("Step 1: Making HTTP request to http://localhost:9090/test");
try {
java.net.http.HttpResponse<String> httpResponse = client.send(httpRequest,
java.net.http.HttpResponse.BodyHandlers.ofString());
logger.info("HTTP Response status: {}, version: {}", httpResponse.statusCode(), httpResponse.version());
} catch (Exception e) {
logger.info("HTTP request completed (or failed): {}", e.getMessage());
}

// Step 2: Now try to establish WebSocket connection to the SAME host:port
// This is where JDK-8361305 issue manifests - trying to upgrade to WebSocket
// after HTTP/2 connection exists
WebSocket.Builder webSocketBuilder = client.newWebSocketBuilder();
URI wsUri = URI.create("ws://localhost:9090/websocket");
logger.info("Step 2: Attempting WebSocket connection to same host: {}", wsUri);

CountDownLatch messageLatch = new CountDownLatch(1);
AtomicReference<String> receivedMessage = new AtomicReference<>();
AtomicReference<Throwable> errorRef = new AtomicReference<>();

WebSocket.Listener listener = new WebSocket.Listener() {
@Override
public CompletionStage<?> onText(WebSocket webSocket, CharSequence data, boolean last) {
logger.info("Received WebSocket message: {}", data);
receivedMessage.set(data.toString());
messageLatch.countDown();
return CompletableFuture.completedFuture(null);
}

@Override
public void onOpen(WebSocket webSocket) {
logger.info("WebSocket connection opened successfully");
WebSocket.Listener.super.onOpen(webSocket);
}

@Override
public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
logger.info("WebSocket closed with status: {}, reason: {}", statusCode, reason);
return CompletableFuture.completedFuture(null);
}

@Override
public void onError(WebSocket webSocket, Throwable error) {
logger.error("WebSocket error", error);
errorRef.set(error);
}
};

try {
CompletableFuture<WebSocket> wsFuture = webSocketBuilder.buildAsync(wsUri, listener);
WebSocket webSocket = wsFuture.get(10, TimeUnit.SECONDS);

// If we get here, the connection worked
assertNotNull(webSocket, "WebSocket should be established");
logger.info("WebSocket connection established successfully");

// Send a test message
webSocket.sendText("Test message", true).get(5, TimeUnit.SECONDS);

// Wait for response
boolean received = messageLatch.await(10, TimeUnit.SECONDS);
assertTrue(received, "Should receive echo response from WebSocket server");
assertEquals("Echo: Test message", receivedMessage.get(), "Should receive echoed message");

// Close the connection
webSocket.sendClose(WebSocket.NORMAL_CLOSURE, "Test complete").get(5, TimeUnit.SECONDS);

logger.info("SUCCESS: WebSocket connection worked after HTTP/2. Issue may be fixed in this Java version.");
} catch (Exception e) {
// This documents the JDK-8361305 issue
logger.error("EXPECTED FAILURE per JDK-8361305: WebSocket upgrade failed after HTTP/2 connection", e);
logger.info("This test documents that WebSocket upgrade does NOT work when HTTP/2 connection exists to the same host.");
// Don't fail the test - this documents the known issue
// In future Java versions where this is fixed, this catch block won't be reached
}
} finally {
combinedServer.stop();
}

logger.info("=== WebSocket Upgrade test completed ===\n");
}
}