From 2a13b2a3b80a148de4309cda948e93bb55881c51 Mon Sep 17 00:00:00 2001 From: viretp Date: Thu, 30 Jan 2025 17:43:15 +0100 Subject: [PATCH 01/16] fix issue #17 - duplicate header count more than 2 causes exception (#18) * Fix a bug if multiple headers have the same name * make slightly more efficient to avoid array allocation (most likely). change instanceof cases to use pattern matching for consistency --------- Co-authored-by: Pierre Viret Co-authored-by: robert engels --- .../robaho/net/httpserver/OptimizedHeaders.java | 15 ++++++++------- .../robaho/net/httpserver/RequestHeadersTest.java | 11 +++++++++++ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java index 8248531..7624d3b 100644 --- a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -1,6 +1,7 @@ package robaho.net.httpserver; import java.util.AbstractMap; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; @@ -35,25 +36,25 @@ public boolean isEmpty() { @Override public List get(Object key) { Object o = map.get(normalize((String)key)); - return o == null ? null : (o instanceof String) ? Arrays.asList((String)o) : (List)o; + return o == null ? null : (o instanceof String s) ? List.of(s) : (List)o; } @Override public List put(String key, List value) { Object o = map.put(normalize(key), value); - return o == null ? null : (o instanceof String) ? Arrays.asList((String)o) : (List)o; + return o == null ? null : (o instanceof String s) ? List.of(s) : (List)o; } @Override public List remove(Object key) { Object o = map.put(normalize((String)key),null); - return o == null ? null : (o instanceof String) ? Arrays.asList((String)o) : (List)o; + return o == null ? null : (o instanceof String s) ? List.of(s) : (List)o; } @Override public String getFirst(String key) { Object o = map.get(normalize(key)); - return o == null ? null : (o instanceof String) ? (String)o : ((List)o).getFirst(); + return o == null ? null : (o instanceof String s) ? s : ((List)o).getFirst(); } /** @@ -91,8 +92,8 @@ public void add(String key, String value) { Object o = map.get(normalized); if (o == null) { map.put(normalized, value); - } else if(o instanceof String) { - map.put(normalized, Arrays.asList((String)o,value)); + } else if(o instanceof String s) { + map.put(normalized, new ArrayList(List.of(s,value))); } else { ((List)o).add(value); } @@ -146,6 +147,6 @@ public int hashCode() { @Override public void forEach(BiConsumer> action) { - map.forEach((k,v) -> action.accept(k, (v instanceof String) ? List.of((String)v) : (List)v)); + map.forEach((k,v) -> action.accept(k, (v instanceof String s) ? List.of(s) : (List)v)); } } \ No newline at end of file diff --git a/src/test/java/robaho/net/httpserver/RequestHeadersTest.java b/src/test/java/robaho/net/httpserver/RequestHeadersTest.java index 8efad4b..aa01d89 100644 --- a/src/test/java/robaho/net/httpserver/RequestHeadersTest.java +++ b/src/test/java/robaho/net/httpserver/RequestHeadersTest.java @@ -3,6 +3,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.List; import static org.testng.Assert.assertEquals; import static org.testng.Assert.assertTrue; @@ -63,4 +64,14 @@ public void TestWhitespace() throws IOException { assertEquals(r.headers().getFirst("KEY2"),"VAL2"); } + @Test + public void TestDuplicateHeaders() throws IOException { + String request = "GET blah\r\nKEY : VAL\r\nKEY:VAL2\r\nKEY:VAL3 \r\n\r\nSome Body Data"; + var is = new ByteArrayInputStream(request.getBytes()); + var os = new ByteArrayOutputStream(); + + Request r = new Request(is,os); + assertTrue("GET blah".contentEquals(r.requestLine())); + assertEquals(r.headers().get("KEY"), List.of("VAL", "VAL2", "VAL3")); + } } \ No newline at end of file From ca3f1cf8dc9a289779b76ed20a97fb3fbea58e20 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 13 Feb 2025 14:13:28 -0600 Subject: [PATCH 02/16] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1ab26e7..cbcce1e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # httpserver -Zero-dependency implementation of the JDK [`com.sun.net.httpserver.HttpServer` specification](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.httpserver/com/sun/net/httpserver/package-summary.html) with a few significant enhancements. +Zero-dependency implementation of the JDK `com.sun.net.httpserver.HttpServer` [specification](https://docs.oracle.com/en/java/javase/21/docs/api/jdk.httpserver/com/sun/net/httpserver/package-summary.html) with a few significant enhancements. - WebSocket support using modified source code from nanohttpd. - Server-side proxy support using [ProxyHandler](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/extras/ProxyHandler.java). (Tunneling proxies are also supported using CONNECT for https.) From 238b7edaf9fa77b1772f3f688bb506922e3357b9 Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 24 Mar 2025 10:11:52 -0500 Subject: [PATCH 03/16] fix issue [Bug] Request hangs if request body is not consumed #19 --- .../httpserver/FixedLengthOutputStream.java | 15 +- .../robaho/net/httpserver/HttpConnection.java | 9 + .../net/httpserver/LeftOverInputStream.java | 28 +-- .../robaho/net/httpserver/ServerImpl.java | 3 + .../net/httpserver/PipeliningStallTest.java | 175 ++++++++++++++++++ 5 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 src/test/java/robaho/net/httpserver/PipeliningStallTest.java diff --git a/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java b/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java index dcef65d..a4a844f 100644 --- a/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java +++ b/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java @@ -87,12 +87,7 @@ public void close() throws IOException { throw new IOException("insufficient bytes written to stream"); } LeftOverInputStream is = t.getOriginalInputStream(); - // if after reading the rest of the known input for this request, there is - // more input available, http pipelining is in effect, so avoid flush, since - // it will be flushed after processing the next request - if(is.getRawInputStream().available()==0) { - flush(); - } + if (!is.isClosed()) { try { @@ -100,5 +95,13 @@ public void close() throws IOException { } catch (IOException e) { } } + + // if after reading the rest of the known input for this request, there is + // more input available, http pipelining is in effect, so avoid flush, since + // it will be flushed after processing the next request + if(is.getRawInputStream().available()==0) { + flush(); + } + } } diff --git a/src/main/java/robaho/net/httpserver/HttpConnection.java b/src/main/java/robaho/net/httpserver/HttpConnection.java index f22cf58..c74e086 100644 --- a/src/main/java/robaho/net/httpserver/HttpConnection.java +++ b/src/main/java/robaho/net/httpserver/HttpConnection.java @@ -59,6 +59,7 @@ public class HttpConnection { volatile long lastActivityTime; volatile boolean noActivity; volatile boolean inRequest; + volatile long drainingAt; public AtomicLong requestCount = new AtomicLong(); private final String connectionId; @@ -124,6 +125,13 @@ synchronized void close() { if (socket.isClosed()) { return; } + try { + if (os!=null) { + // see issue #19, flush before closing, in case of pending data + os.flush(); + } + } catch(IOException ex){} + try { /* need to ensure temporary selectors are closed */ if (is != null) { @@ -134,6 +142,7 @@ synchronized void close() { } try { if (os != null) { + os.flush(); os.close(); } } catch (IOException e) { diff --git a/src/main/java/robaho/net/httpserver/LeftOverInputStream.java b/src/main/java/robaho/net/httpserver/LeftOverInputStream.java index 0ce99f3..7d5fc9c 100644 --- a/src/main/java/robaho/net/httpserver/LeftOverInputStream.java +++ b/src/main/java/robaho/net/httpserver/LeftOverInputStream.java @@ -98,20 +98,24 @@ public synchronized int read(byte[] b, int off, int len) throws IOException { * (still bytes to be read) */ public boolean drain(long l) throws IOException { - - while (l > 0) { - if (server.isFinishing()) { - break; - } - long len = readImpl(drainBuffer, 0, drainBuffer.length); - if (len == -1) { - eof = true; - return true; - } else { - l = l - len; + try { + while (l > 0) { + if (server.isFinishing()) { + break; + } + t.connection.drainingAt = ActivityTimer.now(); + long len = readImpl(drainBuffer, 0, drainBuffer.length); + if (len == -1) { + eof = true; + return true; + } else { + l = l - len; + } } + return false; + } finally { + t.connection.drainingAt = 0; } - return false; } public InputStream getRawInputStream() { return super.in; diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 0cf7fd9..458e65f 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -913,6 +913,9 @@ public void run() { long now = ActivityTimer.now(); for (var c : allConnections) { + if (c.drainingAt != 0 && now- c.drainingAt >= IDLE_INTERVAL / 2) { + closeConnection(c); + } if (now- c.lastActivityTime >= IDLE_INTERVAL && !c.inRequest) { logger.log(Level.DEBUG, "closing idle connection"); stats.idleCloseCount.incrementAndGet(); diff --git a/src/test/java/robaho/net/httpserver/PipeliningStallTest.java b/src/test/java/robaho/net/httpserver/PipeliningStallTest.java new file mode 100644 index 0000000..08164e1 --- /dev/null +++ b/src/test/java/robaho/net/httpserver/PipeliningStallTest.java @@ -0,0 +1,175 @@ +package robaho.net.httpserver; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import org.testng.annotations.Test; + +import static java.nio.charset.StandardCharsets.*; + +/** + * see issue #19 + * + * the server attempts to optimize flushing the response stream if there is + * another request in the pipeline, but the bug caused the server to assume the + * data remaining to be read was part of the next request, causing the server to + * hang. Reading even a single character from the request body would have + * prevented the issue since the buffer would have been filled. + * + * The solution is to read the remaining request data, then check if there are + * any characters waiting to be read. + */ +public class PipeliningStallTest { + + private static final int msgCode = 200; + private static final String someContext = "/context"; + + static class ServerThreadFactory implements ThreadFactory { + + static final AtomicLong tokens = new AtomicLong(); + + @Override + public Thread newThread(Runnable r) { + var thread = new Thread(r, "Server-" + tokens.incrementAndGet()); + thread.setDaemon(true); + return thread; + } + } + + static { + Logger.getLogger("").setLevel(Level.ALL); + Logger.getLogger("").getHandlers()[0].setLevel(Level.ALL); + } + + @Test + public void testSendResponse() throws Exception { + System.out.println("testSendResponse()"); + InetAddress loopback = InetAddress.getLoopbackAddress(); + HttpServer server = HttpServer.create(new InetSocketAddress(loopback, 0), 0); + ExecutorService executor = Executors.newCachedThreadPool(new ServerThreadFactory()); + server.setExecutor(executor); + try { + server.createContext(someContext, new HttpHandler() { + @Override + public void handle(HttpExchange exchange) throws IOException { + var length = exchange.getRequestHeaders().getFirst("Content-Length"); + + var msg = "hi"; + var status = 200; + if (Integer.valueOf(length) > 4) { + msg = "oversized"; + status = 413; + } + + var bytes = msg.getBytes(); + + // -1 means no content, 0 means unknown content length + var contentLength = bytes.length == 0 ? -1 : bytes.length; + + try (OutputStream os = exchange.getResponseBody()) { + exchange.sendResponseHeaders(status, contentLength); + os.write(bytes); + } + } + }); + server.start(); + System.out.println("Server started at port " + + server.getAddress().getPort()); + + runRawSocketHttpClient(loopback, server.getAddress().getPort(), -1); + } finally { + System.out.println("shutting server down"); + executor.shutdown(); + server.stop(0); + } + System.out.println("Server finished."); + } + + static void runRawSocketHttpClient(InetAddress address, int port, int contentLength) + throws Exception { + Socket socket = null; + PrintWriter writer = null; + BufferedReader reader = null; + final String CRLF = "\r\n"; + try { + socket = new Socket(address, port); + writer = new PrintWriter(new OutputStreamWriter( + socket.getOutputStream())); + System.out.println("Client connected by socket: " + socket); + String body = "I will send all the data."; + if (contentLength <= 0) { + contentLength = body.getBytes(UTF_8).length; + } + + writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); + writer.print("User-Agent: Java/" + + System.getProperty("java.version") + + CRLF); + writer.print("Host: " + address.getHostName() + CRLF); + writer.print("Accept: */*" + CRLF); + writer.print("Content-Length: " + contentLength + CRLF); + writer.print("Connection: keep-alive" + CRLF); + writer.print(CRLF); // Important, else the server will expect that + // there's more into the request. + writer.flush(); + System.out.println("Client wrote request to socket: " + socket); + writer.print(body); + writer.flush(); + + reader = new BufferedReader(new InputStreamReader( + socket.getInputStream())); + System.out.println("Client start reading from server:"); + String line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("\"" + line + "\""); + } + System.out.println("Client finished reading from server"); + } finally { + // give time to the server to try & drain its input stream + Thread.sleep(500); + // closes the client outputstream while the server is draining + // it + if (writer != null) { + writer.close(); + } + // give time to the server to trigger its assertion + // error before closing the connection + Thread.sleep(500); + if (reader != null) + try { + reader.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + if (socket != null) { + try { + socket.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + } + } + System.out.println("Client finished."); + } + +} From 08d56b54f64ab52c81ec5a2f3a9686101c8d68be Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 24 Mar 2025 11:11:48 -0500 Subject: [PATCH 04/16] some DRY with the test support client --- src/test/java/InputNotRead.java | 78 +--------------- src/test/java/jdk/test/lib/RawClient.java | 91 +++++++++++++++++++ .../net/httpserver/PipeliningStallTest.java | 80 +--------------- src/test/test_mains/MissingTrailingSpace.java | 73 +-------------- 4 files changed, 101 insertions(+), 221 deletions(-) create mode 100644 src/test/java/jdk/test/lib/RawClient.java diff --git a/src/test/java/InputNotRead.java b/src/test/java/InputNotRead.java index 5b67f8d..5844471 100644 --- a/src/test/java/InputNotRead.java +++ b/src/test/java/InputNotRead.java @@ -35,11 +35,8 @@ import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -55,6 +52,8 @@ import static java.nio.charset.StandardCharsets.*; +import jdk.test.lib.RawClient; + public class InputNotRead { private static final int msgCode = 200; @@ -106,7 +105,7 @@ public void handle(HttpExchange msg) throws IOException { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort(), -1); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(), someContext, "I will send all of the data", -1); } finally { System.out.println("shutting server down"); executor.shutdown(); @@ -148,7 +147,7 @@ public void handle(HttpExchange msg) throws IOException { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort(), 64 * 1024 + 16); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(), someContext, "send some data to trigger the output", 64 * 1024 + 16); } finally { System.out.println("shutting server down"); executor.shutdown(); @@ -157,74 +156,5 @@ public void handle(HttpExchange msg) throws IOException { System.out.println("Server finished."); } - static void runRawSocketHttpClient(InetAddress address, int port, int contentLength) - throws Exception - { - Socket socket = null; - PrintWriter writer = null; - BufferedReader reader = null; - final String CRLF = "\r\n"; - try { - socket = new Socket(address, port); - writer = new PrintWriter(new OutputStreamWriter( - socket.getOutputStream())); - System.out.println("Client connected by socket: " + socket); - String body = "I will send all the data."; - if (contentLength <= 0) - contentLength = body.getBytes(UTF_8).length; - - writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); - writer.print("User-Agent: Java/" - + System.getProperty("java.version") - + CRLF); - writer.print("Host: " + address.getHostName() + CRLF); - writer.print("Accept: */*" + CRLF); - writer.print("Content-Length: " + contentLength + CRLF); - writer.print("Connection: keep-alive" + CRLF); - writer.print(CRLF); // Important, else the server will expect that - // there's more into the request. - writer.flush(); - System.out.println("Client wrote request to socket: " + socket); - writer.print(body); - writer.flush(); - - reader = new BufferedReader(new InputStreamReader( - socket.getInputStream())); - System.out.println("Client start reading from server:" ); - String line = reader.readLine(); - for (; line != null; line = reader.readLine()) { - if (line.isEmpty()) { - break; - } - System.out.println("\"" + line + "\""); - } - System.out.println("Client finished reading from server" ); - } finally { - // give time to the server to try & drain its input stream - Thread.sleep(500); - // closes the client outputstream while the server is draining - // it - if (writer != null) { - writer.close(); - } - // give time to the server to trigger its assertion - // error before closing the connection - Thread.sleep(500); - if (reader != null) - try { - reader.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - if (socket != null) { - try { - socket.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - } - } - System.out.println("Client finished." ); - } } diff --git a/src/test/java/jdk/test/lib/RawClient.java b/src/test/java/jdk/test/lib/RawClient.java new file mode 100644 index 0000000..6254309 --- /dev/null +++ b/src/test/java/jdk/test/lib/RawClient.java @@ -0,0 +1,91 @@ +package jdk.test.lib; + +import java.io.*; +import java.net.*; +import static java.nio.charset.StandardCharsets.UTF_8; + +public class RawClient { + + /** + * performs an HTTP request using a raw socket. + * + * @param address the server address + * @param port the server port + * @param context the server context (i.e. endpoint) + * @param data the data to send in the request body + * @param contentLength the content length, if > 0, this will be used as the + * Content-length header value which can be used to simulate the client + * advertising more data than it sends. in almost all cases this parameter + * should be 0, upon which the actual length of the data is used. + * @throws Exception + */ + public static void runRawSocketHttpClient(InetAddress address, int port, String context, String data, int contentLength) + throws Exception { + Socket socket = null; + PrintWriter writer = null; + BufferedReader reader = null; + final String CRLF = "\r\n"; + try { + socket = new Socket(address, port); + writer = new PrintWriter(new OutputStreamWriter( + socket.getOutputStream())); + System.out.println("Client connected by socket: " + socket); + String body = data == null ? "" : data; + if (contentLength <= 0) { + contentLength = body.getBytes(UTF_8).length; + } + + writer.print("GET " + context + "/ HTTP/1.1" + CRLF); + writer.print("User-Agent: Java/" + + System.getProperty("java.version") + + CRLF); + writer.print("Host: " + address.getHostName() + CRLF); + writer.print("Accept: */*" + CRLF); + writer.print("Content-Length: " + contentLength + CRLF); + writer.print("Connection: keep-alive" + CRLF); + writer.print(CRLF); // Important, else the server will expect that + // there's more into the request. + writer.flush(); + System.out.println("Client wrote request to socket: " + socket); + writer.print(body); + writer.flush(); + + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + System.out.println("Client start reading from server:"); + String line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("\"" + line + "\""); + } + System.out.println("Client finished reading from server"); + } finally { + // give time to the server to try & drain its input stream + Thread.sleep(500); + // closes the client outputstream while the server is draining + // it + if (writer != null) { + writer.close(); + } + // give time to the server to trigger its assertion + // error before closing the connection + Thread.sleep(500); + if (reader != null) + try { + reader.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + if (socket != null) { + try { + socket.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + } + } + System.out.println("Client finished."); + } + +} diff --git a/src/test/java/robaho/net/httpserver/PipeliningStallTest.java b/src/test/java/robaho/net/httpserver/PipeliningStallTest.java index 08164e1..9ca2d3a 100644 --- a/src/test/java/robaho/net/httpserver/PipeliningStallTest.java +++ b/src/test/java/robaho/net/httpserver/PipeliningStallTest.java @@ -1,14 +1,9 @@ package robaho.net.httpserver; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStreamReader; import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; @@ -22,7 +17,7 @@ import org.testng.annotations.Test; -import static java.nio.charset.StandardCharsets.*; +import jdk.test.lib.RawClient; /** * see issue #19 @@ -93,7 +88,7 @@ public void handle(HttpExchange exchange) throws IOException { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort(), -1); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(),someContext,"I will send all of the data", -1); } finally { System.out.println("shutting server down"); executor.shutdown(); @@ -101,75 +96,4 @@ public void handle(HttpExchange exchange) throws IOException { } System.out.println("Server finished."); } - - static void runRawSocketHttpClient(InetAddress address, int port, int contentLength) - throws Exception { - Socket socket = null; - PrintWriter writer = null; - BufferedReader reader = null; - final String CRLF = "\r\n"; - try { - socket = new Socket(address, port); - writer = new PrintWriter(new OutputStreamWriter( - socket.getOutputStream())); - System.out.println("Client connected by socket: " + socket); - String body = "I will send all the data."; - if (contentLength <= 0) { - contentLength = body.getBytes(UTF_8).length; - } - - writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); - writer.print("User-Agent: Java/" - + System.getProperty("java.version") - + CRLF); - writer.print("Host: " + address.getHostName() + CRLF); - writer.print("Accept: */*" + CRLF); - writer.print("Content-Length: " + contentLength + CRLF); - writer.print("Connection: keep-alive" + CRLF); - writer.print(CRLF); // Important, else the server will expect that - // there's more into the request. - writer.flush(); - System.out.println("Client wrote request to socket: " + socket); - writer.print(body); - writer.flush(); - - reader = new BufferedReader(new InputStreamReader( - socket.getInputStream())); - System.out.println("Client start reading from server:"); - String line = reader.readLine(); - for (; line != null; line = reader.readLine()) { - if (line.isEmpty()) { - break; - } - System.out.println("\"" + line + "\""); - } - System.out.println("Client finished reading from server"); - } finally { - // give time to the server to try & drain its input stream - Thread.sleep(500); - // closes the client outputstream while the server is draining - // it - if (writer != null) { - writer.close(); - } - // give time to the server to trigger its assertion - // error before closing the connection - Thread.sleep(500); - if (reader != null) - try { - reader.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - if (socket != null) { - try { - socket.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - } - } - System.out.println("Client finished."); - } - } diff --git a/src/test/test_mains/MissingTrailingSpace.java b/src/test/test_mains/MissingTrailingSpace.java index 6671105..6cc1a5b 100644 --- a/src/test/test_mains/MissingTrailingSpace.java +++ b/src/test/test_mains/MissingTrailingSpace.java @@ -32,18 +32,16 @@ import java.net.InetAddress; import java.net.InetSocketAddress; -import java.io.InputStreamReader; import java.io.IOException; -import java.io.BufferedReader; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.net.Socket; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; + import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; +import jdk.test.lib.RawClient; + public class MissingTrailingSpace { private static final int noMsgCode = 207; @@ -72,7 +70,7 @@ public void handle(HttpExchange msg) { System.out.println("Server started at port " + server.getAddress().getPort()); - runRawSocketHttpClient(loopback, server.getAddress().getPort()); + RawClient.runRawSocketHttpClient(loopback, server.getAddress().getPort(),someContext,"", -1); } finally { ((ExecutorService)server.getExecutor()).shutdown(); server.stop(0); @@ -80,67 +78,4 @@ public void handle(HttpExchange msg) { System.out.println("Server finished."); } - static void runRawSocketHttpClient(InetAddress address, int port) - throws Exception - { - Socket socket = null; - PrintWriter writer = null; - BufferedReader reader = null; - final String CRLF = "\r\n"; - try { - socket = new Socket(address, port); - writer = new PrintWriter(new OutputStreamWriter( - socket.getOutputStream())); - System.out.println("Client connected by socket: " + socket); - - writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); - writer.print("User-Agent: Java/" - + System.getProperty("java.version") - + CRLF); - writer.print("Host: " + address.getHostName() + CRLF); - writer.print("Accept: */*" + CRLF); - writer.print("Connection: keep-alive" + CRLF); - writer.print(CRLF); // Important, else the server will expect that - // there's more into the request. - writer.flush(); - System.out.println("Client wrote rquest to socket: " + socket); - - reader = new BufferedReader(new InputStreamReader( - socket.getInputStream())); - System.out.println("Client start reading from server:" ); - String line = reader.readLine(); - if ( !line.endsWith(" ") ) { - throw new RuntimeException("respond to unknown code " - + noMsgCode - + " doesn't return space at the end of the first header.\n" - + "Should be: " + "\"" + line + " \"" - + ", but returns: " + "\"" + line + "\"."); - } - for (; line != null; line = reader.readLine()) { - if (line.isEmpty()) { - break; - } - System.out.println("\"" + line + "\""); - } - System.out.println("Client finished reading from server" ); - } finally { - if (reader != null) - try { - reader.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - if (writer != null) { - writer.close(); - } - if (socket != null) { - try { - socket.close(); - } catch (IOException logOrIgnore) { - logOrIgnore.printStackTrace(); - } - } - } - System.out.println("Client finished." ); - } } From 2053ff28e50af0191fb9684dc40181d7a33eb8ef Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 4 Apr 2025 10:24:21 -0500 Subject: [PATCH 05/16] possible fix for issue #21, protect against NPE --- .../java/robaho/net/httpserver/NoSyncBufferedInputStream.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java index 33c1da1..b99e5aa 100644 --- a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java +++ b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java @@ -83,7 +83,7 @@ public NoSyncBufferedInputStream(InputStream in) { private void fill() throws IOException { pos = 0; count = 0; - int n = getInIfOpen().read(buf); + int n = getInIfOpen().read(getBufIfOpen()); if (n > 0) count = n; } From affadb705556feef5453c14eccd13800705d2ddc Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 4 Apr 2025 10:24:21 -0500 Subject: [PATCH 06/16] possible fix for issue #21, protect against NPE --- .../java/robaho/net/httpserver/NoSyncBufferedInputStream.java | 2 +- src/main/java/robaho/net/httpserver/ServerImpl.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java index 33c1da1..b99e5aa 100644 --- a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java +++ b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java @@ -83,7 +83,7 @@ public NoSyncBufferedInputStream(InputStream in) { private void fill() throws IOException { pos = 0; count = 0; - int n = getInIfOpen().read(buf); + int n = getInIfOpen().read(getBufIfOpen()); if (n > 0) count = n; } diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 458e65f..0f3f14a 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -621,7 +621,7 @@ public void run() { logger.log(Level.TRACE, () -> "exchange started "+connection.toString()); - while (true) { + while (!connection.closed) { try { runPerRequest(); if (connection.closed) { From e7d678c86e59feccadc451f2d56b8622480fffa8 Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 4 Jun 2025 12:45:56 -0500 Subject: [PATCH 07/16] fix issue Dispatcher thread stuck in SSL Handshake, httpserver not anymore responsive #23 --- .../robaho/net/httpserver/ServerImpl.java | 134 +++++++++--------- 1 file changed, 68 insertions(+), 66 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 299b6ee..379e1b6 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -342,82 +342,84 @@ public void run() { while (true) { try { Socket s = socket.accept(); - if(logger.isLoggable(Level.TRACE)) { - logger.log(Level.TRACE, "accepted connection: " + s.toString()); - } - stats.connectionCount.incrementAndGet(); - if (MAX_CONNECTIONS > 0 && allConnections.size() >= MAX_CONNECTIONS) { - // we've hit max limit of current open connections, so we go - // ahead and close this connection without processing it + executor.execute(() -> { try { - stats.maxConnectionsExceededCount.incrementAndGet(); - logger.log(Level.WARNING, "closing accepted connection due to too many connections"); - s.close(); - } catch (IOException ignore) { + acceptConnection(s); + } catch (IOException t) { + logger.log(Level.ERROR, "Dispatcher Exception", t); } - continue; - } - - if (ServerConfig.noDelay()) { - s.setTcpNoDelay(true); + }); + } catch (IOException e) { + if (!isFinishing()) { + logger.log(Level.ERROR, "Dispatcher Exception, terminating", e); } + return; + } + } + } + private void acceptConnection(Socket s) throws IOException { + if(logger.isLoggable(Level.TRACE)) { + logger.log(Level.TRACE, "accepted connection: " + s.toString()); + } + stats.connectionCount.incrementAndGet(); + if (MAX_CONNECTIONS > 0 && allConnections.size() >= MAX_CONNECTIONS) { + // we've hit max limit of current open connections, so we go + // ahead and close this connection without processing it + try { + stats.maxConnectionsExceededCount.incrementAndGet(); + logger.log(Level.WARNING, "closing accepted connection due to too many connections"); + s.close(); + } catch (IOException ignore) { + } + return; + } - boolean http2 = false; + if (ServerConfig.noDelay()) { + s.setTcpNoDelay(true); + } - if (https) { - // for some reason, creating an SSLServerSocket and setting the default parameters would - // not work, so upgrade to a SSLSocket after connection - SSLSocketFactory ssf = httpsConfig.getSSLContext().getSocketFactory(); - SSLSocket sslSocket = (SSLSocket) ssf.createSocket(s, null, false); - SSLConfigurator.configure(sslSocket,httpsConfig); + boolean http2 = false; - sslSocket.setHandshakeApplicationProtocolSelector((_sslSocket, protocols) -> { - if (protocols.contains("h2") && ServerConfig.http2OverSSL()) { - return "h2"; - } else { - return "http/1.1"; - } - }); - // the following forces the SSL handshake to complete in order to determine the negotiated protocol - var session = sslSocket.getSession(); - if ("h2".equals(sslSocket.getApplicationProtocol())) { - logger.log(Level.DEBUG, () -> "http2 connection "+sslSocket.toString()); - http2 = true; - } else { - logger.log(Level.DEBUG, () -> "http/1.1 connection "+sslSocket.toString()); - } - s = sslSocket; + if (https) { + // for some reason, creating an SSLServerSocket and setting the default parameters would + // not work, so upgrade to a SSLSocket after connection + SSLSocketFactory ssf = httpsConfig.getSSLContext().getSocketFactory(); + SSLSocket sslSocket = (SSLSocket) ssf.createSocket(s, null, false); + SSLConfigurator.configure(sslSocket,httpsConfig); + + sslSocket.setHandshakeApplicationProtocolSelector((_sslSocket, protocols) -> { + if (protocols.contains("h2") && ServerConfig.http2OverSSL()) { + return "h2"; + } else { + return "http/1.1"; } + }); + // the following forces the SSL handshake to complete in order to determine the negotiated protocol + var session = sslSocket.getSession(); + if ("h2".equals(sslSocket.getApplicationProtocol())) { + logger.log(Level.DEBUG, () -> "http2 connection "+sslSocket.toString()); + http2 = true; + } else { + logger.log(Level.DEBUG, () -> "http/1.1 connection "+sslSocket.toString()); + } + s = sslSocket; + } - HttpConnection c; - try { - c = new HttpConnection(s); - } catch (IOException e) { - logger.log(Level.WARNING, "Failed to create HttpConnection", e); - continue; - } - try { - allConnections.add(c); - - if (http2) { - Http2Exchange t = new Http2Exchange(protocol, c); - executor.execute(t); - } else { - Exchange t = new Exchange(protocol, c); - executor.execute(t); - } + HttpConnection c = new HttpConnection(s); + try { + allConnections.add(c); - } catch (Exception e) { - logger.log(Level.TRACE, "Dispatcher Exception", e); - stats.handleExceptionCount.incrementAndGet(); - closeConnection(c); - } - } catch (IOException e) { - if (!isFinishing()) { - logger.log(Level.ERROR, "Dispatcher Exception, terminating", e); - } - return; + if (http2) { + Http2Exchange t = new Http2Exchange(protocol, c); + executor.execute(t); + } else { + Exchange t = new Exchange(protocol, c); + executor.execute(t); } + } catch (Exception e) { + logger.log(Level.TRACE, "Dispatcher Exception", e); + stats.handleExceptionCount.incrementAndGet(); + closeConnection(c); } } } From 3e46173aa1ac51b5ba9d3d441ce3250e9ba1e2ae Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 4 Jun 2025 12:45:56 -0500 Subject: [PATCH 08/16] fix issue Dispatcher thread stuck in SSL Handshake, httpserver not anymore responsive #23 --- .../robaho/net/httpserver/ServerImpl.java | 141 ++++++++++-------- 1 file changed, 76 insertions(+), 65 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 299b6ee..0cabe07 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -50,6 +50,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.logging.LogRecord; import javax.net.ssl.SSLSocket; @@ -342,82 +343,92 @@ public void run() { while (true) { try { Socket s = socket.accept(); - if(logger.isLoggable(Level.TRACE)) { - logger.log(Level.TRACE, "accepted connection: " + s.toString()); - } - stats.connectionCount.incrementAndGet(); - if (MAX_CONNECTIONS > 0 && allConnections.size() >= MAX_CONNECTIONS) { - // we've hit max limit of current open connections, so we go - // ahead and close this connection without processing it + try { + executor.execute(() -> { try { - stats.maxConnectionsExceededCount.incrementAndGet(); - logger.log(Level.WARNING, "closing accepted connection due to too many connections"); - s.close(); - } catch (IOException ignore) { + acceptConnection(s); + } catch (IOException t) { + logger.log(Level.ERROR, "Dispatcher Exception", t); + try { + s.close(); + } catch (IOException ex) { + } } - continue; + }); + } catch (RejectedExecutionException e) { + s.close(); } - - if (ServerConfig.noDelay()) { - s.setTcpNoDelay(true); + } catch (IOException e) { + if (!isFinishing()) { + logger.log(Level.ERROR, "Dispatcher Exception, terminating", e); } + return; + } + } + } + private void acceptConnection(Socket s) throws IOException { + if(logger.isLoggable(Level.TRACE)) { + logger.log(Level.TRACE, "accepted connection: " + s.toString()); + } + stats.connectionCount.incrementAndGet(); + if (MAX_CONNECTIONS > 0 && allConnections.size() >= MAX_CONNECTIONS) { + // we've hit max limit of current open connections, so we go + // ahead and close this connection without processing it + try { + stats.maxConnectionsExceededCount.incrementAndGet(); + logger.log(Level.WARNING, "closing accepted connection due to too many connections"); + s.close(); + } catch (IOException ignore) { + } + return; + } - boolean http2 = false; + if (ServerConfig.noDelay()) { + s.setTcpNoDelay(true); + } - if (https) { - // for some reason, creating an SSLServerSocket and setting the default parameters would - // not work, so upgrade to a SSLSocket after connection - SSLSocketFactory ssf = httpsConfig.getSSLContext().getSocketFactory(); - SSLSocket sslSocket = (SSLSocket) ssf.createSocket(s, null, false); - SSLConfigurator.configure(sslSocket,httpsConfig); + boolean http2 = false; - sslSocket.setHandshakeApplicationProtocolSelector((_sslSocket, protocols) -> { - if (protocols.contains("h2") && ServerConfig.http2OverSSL()) { - return "h2"; - } else { - return "http/1.1"; - } - }); - // the following forces the SSL handshake to complete in order to determine the negotiated protocol - var session = sslSocket.getSession(); - if ("h2".equals(sslSocket.getApplicationProtocol())) { - logger.log(Level.DEBUG, () -> "http2 connection "+sslSocket.toString()); - http2 = true; - } else { - logger.log(Level.DEBUG, () -> "http/1.1 connection "+sslSocket.toString()); - } - s = sslSocket; + if (https) { + // for some reason, creating an SSLServerSocket and setting the default parameters would + // not work, so upgrade to a SSLSocket after connection + SSLSocketFactory ssf = httpsConfig.getSSLContext().getSocketFactory(); + SSLSocket sslSocket = (SSLSocket) ssf.createSocket(s, null, false); + SSLConfigurator.configure(sslSocket,httpsConfig); + + sslSocket.setHandshakeApplicationProtocolSelector((_sslSocket, protocols) -> { + if (protocols.contains("h2") && ServerConfig.http2OverSSL()) { + return "h2"; + } else { + return "http/1.1"; } + }); + // the following forces the SSL handshake to complete in order to determine the negotiated protocol + var session = sslSocket.getSession(); + if ("h2".equals(sslSocket.getApplicationProtocol())) { + logger.log(Level.DEBUG, () -> "http2 connection "+sslSocket.toString()); + http2 = true; + } else { + logger.log(Level.DEBUG, () -> "http/1.1 connection "+sslSocket.toString()); + } + s = sslSocket; + } - HttpConnection c; - try { - c = new HttpConnection(s); - } catch (IOException e) { - logger.log(Level.WARNING, "Failed to create HttpConnection", e); - continue; - } - try { - allConnections.add(c); - - if (http2) { - Http2Exchange t = new Http2Exchange(protocol, c); - executor.execute(t); - } else { - Exchange t = new Exchange(protocol, c); - executor.execute(t); - } + HttpConnection c = new HttpConnection(s); + try { + allConnections.add(c); - } catch (Exception e) { - logger.log(Level.TRACE, "Dispatcher Exception", e); - stats.handleExceptionCount.incrementAndGet(); - closeConnection(c); - } - } catch (IOException e) { - if (!isFinishing()) { - logger.log(Level.ERROR, "Dispatcher Exception, terminating", e); - } - return; + if (http2) { + Http2Exchange t = new Http2Exchange(protocol, c); + executor.execute(t); + } else { + Exchange t = new Exchange(protocol, c); + executor.execute(t); } + } catch (Exception e) { + logger.log(Level.TRACE, "Dispatcher Exception", e); + stats.handleExceptionCount.incrementAndGet(); + closeConnection(c); } } } From c4ca5534b2d52d61a1a1688ca5a2472a59669814 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 5 Jun 2025 14:37:03 -0500 Subject: [PATCH 09/16] change to publish to Central Publishing Portal --- build.gradle | 49 +++++++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/build.gradle b/build.gradle index 1f39938..f9d8909 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,8 @@ plugins { - id 'java-library' id 'maven-publish' id 'signing' + id 'java-library' + id 'tech.yanand.maven-central-publish' version '1.3.0' } repositories { @@ -29,7 +30,7 @@ tasks.withType(Test) { systemProperty("robaho.net.httpserver.http2OverNonSSL","true") // systemProperty("robaho.net.httpserver.http2MaxConcurrentStreams","5000") // systemProperty("robaho.net.httpserver.http2DisableFlushDelay","true") - systemProperty("robaho.net.httpserver.http2OverSSL","true") + // systemProperty("robaho.net.httpserver.http2OverSSL","true") systemProperty("robaho.net.httpserver.http2OverNonSSL","true") // systemProperty("javax.net.debug","ssl:handshake:verbose:keymanager:trustmanager") } @@ -38,7 +39,7 @@ tasks.withType(JavaExec) { jvmArgs += "--enable-preview" systemProperty("java.util.logging.config.file","logging.properties") systemProperty("com.sun.net.httpserver.HttpServerProvider","robaho.net.httpserver.DefaultHttpServerProvider") - systemProperty("robaho.net.httpserver.http2OverSSL","true") + // systemProperty("robaho.net.httpserver.http2OverSSL","true") systemProperty("robaho.net.httpserver.http2OverNonSSL","true") systemProperty("robaho.net.httpserver.http2InitialWindowSize","1024000") systemProperty("robaho.net.httpserver.http2ConnectionWindowSize","1024000000") @@ -72,14 +73,14 @@ sourceSets { test { java { srcDirs = [ - 'src/test/extras', - 'src/test/java', - 'src/test/java_default/bugs', - 'src/test/java_default/HttpExchange' + 'src/test/extras', + 'src/test/java', + 'src/test/java_default/bugs', + 'src/test/java_default/HttpExchange' ] } } - testMains { + create('testMains') { java { srcDirs = ['src/test/test_mains'] compileClasspath = test.output + main.output + configurations.testMainsCompile @@ -132,7 +133,7 @@ task runSingleUnitTest(type: Test) { outputs.upToDateWhen { false } dependsOn testClasses filter { - includeTestsMatching 'InputNotRead' + includeTestsMatching 'PipeliningStallTest' } useTestNG() } @@ -181,8 +182,9 @@ task runSimpleFileServer(type: JavaExec) { dependsOn testClasses classpath sourceSets.test.runtimeClasspath main "SimpleFileServer" - args = ['fileserver','8080','fileserver/logfile.txt'] - javaLauncher = javaToolchains.launcherFor { + args = ['fileserver','443','fileserver/logfile.txt'] + // args = ['fileserver','8080','fileserver/logfile.txt'] + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(23) } // debugOptions { @@ -203,7 +205,7 @@ task runAllTests(type: Test) { } publish { - dependsOn runAllTests + // dependsOn runAllTests } publishing { @@ -254,14 +256,17 @@ publishing { } } } - repositories { - maven { - name = "OSSRH" - url = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/" - credentials { - username = "$maven_user" - password = "$maven_password" - } - } - } +} + +mavenCentral { + def tokenString = "${maven_user}:${maven_password}" + def token = tokenString.bytes.encodeBase64().toString() + authToken = token + // Whether the upload should be automatically published or not. Use 'USER_MANAGED' if you wish to do this manually. + // This property is optional and defaults to 'AUTOMATIC'. + publishingType = 'AUTOMATIC' + // Max wait time for status API to get 'PUBLISHING' or 'PUBLISHED' status when the publishing type is 'AUTOMATIC', + // or additionally 'VALIDATED' when the publishing type is 'USER_MANAGED'. + // This property is optional and defaults to 60 seconds. + maxWait = 60 } From 85dd5e7c8c9fac0c4fc74a67b68304c5442e26e2 Mon Sep 17 00:00:00 2001 From: robert engels Date: Sun, 8 Jun 2025 18:21:38 -0500 Subject: [PATCH 10/16] fix issue #24 - performance regression --- src/main/java/robaho/net/httpserver/ServerImpl.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 825fb33..77ea234 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -420,13 +420,13 @@ private void acceptConnection(Socket s) throws IOException { if (http2) { Http2Exchange t = new Http2Exchange(protocol, c); - executor.execute(t); + t.run(); } else { Exchange t = new Exchange(protocol, c); - executor.execute(t); + t.run(); } - } catch (Exception e) { - logger.log(Level.TRACE, "Dispatcher Exception", e); + } catch (Throwable t) { + logger.log(Level.WARNING, "Dispatcher Exception", t); stats.handleExceptionCount.incrementAndGet(); closeConnection(c); } From bc8161b953c47606deddf61a2ae919ddc12b2ef9 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Wed, 3 Sep 2025 00:46:03 -0400 Subject: [PATCH 11/16] Update README.md with websocket (#27) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cbcce1e..a097a9e 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ There is a [simple file server](https://github.com/robaho/httpserver/blob/727759 gradle runSimpleFileServer ``` +## Websockets + +For websocket usage, see the examples in the [websocket testing folder](https://github.com/robaho/httpserver/tree/main/src/test/java/robaho/net/httpserver/websockets). + ## logging All logging is performed using the [Java System Logger](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/System.Logger.html) From 2c5da6ef7afd36e68c64a8087740727c7ba6da42 Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 2 Sep 2025 23:48:12 -0500 Subject: [PATCH 12/16] Update README.md --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a097a9e..84a36d2 100644 --- a/README.md +++ b/README.md @@ -94,10 +94,17 @@ There is a [simple file server](https://github.com/robaho/httpserver/blob/727759 gradle runSimpleFileServer ``` -## Websockets +## websockets For websocket usage, see the examples in the [websocket testing folder](https://github.com/robaho/httpserver/tree/main/src/test/java/robaho/net/httpserver/websockets). +In general, create a handler that extends WebSocketHandler, and add an endpoint for the handler: + +``` + HttpHandler h = new EchoWebSocketHandler(); + HttpContext c = server.createContext(path, h); +``` + ## logging All logging is performed using the [Java System Logger](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/System.Logger.html) From 92e1d728d39a0235d4bba76ab1eb689ea87b03a2 Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 2 Sep 2025 23:51:39 -0500 Subject: [PATCH 13/16] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 84a36d2..27f4b6a 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,11 @@ In general, create a handler that extends WebSocketHandler, and add an endpoint ``` HttpHandler h = new EchoWebSocketHandler(); - HttpContext c = server.createContext(path, h); + HttpContext c = server.createContext("/ws", h); ``` +The low-level websocket api is [nanohttpd](https://github.com/NanoHttpd/nanohttpd) so there are many examples on the web. + ## logging All logging is performed using the [Java System Logger](https://docs.oracle.com/en/java/javase/19/docs/api/java.base/java/lang/System.Logger.html) From 708d96ea6f458e7c5796ad267f96030418c6c663 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Mon, 22 Sep 2025 13:38:03 -0400 Subject: [PATCH 14/16] Get Content Type From Multipart (#30) * get content type from multipart * fix PR. add test cases --------- Co-authored-by: robert engels --- .gitignore | 5 +++++ .../net/httpserver/extras/MultipartFormParser.java | 11 +++++++---- .../httpserver/extras/MultipartFormParserTest.java | 10 ++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 1f9231f..c4d762b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ fileserver/ gradlew.bat gradle/ gradle/ +/target/ +.classpath +.factorypath +*.prefs +.project diff --git a/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java b/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java index 8d6f169..815868d 100644 --- a/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java +++ b/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java @@ -38,7 +38,7 @@ public record Part(String contentType, String filename, String data, File file) } - private record PartMetadata(String name, String filename) { + private record PartMetadata(String contentType, String name, String filename) { } @@ -120,12 +120,12 @@ public static Map> parse(String encoding, String content_type if (meta.filename == null) { var bos = new ByteArrayOutputStream(); os = bos; - addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(null, null, bos.toString(charset), null)); + addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(meta.contentType, null, bos.toString(charset), null)); } else { File file = Path.of(storage.toString(), meta.filename).toFile(); file.deleteOnExit(); os = new NoSyncBufferedOutputStream(new FileOutputStream(file)); - addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(null, meta.filename, null, file)); + addToResults = () -> results.computeIfAbsent(meta.name, k -> new LinkedList()).add(new Part(meta.contentType, meta.filename, null, file)); } try (os) { @@ -170,6 +170,7 @@ public static Map> parse(String encoding, String content_type private static PartMetadata parseHeaders(List headers) { String name = null; String filename = null; + String contentType = null; for (var header : headers) { String[] parts = header.split(":", 2); if ("content-disposition".equalsIgnoreCase(parts[0])) { @@ -188,9 +189,11 @@ private static PartMetadata parseHeaders(List headers) { } } + } else if ("content-type".equalsIgnoreCase(parts[0])) { + contentType = parts[1].trim(); } } - return new PartMetadata(name, filename); + return new PartMetadata(contentType, name, filename); } private static String readLine(Charset charset, InputStream is) throws IOException { diff --git a/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java b/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java index 0a399e8..12b065d 100644 --- a/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java +++ b/src/test/java/robaho/net/httpserver/extras/MultipartFormParserTest.java @@ -52,13 +52,16 @@ public void testFiles() throws UnsupportedEncodingException, IOException { s += "111Y\r\n"; s += "111Z\rCCCC\nCCCC\r\nCCCCC@\r\n"; + Assert.assertEquals(values.get(0).contentType(),"text/plain"); Assert.assertEquals(s.getBytes("UTF-8"), Files.readAllBytes((values.get(0).file()).toPath()), "file1 failed"); + s = "\r\n"; s += "@22X"; s += "222Y\r\n"; s += "222Z\r222W\n2220\r\n666@"; + Assert.assertEquals(values.get(1).contentType(),"text/plain"); Assert.assertEquals(s.getBytes("UTF-8"), Files.readAllBytes((values.get(1).file()).toPath()), "file2 failed"); } @@ -118,6 +121,7 @@ public void testFormSample() throws IOException { Assert.assertEquals(results.size(), 1); List values = results.get("myfile"); + Assert.assertEquals(values.get(0).contentType(), "text/plain"); Assert.assertEquals(values.size(), 1); } @@ -133,6 +137,12 @@ public void testMultiFileFormSample() throws IOException { Assert.assertEquals(results.size(), 2); List values = results.get("myfile"); + Assert.assertEquals(values.get(0).contentType(), "text/plain"); + Assert.assertEquals(values.size(), 1); + + values = results.get("myfile2"); + Assert.assertEquals(values.get(0).contentType(), "image/png"); Assert.assertEquals(values.size(), 1); + } } From 66dae750d9b01c660f43fb7a5174df290986cb2d Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 22 Sep 2025 12:47:52 -0500 Subject: [PATCH 15/16] change multipart form parser to use Logger --- .../net/httpserver/extras/MultipartFormParser.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java b/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java index 815868d..def4b3b 100644 --- a/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java +++ b/src/main/java/robaho/net/httpserver/extras/MultipartFormParser.java @@ -14,6 +14,7 @@ import java.util.LinkedList; import java.util.List; import java.util.Map; +import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -23,6 +24,8 @@ * parse multipart form data */ public class MultipartFormParser { + static final Logger logger = Logger.getLogger("robaho.net.httpserver.MultipartFormParser"); + /** * a multipart part. * @@ -66,7 +69,8 @@ public static Map> parse(String encoding, String content_type List headers = new LinkedList<>(); - System.out.println("reading until start of part"); + logger.finer(() -> "reading multipart form data with boundary '%s'".formatted(boundary)); + // read until boundary found int matchCount = 2; // starting at 2 allows matching non-compliant senders. rfc says CRLF is part of // boundary marker @@ -78,7 +82,6 @@ public static Map> parse(String encoding, String content_type if (c == boundaryCheck[matchCount]) { matchCount++; if (matchCount == boundaryCheck.length - 2) { - System.out.println("found boundary marker"); break; } } else { @@ -99,7 +102,7 @@ public static Map> parse(String encoding, String content_type while (true) { // read part headers until blank line - System.out.println("reading part headers"); + while (true) { s = readLine(charset, is); if (s == null) { @@ -111,7 +114,6 @@ public static Map> parse(String encoding, String content_type headers.add(s); } - System.out.println("reading part data"); // read part data - need to detect end of part PartMetadata meta = parseHeaders(headers); @@ -138,7 +140,6 @@ public static Map> parse(String encoding, String content_type if (c == boundaryCheck[matchCount]) { matchCount++; if (matchCount == boundaryCheck.length) { - System.out.println("found boundary marker"); break; } } else { From 7ed4d2629d06a50adca705fd00d1ff45cb722717 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Wed, 8 Oct 2025 18:48:38 -0400 Subject: [PATCH 16/16] Support 1xx codes (#31) * support 1xx codes * Create InputRead100Test.java * no contentlength header * update build for gradle 9.1.0 * test 100 Continue to Expect header * handle manual 1xx responses including make connection upgrade generic rather than websocket specific * update comments --------- Co-authored-by: robert engels --- build.gradle | 43 ++--- src/main/java/robaho/net/httpserver/Code.java | 3 + .../robaho/net/httpserver/ExchangeImpl.java | 70 ++++---- .../robaho/net/httpserver/ServerImpl.java | 9 +- src/test/java/InputRead100Test.java | 151 ++++++++++++++++++ 5 files changed, 215 insertions(+), 61 deletions(-) create mode 100644 src/test/java/InputRead100Test.java diff --git a/build.gradle b/build.gradle index f9d8909..e47b7dc 100644 --- a/build.gradle +++ b/build.gradle @@ -92,22 +92,15 @@ sourceSets { } } -def getGitVersion () { - def output = new ByteArrayOutputStream() - exec { - commandLine 'git', 'rev-list', '--tags', '--max-count=1' - standardOutput = output - } - def revision = output.toString().trim() - output.reset() - exec { - commandLine 'git', 'describe', '--tags', revision - standardOutput = output - } - return output.toString().trim() -} +// Use a lazy Provider to get the git version. This is the modern, configuration-cache-friendly approach. +def gitVersionProvider = project.providers.exec { + // 1. Describe the latest revision with a tag + commandLine = ['git', 'describe', '--tags', '--always'] + ignoreExitValue = true // Don't fail the build if git fails (e.g., no tags exist) +}.standardOutput.asText.map { it.trim() } -version = getGitVersion() +// Apply the git version to your project +version = gitVersionProvider.get() task showGitVersion { doLast { @@ -116,9 +109,6 @@ task showGitVersion { } build { - doFirst { - getGitVersion - } } jar { @@ -181,16 +171,17 @@ task runSimpleFileServer(type: JavaExec) { } dependsOn testClasses classpath sourceSets.test.runtimeClasspath - main "SimpleFileServer" + + // FIX 1: Use 'mainClass' instead of 'main' + // FIX 2: Replace "SimpleFileServer" with the FULLY QUALIFIED class name + // (e.g., if it's in a package named com.example) + mainClass = "com.example.SimpleFileServer" + args = ['fileserver','443','fileserver/logfile.txt'] - // args = ['fileserver','8080','fileserver/logfile.txt'] - javaLauncher = javaToolchains.launcherFor { + + javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(23) } - // debugOptions { - // enabled = true - // suspend = true - // } } task testJar(type: Jar) { @@ -205,7 +196,7 @@ task runAllTests(type: Test) { } publish { - // dependsOn runAllTests + dependsOn runAllTests } publishing { diff --git a/src/main/java/robaho/net/httpserver/Code.java b/src/main/java/robaho/net/httpserver/Code.java index a32a5cc..47c46ec 100644 --- a/src/main/java/robaho/net/httpserver/Code.java +++ b/src/main/java/robaho/net/httpserver/Code.java @@ -28,6 +28,7 @@ public class Code { public static final int HTTP_CONTINUE = 100; + public static final int HTTP_SWITCHING_PROTOCOLS = 101; public static final int HTTP_OK = 200; public static final int HTTP_CREATED = 201; public static final int HTTP_ACCEPTED = 202; @@ -71,6 +72,8 @@ static String msg(int code) { return " OK"; case HTTP_CONTINUE: return " Continue"; + case HTTP_SWITCHING_PROTOCOLS: + return " Switching Protocols"; case HTTP_CREATED: return " Created"; case HTTP_ACCEPTED: diff --git a/src/main/java/robaho/net/httpserver/ExchangeImpl.java b/src/main/java/robaho/net/httpserver/ExchangeImpl.java index 66e366d..7da9ca0 100644 --- a/src/main/java/robaho/net/httpserver/ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/ExchangeImpl.java @@ -40,8 +40,6 @@ import com.sun.net.httpserver.*; -import robaho.net.httpserver.websockets.WebSocketHandler; - class ExchangeImpl { Headers reqHdrs, rspHdrs; @@ -69,7 +67,8 @@ class ExchangeImpl { private static final String HEAD = "HEAD"; private static final String CONNECT = "CONNECT"; - + private static final String HEADER_CONNECTION = "Connection"; + private static final String HEADER_CONNECTION_UPGRADE = "Upgrade"; /* * streams which take care of the HTTP protocol framing * and are passed up to higher layers @@ -85,7 +84,7 @@ class ExchangeImpl { Map attributes; int rcode = -1; HttpPrincipal principal; - final boolean websocket; + boolean connectionUpgraded = false; ExchangeImpl( String m, URI u, Request req, long len, HttpConnection connection) throws IOException { @@ -97,11 +96,6 @@ class ExchangeImpl { this.method = m; this.uri = u; this.connection = connection; - this.websocket = WebSocketHandler.isWebsocketRequested(this.reqHdrs); - if (this.websocket) { - // length is indeterminate - len = -1; - } this.reqContentLen = len; /* ros only used for headers, body written directly to stream */ this.ros = req.outputStream(); @@ -135,6 +129,9 @@ private boolean isHeadRequest() { private boolean isConnectRequest() { return CONNECT.equals(getRequestMethod()); } + private boolean isUpgradeRequest() { + return HEADER_CONNECTION_UPGRADE.equalsIgnoreCase(reqHdrs.getFirst(HEADER_CONNECTION)); + } public void close() { if (closed) { @@ -170,7 +167,7 @@ public InputStream getRequestBody() { if (uis != null) { return uis; } - if (websocket || isConnectRequest()) { + if (connectionUpgraded || isConnectRequest() || isUpgradeRequest()) { // connection cannot be re-used uis = ris; } else if (reqContentLen == -1L) { @@ -232,7 +229,6 @@ public void sendResponseHeaders(int rCode, long contentLen) ros.write(statusLine.getBytes(ISO_CHARSET)); boolean noContentToSend = false; // assume there is content boolean noContentLengthHeader = false; // must not send Content-length is set - rspHdrs.set("Date", ActivityTimer.dateAndTime()); Integer bufferSize = (Integer)this.getAttribute(Attributes.SOCKET_WRITE_BUFFER); if(bufferSize!=null) { @@ -242,19 +238,21 @@ public void sendResponseHeaders(int rCode, long contentLen) boolean flush = false; /* check for response type that is not allowed to send a body */ - if (rCode == 101) { - logger.log(Level.DEBUG, () -> "switching protocols"); - - if (contentLen != 0) { - String msg = "sendResponseHeaders: rCode = " + rCode - + ": forcing contentLen = 0"; - logger.log(Level.WARNING, msg); - } - contentLen = 0; - flush = true; - - } else if ((rCode >= 100 && rCode < 200) /* informational */ - || (rCode == 204) /* no content */ + var informational = rCode >= 100 && rCode < 200; + + if (informational) { + if (rCode == 101) { + logger.log(Level.DEBUG, () -> "switching protocols"); + if (contentLen != 0) { + String msg = "sendResponseHeaders: rCode = " + rCode + + ": forcing contentLen = 0"; + logger.log(Level.WARNING, msg); + contentLen = 0; + } + connectionUpgraded = true; + } + noContentLengthHeader = true; // the Content-length header must not be set for interim responses as they cannot have a body + } else if ((rCode == 204) /* no content */ || (rCode == 304)) /* not modified */ { if (contentLen != -1) { @@ -266,6 +264,10 @@ public void sendResponseHeaders(int rCode, long contentLen) noContentLengthHeader = (rCode != 304); } + if(!informational) { + rspHdrs.set("Date", ActivityTimer.dateAndTime()); + } + if (isHeadRequest() || rCode == 304) { /* * HEAD requests or 304 responses should not set a content length by passing it @@ -278,14 +280,16 @@ public void sendResponseHeaders(int rCode, long contentLen) noContentToSend = true; contentLen = 0; o.setWrappedStream(new FixedLengthOutputStream(this, ros, contentLen)); - } else { /* not a HEAD request or 304 response */ + } else if(informational && !connectionUpgraded) { + // don't want to set the stream for 1xx responses, except 101, the handler must call sendResponseHeaders again with the final code + flush = true; + } else if(connectionUpgraded || isConnectRequest()) { + o.setWrappedStream(ros); + close = true; + flush = true; + } else { /* standard response with possible response data */ if (contentLen == 0) { - if (websocket || isConnectRequest()) { - o.setWrappedStream(ros); - close = true; - flush = true; - } - else if (http10) { + if (http10) { o.setWrappedStream(new UndefLengthOutputStream(this, ros)); close = true; } else { @@ -323,9 +327,9 @@ else if (http10) { writeHeaders(rspHdrs, ros); this.rspContentLen = contentLen; - sentHeaders = true; + sentHeaders = !informational; if(logger.isLoggable(Level.TRACE)) { - logger.log(Level.TRACE, "Sent headers: noContentToSend=" + noContentToSend); + logger.log(Level.TRACE, "sendResponseHeaders(), code="+rCode+", noContentToSend=" + noContentToSend + ", contentLen=" + contentLen); } if(flush) { ros.flush(); diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 77ea234..214ad23 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -862,12 +862,17 @@ void sendReply( builder.append("HTTP/1.1 ") .append(code).append(Code.msg(code)).append("\r\n"); + var informational = (code >= 100 && code < 200); + if (text != null && text.length() != 0) { builder.append("Content-length: ") .append(text.length()).append("\r\n") .append("Content-type: text/html\r\n"); } else { - builder.append("Content-length: 0\r\n"); + if (!informational) { + // no body for 1xx responses + builder.append("Content-length: 0\r\n"); + } text = ""; } if (closeNow) { @@ -898,7 +903,7 @@ void logReply(int code, String requestStr, String text) { } else { r = requestStr; } - logger.log(Level.DEBUG, () -> "reply "+ r + " [" + code + " " + Code.msg(code) + "] (" + (text!=null ? text : "") + ")"); + logger.log(Level.DEBUG, () -> "reply "+ r + " [" + code + Code.msg(code) + "] (" + (text!=null ? text : "") + ")"); } void delay() { diff --git a/src/test/java/InputRead100Test.java b/src/test/java/InputRead100Test.java new file mode 100644 index 0000000..34d142f --- /dev/null +++ b/src/test/java/InputRead100Test.java @@ -0,0 +1,151 @@ +/** + * @test id=default + * @bug 8349670 + * @summary Test 100 continue response handling + * @run junit/othervm InputRead100Test + */ +/** + * @test id=preferIPv6 + * @bug 8349670 + * @summary Test 100 continue response handling ipv6 + * @run junit/othervm -Djava.net.preferIPv6Addresses=true InputRead100Test + */ +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.logging.Level; +import java.util.logging.Logger; + +import com.sun.net.httpserver.HttpServer; + +import org.testng.annotations.Test; + +import static java.nio.charset.StandardCharsets.*; + +public class InputRead100Test { + private static final String someContext = "/context"; + + static { + Logger.getLogger("").setLevel(Level.ALL); + Logger.getLogger("").getHandlers()[0].setLevel(Level.ALL); + } + + @Test + public static void testContinue() throws Exception { + System.out.println("testContinue()"); + InetAddress loopback = InetAddress.getLoopbackAddress(); + HttpServer server = HttpServer.create(new InetSocketAddress(loopback, 0), 0); + try { + server.createContext( + someContext, + msg -> { + System.err.println("Handling request: " + msg.getRequestURI()); + byte[] reply = "Here is my reply!".getBytes(UTF_8); + try { + msg.getRequestBody().readAllBytes(); + msg.sendResponseHeaders(200, reply.length); + msg.getResponseBody().write(reply); + msg.getResponseBody().close(); + } finally { + System.err.println("Request handled: " + msg.getRequestURI()); + } + }); + server.start(); + System.out.println("Server started at port " + server.getAddress().getPort()); + + runRawSocketHttpClient(loopback, server.getAddress().getPort(), 0); + } finally { + System.out.println("shutting server down"); + server.stop(0); + } + System.out.println("Server finished."); + } + + static void runRawSocketHttpClient(InetAddress address, int port, int contentLength) + throws Exception { + Socket socket = null; + PrintWriter writer = null; + BufferedReader reader = null; + + boolean foundContinue = false; + + final String CRLF = "\r\n"; + try { + socket = new Socket(address, port); + writer = new PrintWriter(new OutputStreamWriter(socket.getOutputStream())); + System.out.println("Client connected by socket: " + socket); + String body = "I will send all the data."; + if (contentLength <= 0) contentLength = body.getBytes(UTF_8).length; + + writer.print("GET " + someContext + "/ HTTP/1.1" + CRLF); + writer.print("User-Agent: Java/" + System.getProperty("java.version") + CRLF); + writer.print("Host: " + address.getHostName() + CRLF); + writer.print("Accept: */*" + CRLF); + writer.print("Content-Length: " + contentLength + CRLF); + writer.print("Connection: keep-alive" + CRLF); + writer.print("Expect: 100-continue" + CRLF); + writer.print(CRLF); // Important, else the server will expect that + // there's more into the request. + writer.flush(); + System.out.println("Client wrote request to socket: " + socket); + System.out.println("Client read 100 Continue response from server and headers"); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + String line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("interim response \"" + line + "\""); + if (line.startsWith("HTTP/1.1 100")) { + foundContinue = true; + } + } + if (!foundContinue) { + throw new IOException("Did not receive 100 continue from server"); + } + writer.print(body); + writer.flush(); + System.out.println("Client wrote body to socket: " + socket); + + System.out.println("Client start reading from server:"); + line = reader.readLine(); + for (; line != null; line = reader.readLine()) { + if (line.isEmpty()) { + break; + } + System.out.println("final response \"" + line + "\""); + } + System.out.println("Client finished reading from server"); + } finally { + // give time to the server to try & drain its input stream + Thread.sleep(500); + // closes the client outputstream while the server is draining + // it + if (writer != null) { + writer.close(); + } + // give time to the server to trigger its assertion + // error before closing the connection + Thread.sleep(500); + if (reader != null) + try { + reader.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + if (socket != null) { + try { + socket.close(); + } catch (IOException logOrIgnore) { + logOrIgnore.printStackTrace(); + } + } + } + System.out.println("Client finished."); + } +}